Added user profile page, prepped for TOTP 2fa
This commit is contained in:
@@ -9,7 +9,7 @@ const defaultAllowedOrigin = "http://localhost:5173"
|
||||
func withCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
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")
|
||||
|
||||
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("POST /api/v1/groups", groupsUpsertHandler(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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,31 @@ type Group struct {
|
||||
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.
|
||||
type Settings struct {
|
||||
LogLevel string `json:"log_level"`
|
||||
Groups []Group `json:"groups"`
|
||||
Security SecuritySettings `json:"security"`
|
||||
}
|
||||
|
||||
// Secrets holds sensitive configuration encrypted at rest.
|
||||
|
||||
+21
-2
@@ -52,6 +52,9 @@
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--ctp-surface0);
|
||||
}
|
||||
@@ -89,14 +92,23 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
.config-item,
|
||||
.profile-item {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cog-icon {
|
||||
.cog-icon,
|
||||
.user-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 2rem 2.5rem;
|
||||
@@ -259,6 +271,13 @@
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.security-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.groups-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
+82
-2
@@ -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({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
@@ -95,6 +110,7 @@ describe('App', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Configuration' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Profile' })).toBeInTheDocument()
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Overview', level: 2 }),
|
||||
@@ -102,6 +118,38 @@ describe('App', () => {
|
||||
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 () => {
|
||||
const user = userEvent.setup()
|
||||
stubFetchOk()
|
||||
@@ -215,9 +263,18 @@ describe('App', () => {
|
||||
'aria-selected',
|
||||
'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(
|
||||
screen.getByText(/Service SSH keys and related credentials/i),
|
||||
).toBeInTheDocument()
|
||||
screen.getByLabelText('Re-auth for sensitive actions'),
|
||||
).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()
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Integrations' }))
|
||||
@@ -228,6 +285,29 @@ describe('App', () => {
|
||||
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 () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
|
||||
+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>
|
||||
)}
|
||||
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
deleteGroup,
|
||||
fetchGroups,
|
||||
fetchHealth,
|
||||
fetchSecurity,
|
||||
fetchStatus,
|
||||
getApiBaseUrl,
|
||||
saveSecurity,
|
||||
upsertGroup,
|
||||
} 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,26 @@ export type GroupsResponse = {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ describe('pathFor', () => {
|
||||
expect(pathFor('overview')).toBe('/')
|
||||
expect(pathFor('vms')).toBe('/vms')
|
||||
expect(pathFor('containers')).toBe('/containers')
|
||||
expect(pathFor('profile')).toBe('/profile')
|
||||
expect(pathFor('configuration')).toBe('/configuration')
|
||||
expect(pathFor('configuration', 'overview')).toBe('/configuration')
|
||||
expect(pathFor('configuration', 'groups')).toBe('/configuration/groups')
|
||||
@@ -19,6 +20,7 @@ describe('parseLocation', () => {
|
||||
'/',
|
||||
'/vms',
|
||||
'/containers',
|
||||
'/profile',
|
||||
'/configuration',
|
||||
'/configuration/users',
|
||||
'/configuration/roles',
|
||||
|
||||
+13
-1
@@ -1,4 +1,9 @@
|
||||
export type SectionId = 'overview' | 'vms' | 'containers' | 'configuration'
|
||||
export type SectionId =
|
||||
| 'overview'
|
||||
| 'vms'
|
||||
| 'containers'
|
||||
| 'profile'
|
||||
| 'configuration'
|
||||
|
||||
export type ConfigTabId =
|
||||
| 'overview'
|
||||
@@ -54,6 +59,10 @@ export function parseLocation(pathname: string): AppLocation {
|
||||
return { section: 'containers', configTab: 'overview' }
|
||||
}
|
||||
|
||||
if (first === 'profile' && segments.length === 1) {
|
||||
return { section: 'profile', configTab: 'overview' }
|
||||
}
|
||||
|
||||
if (first === 'configuration') {
|
||||
if (segments.length === 1) {
|
||||
return { section: 'configuration', configTab: 'overview' }
|
||||
@@ -80,6 +89,9 @@ export function pathFor(
|
||||
if (section === 'containers') {
|
||||
return '/containers'
|
||||
}
|
||||
if (section === 'profile') {
|
||||
return '/profile'
|
||||
}
|
||||
if (configTab === 'overview') {
|
||||
return '/configuration'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user