Added user profile page, prepped for TOTP 2fa

This commit is contained in:
2026-07-16 22:13:54 +02:00
parent 63deb02d39
commit c8f47768a3
11 changed files with 850 additions and 15 deletions
+311 -7
View File
@@ -1,10 +1,14 @@
import { useEffect, useState } from 'react'
import {
deleteGroup,
DEFAULT_SECURITY_SETTINGS,
fetchGroups,
fetchSecurity,
fetchStatus,
type Group,
type GroupScopeKind,
type SecuritySettings,
saveSecurity,
upsertGroup,
} from './api/client'
import {
@@ -34,6 +38,7 @@ const SECTION_TITLES: Record<SectionId, string> = {
overview: 'Overview',
vms: 'VMs',
containers: 'Containers',
profile: 'Profile',
configuration: 'Configuration',
}
@@ -77,6 +82,36 @@ function CogIcon() {
)
}
function UserIcon() {
return (
<svg
className="user-icon"
viewBox="0 0 24 24"
width="18"
height="18"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12Zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v1.2c0 .7.5 1.2 1.2 1.2h16.8c.7 0 1.2-.5 1.2-1.2v-1.2c0-3.2-6.4-4.8-9.6-4.8Z"
/>
</svg>
)
}
function ProfilePanel() {
return (
<div className="profile-panel">
<p className="config-empty">
Your account settings will live here enroll TOTP when the
administrator has enabled it, and manage other profile details.
</p>
<p className="config-hint">Coming soon</p>
</div>
)
}
function useThemePreference() {
const [themePreference, setThemePreference] =
useState<ThemePreference>(readThemePreference)
@@ -345,6 +380,259 @@ function GroupsConfigTab() {
)
}
function validateSecurityDraft(security: SecuritySettings): string | null {
if (
!Number.isInteger(security.idle_timeout_minutes) ||
security.idle_timeout_minutes < 1 ||
security.idle_timeout_minutes > 240
) {
return 'Idle timeout must be an integer between 1 and 240 minutes.'
}
if (
!Number.isInteger(security.session_lifetime_hours) ||
security.session_lifetime_hours < 1
) {
return 'Session lifetime must be an integer of at least 1 hour.'
}
if (
!Number.isInteger(security.reauth_grace_minutes) ||
security.reauth_grace_minutes < 0
) {
return 'Re-auth grace period must be an integer of at least 0 minutes.'
}
return null
}
function parsePositiveIntegerInput(raw: string): number | null {
if (raw.trim() === '') {
return null
}
const parsed = Number(raw)
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
return null
}
return parsed
}
function SecurityConfigTab() {
const [draft, setDraft] = useState<SecuritySettings>(DEFAULT_SECURITY_SETTINGS)
const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const [saveSucceeded, setSaveSucceeded] = useState(false)
useEffect(() => {
let isCancelled = false
async function loadSecurity() {
setIsLoading(true)
setLoadError(null)
try {
const response = await fetchSecurity()
if (!isCancelled) {
setDraft(response.security)
}
} catch (err) {
if (!isCancelled) {
setLoadError(
err instanceof Error ? err.message : 'Failed to load security settings',
)
}
} finally {
if (!isCancelled) {
setIsLoading(false)
}
}
}
void loadSecurity()
return () => {
isCancelled = true
}
}, [])
async function handleSave() {
setSubmitError(null)
setSaveSucceeded(false)
const validationError = validateSecurityDraft(draft)
if (validationError) {
setSubmitError(validationError)
return
}
try {
const response = await saveSecurity(draft)
setDraft(response.security)
setSaveSucceeded(true)
} catch (err) {
setSubmitError(
err instanceof Error ? err.message : 'Failed to save security settings',
)
}
}
if (isLoading) {
return <p className="config-empty">Loading</p>
}
if (loadError) {
return (
<p className="config-empty" role="alert">
{loadError}
</p>
)
}
return (
<div className="security-panel">
<p className="config-hint">
Session and re-authentication policy for administrators.
</p>
{submitError ? (
<p className="config-empty groups-error" role="alert">
{submitError}
</p>
) : null}
{saveSucceeded ? (
<p className="config-hint" role="status">
Security settings saved.
</p>
) : null}
<div className="config-field">
<label htmlFor="idle-timeout">Idle timeout (minutes)</label>
<input
id="idle-timeout"
className="config-input"
type="number"
min={1}
max={240}
step={1}
value={draft.idle_timeout_minutes}
onChange={(event) => {
const parsed = parsePositiveIntegerInput(event.target.value)
if (parsed === null) {
setDraft((prev) => ({ ...prev, idle_timeout_minutes: 0 }))
return
}
setDraft((prev) => ({ ...prev, idle_timeout_minutes: parsed }))
setSaveSucceeded(false)
}}
/>
<p className="config-hint">1240 minutes (max 4 hours). Default 30.</p>
</div>
<div className="config-field">
<label htmlFor="session-lifetime">Session lifetime (hours)</label>
<input
id="session-lifetime"
className="config-input"
type="number"
min={1}
step={1}
value={draft.session_lifetime_hours}
onChange={(event) => {
const parsed = parsePositiveIntegerInput(event.target.value)
if (parsed === null) {
setDraft((prev) => ({ ...prev, session_lifetime_hours: 0 }))
return
}
setDraft((prev) => ({ ...prev, session_lifetime_hours: parsed }))
setSaveSucceeded(false)
}}
/>
<p className="config-hint">At least 1 hour. Default 24.</p>
</div>
<div className="config-field">
<label className="permission-option" htmlFor="totp-enabled">
<input
id="totp-enabled"
type="checkbox"
checked={draft.totp_enabled}
onChange={(event) => {
setDraft((prev) => ({
...prev,
totp_enabled: event.target.checked,
}))
setSaveSucceeded(false)
}}
/>
Enable TOTP
</label>
<p className="config-hint">
When enabled, users will be able to use authenticator apps (enrollment
comes later).
</p>
</div>
<div className="config-field">
<label className="permission-option" htmlFor="reauth-sensitive">
<input
id="reauth-sensitive"
type="checkbox"
checked={draft.reauth_sensitive_actions}
onChange={(event) => {
setDraft((prev) => ({
...prev,
reauth_sensitive_actions: event.target.checked,
}))
setSaveSucceeded(false)
}}
/>
Re-auth for sensitive actions
</label>
</div>
<div className="config-field">
<label htmlFor="reauth-grace">Re-auth grace period (minutes)</label>
<input
id="reauth-grace"
className="config-input"
type="number"
min={0}
step={1}
disabled={!draft.reauth_sensitive_actions}
value={draft.reauth_grace_minutes}
onChange={(event) => {
const raw = event.target.value
if (raw.trim() === '') {
setDraft((prev) => ({ ...prev, reauth_grace_minutes: 0 }))
return
}
const parsed = Number(raw)
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
return
}
setDraft((prev) => ({ ...prev, reauth_grace_minutes: parsed }))
setSaveSucceeded(false)
}}
/>
<p className="config-hint">
Skip re-auth if done within this many minutes. 0 means re-auth for
every sensitive action. Default 15.
</p>
</div>
<button
type="button"
className="groups-save"
onClick={() => void handleSave()}
>
Save security settings
</button>
<p className="config-hint">
Service SSH keys and related credentials will also live here later
add keys with a description and an expiry time limit or non-expiry.
</p>
</div>
)
}
function ConfigTabPanelContent({
activeConfigTab,
draftThemePreference,
@@ -385,12 +673,7 @@ function ConfigTabPanelContent({
}
if (activeConfigTab === 'security') {
return (
<p className="config-empty">
Service SSH keys and related credentials will live here add keys with
a description and an expiry time limit or non-expiry.
</p>
)
return <SecurityConfigTab />
}
return <p className="config-empty">Coming soon</p>
@@ -484,9 +767,13 @@ function App() {
const [activeConfigTab, setActiveConfigTab] = useState<ConfigTabId>(
initialLocation.configTab,
)
// Stub until auth provides the logged-in display name.
const [profileDisplayName] = useState<string | null>(null)
const [backendVersion, setBackendVersion] = useState('…')
const { themePreference, updateThemePreference } = useThemePreference()
const profileLabel = profileDisplayName ?? 'Profile'
useEffect(() => {
return subscribeToLocation((pathname) => {
const location = parseLocation(pathname)
@@ -561,6 +848,19 @@ function App() {
</nav>
<div className="sidebar-footer">
<button
type="button"
className={
activeSection === 'profile'
? 'nav-item nav-item-active profile-item'
: 'nav-item profile-item'
}
aria-current={activeSection === 'profile' ? 'page' : undefined}
onClick={() => goToSection('profile')}
>
<UserIcon />
{profileLabel}
</button>
<button
type="button"
className={
@@ -580,7 +880,9 @@ function App() {
</aside>
<main className="content" aria-label={SECTION_TITLES[activeSection]}>
<h2>{SECTION_TITLES[activeSection]}</h2>
<h2>
{activeSection === 'profile' ? profileLabel : SECTION_TITLES[activeSection]}
</h2>
{activeSection === 'configuration' ? (
<ConfigurationPanel
activeConfigTab={activeConfigTab}
@@ -588,6 +890,8 @@ function App() {
themePreference={themePreference}
onThemePreferenceChange={updateThemePreference}
/>
) : activeSection === 'profile' ? (
<ProfilePanel />
) : (
<p>Coming soon</p>
)}