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
+3 -1
View File
@@ -9,7 +9,7 @@ const defaultAllowedOrigin = "http://localhost:5173"
func withCORS(next http.Handler) http.Handler { func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Access-Control-Allow-Origin", defaultAllowedOrigin) writer.Header().Set("Access-Control-Allow-Origin", defaultAllowedOrigin)
writer.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type") writer.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if request.Method == http.MethodOptions { if request.Method == http.MethodOptions {
@@ -28,5 +28,7 @@ func NewRouter(configDir string) http.Handler {
mux.HandleFunc("GET /api/v1/groups", groupsGetHandler(configDir)) mux.HandleFunc("GET /api/v1/groups", groupsGetHandler(configDir))
mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir)) mux.HandleFunc("POST /api/v1/groups", groupsUpsertHandler(configDir))
mux.HandleFunc("DELETE /api/v1/groups", groupsDeleteHandler(configDir)) mux.HandleFunc("DELETE /api/v1/groups", groupsDeleteHandler(configDir))
mux.HandleFunc("GET /api/v1/security", securityGetHandler(configDir))
mux.HandleFunc("PUT /api/v1/security", securityPutHandler(configDir))
return withCORS(mux) return withCORS(mux)
} }
+98
View File
@@ -0,0 +1,98 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
const (
minIdleTimeoutMinutes = 1
maxIdleTimeoutMinutes = 240 // 4 hours
minSessionLifetimeHours = 1
minReauthGraceMinutes = 0
)
type securityResponse struct {
Security settings.SecuritySettings `json:"security"`
}
type putSecurityRequest struct {
Security settings.SecuritySettings `json:"security"`
}
func securityGetHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, securityResponse{
Security: effectiveSecurity(settingsPayload.Security),
})
}
}
func securityPutHandler(configDir string) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if err := authorizeSecurityWrite(request); err != nil {
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
return
}
var payload putSecurityRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "invalid JSON body"})
return
}
if err := validateSecuritySettings(payload.Security); err != nil {
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
return
}
settingsPayload, err := loadSettingsOrDefault(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
settingsPayload.Security = payload.Security
if err := settings.SaveSettings(configDir, settingsPayload); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
writeJSON(writer, http.StatusOK, securityResponse{Security: settingsPayload.Security})
}
}
func authorizeSecurityWrite(_ *http.Request) error {
// TODO: integrate authz middleware.
return nil
}
func effectiveSecurity(security settings.SecuritySettings) settings.SecuritySettings {
if err := validateSecuritySettings(security); err != nil {
return settings.DefaultSecuritySettings()
}
return security
}
func validateSecuritySettings(security settings.SecuritySettings) error {
if security.IdleTimeoutMinutes < minIdleTimeoutMinutes ||
security.IdleTimeoutMinutes > maxIdleTimeoutMinutes {
return errors.New("idle_timeout_minutes must be between 1 and 240")
}
if security.SessionLifetimeHours < minSessionLifetimeHours {
return errors.New("session_lifetime_hours must be at least 1")
}
if security.ReauthGraceMinutes < minReauthGraceMinutes {
return errors.New("reauth_grace_minutes must be at least 0")
}
return nil
}
@@ -0,0 +1,157 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
)
type securityResponseTest struct {
Security settings.SecuritySettings `json:"security"`
}
func TestSecurityGetReturnsDefaultsWhenMissing(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
router := NewRouter(configDir)
request := httptest.NewRequest(http.MethodGet, "/api/v1/security", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
}
var payload securityResponseTest
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
want := settings.DefaultSecuritySettings()
if payload.Security != want {
t.Fatalf("got %#v, want %#v", payload.Security, want)
}
}
func TestSecurityPutRejectsIdleTimeoutZero(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
router := NewRouter(configDir)
body := []byte(`{
"security": {
"idle_timeout_minutes": 0,
"session_lifetime_hours": 24,
"reauth_sensitive_actions": false,
"reauth_grace_minutes": 15
}
}`)
request := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
func TestSecurityPutRejectsIdleTimeoutAboveMax(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
router := NewRouter(configDir)
body := []byte(`{
"security": {
"idle_timeout_minutes": 241,
"session_lifetime_hours": 24,
"reauth_sensitive_actions": false,
"reauth_grace_minutes": 15
}
}`)
request := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
func TestSecurityPutRejectsSessionLifetimeZero(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
router := NewRouter(configDir)
body := []byte(`{
"security": {
"idle_timeout_minutes": 30,
"session_lifetime_hours": 0,
"reauth_sensitive_actions": false,
"reauth_grace_minutes": 15
}
}`)
request := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, recorder.Code)
}
}
func TestSecurityPutRoundTrip(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
router := NewRouter(configDir)
body := []byte(`{
"security": {
"idle_timeout_minutes": 45,
"session_lifetime_hours": 12,
"reauth_sensitive_actions": true,
"reauth_grace_minutes": 0,
"totp_enabled": true
}
}`)
putReq := httptest.NewRequest(http.MethodPut, "/api/v1/security", bytes.NewReader(body))
putRec := httptest.NewRecorder()
router.ServeHTTP(putRec, putReq)
if putRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, putRec.Code)
}
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/security", nil)
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, getRec.Code)
}
var payload securityResponseTest
if err := json.NewDecoder(getRec.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
want := settings.SecuritySettings{
IdleTimeoutMinutes: 45,
SessionLifetimeHours: 12,
ReauthSensitiveActions: true,
ReauthGraceMinutes: 0,
TotpEnabled: true,
}
if payload.Security != want {
t.Fatalf("got %#v, want %#v", payload.Security, want)
}
}
+21
View File
@@ -18,10 +18,31 @@ type Group struct {
Permissions []string `json:"permissions"` Permissions []string `json:"permissions"`
} }
// SecuritySettings holds session and re-auth policy for the admin UI / auth middleware.
type SecuritySettings struct {
IdleTimeoutMinutes int `json:"idle_timeout_minutes"`
SessionLifetimeHours int `json:"session_lifetime_hours"`
ReauthSensitiveActions bool `json:"reauth_sensitive_actions"`
ReauthGraceMinutes int `json:"reauth_grace_minutes"`
TotpEnabled bool `json:"totp_enabled"`
}
// DefaultSecuritySettings returns the built-in security defaults.
func DefaultSecuritySettings() SecuritySettings {
return SecuritySettings{
IdleTimeoutMinutes: 30,
SessionLifetimeHours: 24,
ReauthSensitiveActions: false,
ReauthGraceMinutes: 15,
TotpEnabled: false,
}
}
// Settings holds plain (non-secret) base/CLI configuration. // Settings holds plain (non-secret) base/CLI configuration.
type Settings struct { type Settings struct {
LogLevel string `json:"log_level"` LogLevel string `json:"log_level"`
Groups []Group `json:"groups"` Groups []Group `json:"groups"`
Security SecuritySettings `json:"security"`
} }
// Secrets holds sensitive configuration encrypted at rest. // Secrets holds sensitive configuration encrypted at rest.
+21 -2
View File
@@ -52,6 +52,9 @@
} }
.sidebar-footer { .sidebar-footer {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem; padding: 0.75rem;
border-top: 1px solid var(--ctp-surface0); border-top: 1px solid var(--ctp-surface0);
} }
@@ -89,14 +92,23 @@
font-weight: 600; font-weight: 600;
} }
.config-item { .config-item,
.profile-item {
font-weight: 500; font-weight: 500;
} }
.cog-icon { .cog-icon,
.user-icon {
flex-shrink: 0; flex-shrink: 0;
} }
.profile-panel {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 40rem;
}
.content { .content {
flex: 1; flex: 1;
padding: 2rem 2.5rem; padding: 2rem 2.5rem;
@@ -259,6 +271,13 @@
max-width: 40rem; max-width: 40rem;
} }
.security-panel {
display: flex;
flex-direction: column;
gap: 0.9rem;
max-width: 40rem;
}
.groups-list { .groups-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+82 -2
View File
@@ -34,6 +34,21 @@ function stubFetchOk() {
}) })
} }
if (url.includes('/api/v1/security')) {
return Promise.resolve({
ok: true,
json: async () => ({
security: {
idle_timeout_minutes: 30,
session_lifetime_hours: 24,
reauth_sensitive_actions: false,
reauth_grace_minutes: 15,
totp_enabled: false,
},
}),
})
}
return Promise.resolve({ return Promise.resolve({
ok: true, ok: true,
json: async () => ({}), json: async () => ({}),
@@ -95,6 +110,7 @@ describe('App', () => {
expect( expect(
screen.getByRole('button', { name: 'Configuration' }), screen.getByRole('button', { name: 'Configuration' }),
).toBeInTheDocument() ).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Profile' })).toBeInTheDocument()
expect( expect(
screen.getByRole('heading', { name: 'Overview', level: 2 }), screen.getByRole('heading', { name: 'Overview', level: 2 }),
@@ -102,6 +118,38 @@ describe('App', () => {
expect(screen.getByText('Coming soon')).toBeInTheDocument() expect(screen.getByText('Coming soon')).toBeInTheDocument()
}) })
it('opens Profile from the sidebar and updates the URL', async () => {
const user = userEvent.setup()
stubFetchOk()
render(<App />)
await user.click(screen.getByRole('button', { name: 'Profile' }))
expect(
screen.getByRole('heading', { name: 'Profile', level: 2 }),
).toBeInTheDocument()
expect(window.location.pathname).toBe('/profile')
expect(
screen.getByText(/Your account settings will live here/i),
).toBeInTheDocument()
})
it('restores Profile from the URL on load', async () => {
stubFetchOk()
window.history.replaceState(null, '', '/profile')
render(<App />)
expect(
screen.getByRole('heading', { name: 'Profile', level: 2 }),
).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Profile' })).toHaveAttribute(
'aria-current',
'page',
)
})
it('switches the content panel when a nav item is selected', async () => { it('switches the content panel when a nav item is selected', async () => {
const user = userEvent.setup() const user = userEvent.setup()
stubFetchOk() stubFetchOk()
@@ -215,9 +263,18 @@ describe('App', () => {
'aria-selected', 'aria-selected',
'true', 'true',
) )
expect(await screen.findByLabelText('Idle timeout (minutes)')).toHaveValue(
30,
)
expect(screen.getByLabelText('Session lifetime (hours)')).toHaveValue(24)
expect(screen.getByLabelText('Enable TOTP')).not.toBeChecked()
expect( expect(
screen.getByText(/Service SSH keys and related credentials/i), screen.getByLabelText('Re-auth for sensitive actions'),
).toBeInTheDocument() ).not.toBeChecked()
expect(screen.getByLabelText('Re-auth grace period (minutes)')).toHaveValue(
15,
)
expect(screen.getByLabelText('Re-auth grace period (minutes)')).toBeDisabled()
expect(screen.queryByLabelText('Theme')).not.toBeInTheDocument() expect(screen.queryByLabelText('Theme')).not.toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: 'Integrations' })) await user.click(screen.getByRole('tab', { name: 'Integrations' }))
@@ -228,6 +285,29 @@ describe('App', () => {
expect(screen.getByText('Coming soon')).toBeInTheDocument() expect(screen.getByText('Coming soon')).toBeInTheDocument()
}) })
it('rejects invalid idle timeout on Security save', async () => {
const user = userEvent.setup()
stubFetchOk()
render(<App />)
await user.click(screen.getByRole('button', { name: 'Configuration' }))
await user.click(screen.getByRole('tab', { name: 'Security' }))
const idleInput = await screen.findByLabelText('Idle timeout (minutes)')
await user.clear(idleInput)
await user.type(idleInput, '0')
await user.click(
screen.getByRole('button', { name: 'Save security settings' }),
)
expect(
await screen.findByText(
/Idle timeout must be an integer between 1 and 240 minutes/i,
),
).toBeInTheDocument()
})
it('shows unavailable when backend status fetch fails', async () => { it('shows unavailable when backend status fetch fails', async () => {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
+311 -7
View File
@@ -1,10 +1,14 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
deleteGroup, deleteGroup,
DEFAULT_SECURITY_SETTINGS,
fetchGroups, fetchGroups,
fetchSecurity,
fetchStatus, fetchStatus,
type Group, type Group,
type GroupScopeKind, type GroupScopeKind,
type SecuritySettings,
saveSecurity,
upsertGroup, upsertGroup,
} from './api/client' } from './api/client'
import { import {
@@ -34,6 +38,7 @@ const SECTION_TITLES: Record<SectionId, string> = {
overview: 'Overview', overview: 'Overview',
vms: 'VMs', vms: 'VMs',
containers: 'Containers', containers: 'Containers',
profile: 'Profile',
configuration: 'Configuration', 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() { function useThemePreference() {
const [themePreference, setThemePreference] = const [themePreference, setThemePreference] =
useState<ThemePreference>(readThemePreference) 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({ function ConfigTabPanelContent({
activeConfigTab, activeConfigTab,
draftThemePreference, draftThemePreference,
@@ -385,12 +673,7 @@ function ConfigTabPanelContent({
} }
if (activeConfigTab === 'security') { if (activeConfigTab === 'security') {
return ( return <SecurityConfigTab />
<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 <p className="config-empty">Coming soon</p> return <p className="config-empty">Coming soon</p>
@@ -484,9 +767,13 @@ function App() {
const [activeConfigTab, setActiveConfigTab] = useState<ConfigTabId>( const [activeConfigTab, setActiveConfigTab] = useState<ConfigTabId>(
initialLocation.configTab, initialLocation.configTab,
) )
// Stub until auth provides the logged-in display name.
const [profileDisplayName] = useState<string | null>(null)
const [backendVersion, setBackendVersion] = useState('…') const [backendVersion, setBackendVersion] = useState('…')
const { themePreference, updateThemePreference } = useThemePreference() const { themePreference, updateThemePreference } = useThemePreference()
const profileLabel = profileDisplayName ?? 'Profile'
useEffect(() => { useEffect(() => {
return subscribeToLocation((pathname) => { return subscribeToLocation((pathname) => {
const location = parseLocation(pathname) const location = parseLocation(pathname)
@@ -561,6 +848,19 @@ function App() {
</nav> </nav>
<div className="sidebar-footer"> <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 <button
type="button" type="button"
className={ className={
@@ -580,7 +880,9 @@ function App() {
</aside> </aside>
<main className="content" aria-label={SECTION_TITLES[activeSection]}> <main className="content" aria-label={SECTION_TITLES[activeSection]}>
<h2>{SECTION_TITLES[activeSection]}</h2> <h2>
{activeSection === 'profile' ? profileLabel : SECTION_TITLES[activeSection]}
</h2>
{activeSection === 'configuration' ? ( {activeSection === 'configuration' ? (
<ConfigurationPanel <ConfigurationPanel
activeConfigTab={activeConfigTab} activeConfigTab={activeConfigTab}
@@ -588,6 +890,8 @@ function App() {
themePreference={themePreference} themePreference={themePreference}
onThemePreferenceChange={updateThemePreference} onThemePreferenceChange={updateThemePreference}
/> />
) : activeSection === 'profile' ? (
<ProfilePanel />
) : ( ) : (
<p>Coming soon</p> <p>Coming soon</p>
)} )}
+93
View File
@@ -3,8 +3,10 @@ import {
deleteGroup, deleteGroup,
fetchGroups, fetchGroups,
fetchHealth, fetchHealth,
fetchSecurity,
fetchStatus, fetchStatus,
getApiBaseUrl, getApiBaseUrl,
saveSecurity,
upsertGroup, upsertGroup,
} from './client' } from './client'
@@ -209,3 +211,94 @@ describe('deleteGroup', () => {
) )
}) })
}) })
describe('fetchSecurity', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('returns parsed security payload', async () => {
const security = {
idle_timeout_minutes: 30,
session_lifetime_hours: 24,
reauth_sensitive_actions: false,
reauth_grace_minutes: 15,
totp_enabled: false,
}
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ security }),
})
const payload = await fetchSecurity(fetchMock)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/security',
)
expect(payload.security).toEqual(security)
})
it('throws when the response is not ok', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 503,
json: async () => ({}),
})
await expect(fetchSecurity(fetchMock)).rejects.toThrow(
'security fetch failed: 503',
)
})
})
describe('saveSecurity', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('sends JSON body and returns parsed security', async () => {
const security = {
idle_timeout_minutes: 45,
session_lifetime_hours: 12,
reauth_sensitive_actions: true,
reauth_grace_minutes: 0,
totp_enabled: true,
}
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ security }),
})
await saveSecurity(security, fetchMock)
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/security',
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ security }),
},
)
})
it('throws when the response is not ok', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({}),
})
await expect(
saveSecurity(
{
idle_timeout_minutes: 0,
session_lifetime_hours: 24,
reauth_sensitive_actions: false,
reauth_grace_minutes: 15,
totp_enabled: false,
},
fetchMock,
),
).rejects.toThrow('security save failed: 400')
})
})
+47
View File
@@ -20,6 +20,26 @@ export type GroupsResponse = {
groups: ReadonlyArray<Group> groups: ReadonlyArray<Group>
} }
export type SecuritySettings = {
idle_timeout_minutes: number
session_lifetime_hours: number
reauth_sensitive_actions: boolean
reauth_grace_minutes: number
totp_enabled: boolean
}
export type SecurityResponse = {
security: SecuritySettings
}
export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = {
idle_timeout_minutes: 30,
session_lifetime_hours: 24,
reauth_sensitive_actions: false,
reauth_grace_minutes: 15,
totp_enabled: false,
}
export function getApiBaseUrl(): string { export function getApiBaseUrl(): string {
return import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080' return import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080'
} }
@@ -86,3 +106,30 @@ export async function deleteGroup(
return (await response.json()) as GroupsResponse return (await response.json()) as GroupsResponse
} }
export async function fetchSecurity(
fetchImpl: typeof fetch = fetch,
): Promise<SecurityResponse> {
const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/security`)
if (!response.ok) {
throw new Error(`security fetch failed: ${response.status}`)
}
return (await response.json()) as SecurityResponse
}
export async function saveSecurity(
security: SecuritySettings,
fetchImpl: typeof fetch = fetch,
): Promise<SecurityResponse> {
const response = await fetchImpl(`${getApiBaseUrl()}/api/v1/security`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ security }),
})
if (!response.ok) {
throw new Error(`security save failed: ${response.status}`)
}
return (await response.json()) as SecurityResponse
}
+2
View File
@@ -6,6 +6,7 @@ describe('pathFor', () => {
expect(pathFor('overview')).toBe('/') expect(pathFor('overview')).toBe('/')
expect(pathFor('vms')).toBe('/vms') expect(pathFor('vms')).toBe('/vms')
expect(pathFor('containers')).toBe('/containers') expect(pathFor('containers')).toBe('/containers')
expect(pathFor('profile')).toBe('/profile')
expect(pathFor('configuration')).toBe('/configuration') expect(pathFor('configuration')).toBe('/configuration')
expect(pathFor('configuration', 'overview')).toBe('/configuration') expect(pathFor('configuration', 'overview')).toBe('/configuration')
expect(pathFor('configuration', 'groups')).toBe('/configuration/groups') expect(pathFor('configuration', 'groups')).toBe('/configuration/groups')
@@ -19,6 +20,7 @@ describe('parseLocation', () => {
'/', '/',
'/vms', '/vms',
'/containers', '/containers',
'/profile',
'/configuration', '/configuration',
'/configuration/users', '/configuration/users',
'/configuration/roles', '/configuration/roles',
+13 -1
View File
@@ -1,4 +1,9 @@
export type SectionId = 'overview' | 'vms' | 'containers' | 'configuration' export type SectionId =
| 'overview'
| 'vms'
| 'containers'
| 'profile'
| 'configuration'
export type ConfigTabId = export type ConfigTabId =
| 'overview' | 'overview'
@@ -54,6 +59,10 @@ export function parseLocation(pathname: string): AppLocation {
return { section: 'containers', configTab: 'overview' } return { section: 'containers', configTab: 'overview' }
} }
if (first === 'profile' && segments.length === 1) {
return { section: 'profile', configTab: 'overview' }
}
if (first === 'configuration') { if (first === 'configuration') {
if (segments.length === 1) { if (segments.length === 1) {
return { section: 'configuration', configTab: 'overview' } return { section: 'configuration', configTab: 'overview' }
@@ -80,6 +89,9 @@ export function pathFor(
if (section === 'containers') { if (section === 'containers') {
return '/containers' return '/containers'
} }
if (section === 'profile') {
return '/profile'
}
if (configTab === 'overview') { if (configTab === 'overview') {
return '/configuration' return '/configuration'
} }