Added user profile page, prepped for TOTP 2fa
This commit is contained in:
+311
-7
@@ -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">1–240 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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user