From ccdc0801814fcad40c4d8370f8f3c2f7f27e58dc Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 29 Sep 2025 15:52:03 +0000 Subject: [PATCH] Improve security settings UX and fix alerts typing --- frontend-modern/src/App.tsx | 5 +- .../src/components/Settings/OIDCPanel.tsx | 10 ++ .../Settings/SecurityPostureSummary.tsx | 157 ++++++++++++++++ .../src/components/Settings/Settings.tsx | 167 ++++++++++++++++-- frontend-modern/src/pages/Alerts.tsx | 31 ++-- frontend-modern/src/types/config.ts | 14 +- internal/api/router.go | 10 +- 7 files changed, 363 insertions(+), 31 deletions(-) create mode 100644 frontend-modern/src/components/Settings/SecurityPostureSummary.tsx diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 64badb006..eb8ab1b54 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -47,7 +47,10 @@ export const useDarkMode = () => { function App() { const owner = getOwner(); - const acquireWsStore = () => (owner ? runWithOwner(owner, () => getGlobalWebSocketStore()) : getGlobalWebSocketStore()); + const acquireWsStore = (): EnhancedStore => { + const store = owner ? runWithOwner(owner, () => getGlobalWebSocketStore()) : getGlobalWebSocketStore(); + return store || getGlobalWebSocketStore(); + }; // Simple auth state const [isLoading, setIsLoading] = createSignal(true); diff --git a/frontend-modern/src/components/Settings/OIDCPanel.tsx b/frontend-modern/src/components/Settings/OIDCPanel.tsx index 613f9b492..a3f6a2275 100644 --- a/frontend-modern/src/components/Settings/OIDCPanel.tsx +++ b/frontend-modern/src/components/Settings/OIDCPanel.tsx @@ -202,6 +202,16 @@ export const OIDCPanel: Component = (props) => {
+
+

Getting started

+
    +
  1. Register a confidential client with your IdP and set the redirect URL shown below.
  2. +
  3. Copy the issuer, client ID, and client secret into the fields here.
  4. +
  5. Grant scopes such as openid profile email.
  6. +
  7. Optionally restrict access by domain, email, or groups.
  8. +
  9. Save, then sign out to test the new SSO button.
  10. +
+
diff --git a/frontend-modern/src/components/Settings/SecurityPostureSummary.tsx b/frontend-modern/src/components/Settings/SecurityPostureSummary.tsx new file mode 100644 index 000000000..8670307ca --- /dev/null +++ b/frontend-modern/src/components/Settings/SecurityPostureSummary.tsx @@ -0,0 +1,157 @@ +import { Component, For, Show } from 'solid-js'; +import { Card } from '@/components/shared/Card'; +import { SectionHeader } from '@/components/shared/SectionHeader'; + +interface SecurityPostureSummaryProps { + status: { + hasAuthentication: boolean; + oidcEnabled?: boolean; + hasProxyAuth?: boolean; + apiTokenConfigured: boolean; + exportProtected: boolean; + unprotectedExportAllowed?: boolean; + hasHTTPS?: boolean; + hasAuditLogging: boolean; + requiresAuth: boolean; + publicAccess?: boolean; + isPrivateNetwork?: boolean; + clientIP?: string; + }; +} + +export const SecurityPostureSummary: Component = (props) => { + const items = () => [ + { + key: 'password', + label: 'Password authentication', + enabled: props.status.hasAuthentication, + description: props.status.hasAuthentication + ? 'Login required for the UI.' + : 'Disabled or not configured.', + }, + { + key: 'oidc', + label: 'Single sign-on (OIDC)', + enabled: Boolean(props.status.oidcEnabled), + description: props.status.oidcEnabled + ? 'OIDC login available.' + : 'Add your identity provider to enable it.', + }, + { + key: 'proxy', + label: 'Proxy authentication', + enabled: Boolean(props.status.hasProxyAuth), + description: props.status.hasProxyAuth + ? 'Requests validated by upstream proxy.' + : 'Optional reverse-proxy auth.', + }, + { + key: 'token', + label: 'API token', + enabled: props.status.apiTokenConfigured, + description: props.status.apiTokenConfigured + ? 'Automation available via token.' + : 'Generate a token for scripts.', + }, + { + key: 'export', + label: 'Export protection', + enabled: props.status.exportProtected && !props.status.unprotectedExportAllowed, + description: props.status.unprotectedExportAllowed + ? 'Exports can bypass token checks.' + : 'Exports require token + passphrase.', + }, + { + key: 'https', + label: 'HTTPS', + enabled: Boolean(props.status.hasHTTPS), + description: props.status.hasHTTPS + ? 'Connection is encrypted.' + : 'Serving over HTTP.', + }, + { + key: 'audit', + label: 'Audit logging', + enabled: props.status.hasAuditLogging, + description: props.status.hasAuditLogging + ? 'Auth events logged for review.' + : 'Enable PULSE_AUDIT_LOG for trails.', + }, + ]; + + const badgeClasses = (enabled: boolean) => + enabled + ? 'inline-flex items-center gap-1 px-2.5 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' + : 'inline-flex items-center gap-1 px-2.5 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-200'; + + return ( + +
+
+ +
+ + {props.status.publicAccess && !props.status.isPrivateNetwork ? 'Public network access' : 'Private network access'} + + + + +
+
+ + +
+ + + + + Authentication is required for this instance. Keep at least one trusted login path enabled before disabling password auth. + +
+
+ +
+ + {(item) => ( +
+
+ {item.label} + + + + + {item.enabled ? 'On' : 'Off'} + +
+

{item.description}

+
+ )} +
+
+
+
+ ); +}; diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 5fa2de3a4..bc8c3675e 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -6,6 +6,8 @@ import { GenerateAPIToken } from './GenerateAPIToken'; import { ChangePasswordModal } from './ChangePasswordModal'; import { GuestURLs } from './GuestURLs'; import { OIDCPanel } from './OIDCPanel'; +import { QuickSecuritySetup } from './QuickSecuritySetup'; +import { SecurityPostureSummary } from './SecurityPostureSummary'; import { SettingsAPI } from '@/api/settings'; import { NodesAPI } from '@/api/nodes'; import { UpdatesAPI } from '@/api/updates'; @@ -15,6 +17,7 @@ import { Toggle } from '@/components/shared/Toggle'; import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form'; import type { NodeConfig } from '@/types/nodes'; import type { UpdateInfo, VersionInfo } from '@/api/updates'; +import type { SecurityStatus as SecurityStatusInfo } from '@/types/config'; import { eventBus } from '@/stores/events'; import { notificationStore } from '@/stores/notifications'; import { updateStore } from '@/stores/updates'; @@ -138,18 +141,7 @@ const Settings: Component = () => { const [runningDiagnostics, setRunningDiagnostics] = createSignal(false); // Security - const [securityStatus, setSecurityStatus] = createSignal<{ - apiTokenConfigured: boolean; - apiTokenHint?: string; - requiresAuth: boolean; - exportProtected: boolean; - unprotectedExportAllowed: boolean; - hasAuthentication: boolean; - configuredButPendingRestart?: boolean; - hasAuditLogging: boolean; - credentialsEncrypted: boolean; - hasHTTPS: boolean; - } | null>(null); + const [securityStatus, setSecurityStatus] = createSignal(null); const [securityStatusLoading, setSecurityStatusLoading] = createSignal(true); const [exportPassphrase, setExportPassphrase] = createSignal(''); const [useCustomPassphrase, setUseCustomPassphrase] = createSignal(false); @@ -160,6 +152,20 @@ const Settings: Component = () => { const [showApiTokenModal, setShowApiTokenModal] = createSignal(false); const [apiTokenInput, setApiTokenInput] = createSignal(''); const [apiTokenModalSource, setApiTokenModalSource] = createSignal<'export' | 'import' | null>(null); + const [showQuickSecuritySetup, setShowQuickSecuritySetup] = createSignal(false); + + const formatTimestamp = (timestamp?: string) => { + if (!timestamp) { + return 'Unknown'; + } + + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return 'Unknown'; + } + + return date.toLocaleString(); + }; const tabs: { id: SettingsTab; label: string; icon: string }[] = [ { @@ -1753,6 +1759,49 @@ const Settings: Component = () => { {/* Security Tab */}
+ + + + + + +
+
+ + + + Proxy authentication detected +
+

+ Requests are validated by an upstream proxy. The current proxied user is + {securityStatus()?.proxyAuthUsername ? ` ${securityStatus()?.proxyAuthUsername}` : ' available once a request is received'}. + {securityStatus()?.proxyAuthIsAdmin ? ' Admin privileges confirmed.' : ''} + + {' '} + + Proxy logout + + +

+

+ Need configuration tips? Review the proxy auth guide in the docs. + {' '} + + Read proxy auth guide → + +

+
+
+
+ {/* Show message when auth is disabled */}
@@ -1790,6 +1839,23 @@ const Settings: Component = () => {
  • 4. Complete the security setup wizard on first access
  • +
    + +
    + +
    + { + setShowQuickSecuritySetup(false); + loadSecurityStatus(); + }} /> +
    +
    @@ -1838,7 +1904,67 @@ const Settings: Component = () => {
    Update your login credentials
    - + +
    + + + +
    +
    +
    Run quick security wizard
    +
    Refresh authentication end-to-end
    +
    +
    + + +
    +
    + + + +
    +
    Admin user
    +
    {securityStatus()?.authUsername || 'Not configured'}
    +
    +
    +
    + + + +
    +
    Last updated
    +
    {formatTimestamp(securityStatus()?.authLastModified)}
    +
    +
    +
    + + + +
    +
    Current coverage
    +
    + {securityStatus()?.hasAuthentication ? 'Password login required.' : 'Password login disabled.'} + {' '} + {securityStatus()?.oidcEnabled ? 'OIDC available.' : 'OIDC off.'} +
    +
    +
    +
    + + + +
    +
    Disable password auth
    +
    + Confirm OIDC or proxy auth works, then set DISABLE_AUTH=true in your deployment. +
    +
    +
    @@ -1965,6 +2091,21 @@ const Settings: Component = () => {

    +
    +

    + Exports and imports {securityStatus()?.exportProtected && !securityStatus()?.unprotectedExportAllowed ? 'require an API token and a passphrase' : 'follow the current server policy'}. Generating a token lets you: +

    +
      +
    • Authenticate scripts with the X-API-Token header.
    • +
    • Unlock encrypted export/import flows in Settings → Security → Backup & restore.
    • +
    • Keep UI logins separate from automation secrets.
    • +
    + +

    + Unprotected exports are currently allowed. Set ALLOW_UNPROTECTED_EXPORT=false or configure an API token to harden backups. +

    +
    +
    diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index 0947b193c..4dc0c7b75 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -88,15 +88,16 @@ interface GroupingConfig { byGuest?: boolean; } +type EscalationNotifyTarget = 'email' | 'webhook' | 'all'; + +interface EscalationLevel { + after: number; + notify: EscalationNotifyTarget; +} + interface EscalationConfig { enabled: boolean; - timeToEscalate?: number; - levels: Array<{ - level?: number; - destinations?: string[]; - after?: number; - notify?: string; - }>; + levels: EscalationLevel[]; } const getLocalTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; @@ -411,9 +412,14 @@ export function Alerts() { } if (config.schedule.escalation) { + const rawLevels = config.schedule.escalation.levels || []; + const levels = rawLevels.map((level) => ({ + after: typeof level.after === 'number' ? level.after : 15, + notify: (level.notify as EscalationNotifyTarget) || 'all' + })); setScheduleEscalation({ - enabled: config.schedule.escalation.enabled || false, - levels: config.schedule.escalation.levels || [] + enabled: Boolean(config.schedule.escalation.enabled), + levels }); } } @@ -1647,7 +1653,8 @@ function ScheduleTab(props: ScheduleTabProps) { value={level.after} onChange={(e) => { const newLevels = [...escalation().levels]; - newLevels[index()] = { ...level, after: parseInt(e.currentTarget.value) }; + const parsed = Number.parseInt(e.currentTarget.value, 10); + newLevels[index()] = { ...level, after: Number.isNaN(parsed) ? level.after : parsed }; setEscalation({ ...escalation(), levels: newLevels }); props.setHasUnsavedChanges(true); }} @@ -1661,7 +1668,7 @@ function ScheduleTab(props: ScheduleTabProps) { value={level.notify} onChange={(e) => { const newLevels = [...escalation().levels]; - newLevels[index()] = { ...level, notify: e.currentTarget.value }; + newLevels[index()] = { ...level, notify: e.currentTarget.value as EscalationNotifyTarget }; setEscalation({ ...escalation(), levels: newLevels }); props.setHasUnsavedChanges(true); }} @@ -1698,7 +1705,7 @@ function ScheduleTab(props: ScheduleTabProps) { const newAfter = typeof lastLevel?.after === 'number' ? lastLevel.after + 30 : 15; setEscalation({ ...escalation(), - levels: [...escalation().levels, { after: newAfter, notify: 'all' }] + levels: [...escalation().levels, { after: newAfter, notify: 'all' as EscalationNotifyTarget }] }); props.setHasUnsavedChanges(true); }} diff --git a/frontend-modern/src/types/config.ts b/frontend-modern/src/types/config.ts index b46b373bd..2e816ea3d 100644 --- a/frontend-modern/src/types/config.ts +++ b/frontend-modern/src/types/config.ts @@ -97,6 +97,18 @@ export interface SecurityStatus { exportProtected: boolean; hasAuditLogging: boolean; configuredButPendingRestart: boolean; + unprotectedExportAllowed?: boolean; + hasHTTPS?: boolean; + oidcEnabled?: boolean; + publicAccess?: boolean; + isPrivateNetwork?: boolean; + clientIP?: string; + hasProxyAuth?: boolean; + proxyAuthUsername?: string; + proxyAuthIsAdmin?: boolean; + proxyAuthLogoutURL?: string; + authUsername?: string; + authLastModified?: string; } /** @@ -137,4 +149,4 @@ export const DEFAULT_CONFIG: { backendPort: 7655, frontendPort: 7655, } -}; \ No newline at end of file +}; diff --git a/internal/api/router.go b/internal/api/router.go index ee2c29138..337dc435b 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -235,10 +235,10 @@ func (r *Router) setupRoutes() { envPath = "/etc/pulse/.env" } - // If no auth is currently active but .env exists, security is pending restart - if !hasAuthentication && r.config.AuthUser == "" && r.config.AuthPass == "" { - if _, err := os.Stat(envPath); err == nil { - // .env exists but auth not loaded - pending restart + authLastModified := "" + if stat, err := os.Stat(envPath); err == nil { + authLastModified = stat.ModTime().UTC().Format(time.RFC3339) + if !hasAuthentication && r.config.AuthUser == "" && r.config.AuthPass == "" { configuredButPendingRestart = true } } @@ -306,6 +306,8 @@ func (r *Router) setupRoutes() { "proxyAuthLogoutURL": r.config.ProxyAuthLogoutURL, "proxyAuthUsername": proxyAuthUsername, "proxyAuthIsAdmin": proxyAuthIsAdmin, + "authUsername": r.config.AuthUser, + "authLastModified": authLastModified, } if oidcCfg != nil {