Improve security settings UX and fix alerts typing

This commit is contained in:
rcourtman
2025-09-29 15:52:03 +00:00
parent 1e0c03084f
commit ccdc080181
7 changed files with 363 additions and 31 deletions
+4 -1
View File
@@ -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);
@@ -202,6 +202,16 @@ export const OIDCPanel: Component<Props> = (props) => {
</div>
</div>
<form class="p-6 space-y-5" onSubmit={handleSave}>
<div class="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4 text-xs text-blue-800 dark:text-blue-200">
<p class="font-semibold mb-2">Getting started</p>
<ol class="space-y-1 list-decimal pl-5">
<li>Register a confidential client with your IdP and set the redirect URL shown below.</li>
<li>Copy the issuer, client ID, and client secret into the fields here.</li>
<li>Grant scopes such as <code class="px-1 py-0.5 bg-blue-100/70 dark:bg-blue-900/40 rounded">openid profile email</code>.</li>
<li>Optionally restrict access by domain, email, or groups.</li>
<li>Save, then sign out to test the new SSO button.</li>
</ol>
</div>
<Show when={loading()}>
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<span class="h-4 w-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
@@ -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<SecurityPostureSummaryProps> = (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 (
<Card padding="md" class="border border-gray-200 dark:border-gray-700">
<div class="flex flex-col gap-4">
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-3">
<SectionHeader
title="Security posture"
description="Snapshot of authentication and hardening features"
size="sm"
class="flex-1"
/>
<div class="flex items-center gap-2">
<span
class={`${
props.status.publicAccess && !props.status.isPrivateNetwork
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-200'
} px-3 py-1 text-xs font-semibold rounded-full`}
>
{props.status.publicAccess && !props.status.isPrivateNetwork ? 'Public network access' : 'Private network access'}
</span>
<Show when={props.status.clientIP}>
<span class="hidden md:inline-flex items-center px-3 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300">
IP: {props.status.clientIP}
</span>
</Show>
</div>
</div>
<Show when={props.status.requiresAuth}>
<div class="flex items-start gap-2 p-3 rounded-lg bg-green-50 text-xs text-green-700 dark:bg-green-900/30 dark:text-green-300 border border-green-200 dark:border-green-800">
<svg class="w-4 h-4 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<span>
Authentication is required for this instance. Keep at least one trusted login path enabled before disabling password auth.
</span>
</div>
</Show>
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<For each={items()}>
{(item) => (
<div class="rounded-lg border border-gray-200 dark:border-gray-700 p-3 bg-white dark:bg-gray-800">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">{item.label}</span>
<span class={badgeClasses(item.enabled)}>
<svg
class="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={item.enabled ? 'M5 13l4 4L19 7' : 'M6 18L18 6M6 6l12 12'}
/>
</svg>
{item.enabled ? 'On' : 'Off'}
</span>
</div>
<p class="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">{item.description}</p>
</div>
)}
</For>
</div>
</div>
</Card>
);
};
@@ -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<SecurityStatusInfo | null>(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 */}
<Show when={activeTab() === 'security'}>
<div class="space-y-6">
<Show when={!securityStatusLoading() && securityStatus()}>
<SecurityPostureSummary status={securityStatus()!} />
</Show>
<Show when={!securityStatusLoading() && securityStatus()?.hasProxyAuth}>
<Card padding="sm" class="border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20">
<div class="flex flex-col gap-2 text-xs text-blue-800 dark:text-blue-200">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="font-semibold text-blue-900 dark:text-blue-100">Proxy authentication detected</span>
</div>
<p>
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.' : ''}
<Show when={securityStatus()?.proxyAuthLogoutURL}>
{' '}
<a
class="underline font-medium"
href={securityStatus()?.proxyAuthLogoutURL}
>
Proxy logout
</a>
</Show>
</p>
<p>
Need configuration tips? Review the proxy auth guide in the docs.
{' '}
<a
class="underline font-medium"
href="https://github.com/rcourtman/Pulse/blob/main/docs/PROXY_AUTH.md"
target="_blank"
rel="noreferrer"
>
Read proxy auth guide →
</a>
</p>
</div>
</Card>
</Show>
{/* Show message when auth is disabled */}
<Show when={!securityStatus()?.hasAuthentication}>
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-6">
@@ -1790,6 +1839,23 @@ const Settings: Component = () => {
<li>4. Complete the security setup wizard on first access</li>
</ol>
</Card>
<div class="mt-4">
<button
type="button"
onClick={() => setShowQuickSecuritySetup(!showQuickSecuritySetup())}
class="px-4 py-2 text-xs font-semibold rounded-lg border border-blue-300 text-blue-700 bg-blue-50 hover:bg-blue-100 transition-colors dark:border-blue-700 dark:text-blue-200 dark:bg-blue-900/30 dark:hover:bg-blue-900/40"
>
{showQuickSecuritySetup() ? 'Hide quick security setup' : 'Launch quick security setup'}
</button>
</div>
<Show when={showQuickSecuritySetup()}>
<div class="mt-4">
<QuickSecuritySetup onConfigured={() => {
setShowQuickSecuritySetup(false);
loadSecurityStatus();
}} />
</div>
</Show>
</div>
</div>
</div>
@@ -1838,7 +1904,67 @@ const Settings: Component = () => {
<div class="text-xs text-gray-500 dark:text-gray-400">Update your login credentials</div>
</div>
</button>
<a
href="https://github.com/rcourtman/Pulse/blob/main/docs/SECURITY.md#first-run-security-setup"
target="_blank"
rel="noreferrer"
class="flex items-center gap-3 p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-all group"
>
<div class="p-2 bg-indigo-100 dark:bg-indigo-900/30 rounded-lg group-hover:bg-indigo-200 dark:group-hover:bg-indigo-900/50 transition-colors">
<svg class="w-5 h-5 text-indigo-600 dark:text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div class="text-left">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Run quick security wizard</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Refresh authentication end-to-end</div>
</div>
</a>
</div>
<div class="mt-6 grid gap-3 text-xs text-gray-600 dark:text-gray-400 md:grid-cols-2">
<div class="flex items-start gap-2">
<svg class="w-4 h-4 mt-0.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.121 17.804A13.937 13.937 0 0112 15c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<div>
<div class="font-medium text-gray-800 dark:text-gray-200">Admin user</div>
<div>{securityStatus()?.authUsername || 'Not configured'}</div>
</div>
</div>
<div class="flex items-start gap-2">
<svg class="w-4 h-4 mt-0.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3" />
</svg>
<div>
<div class="font-medium text-gray-800 dark:text-gray-200">Last updated</div>
<div>{formatTimestamp(securityStatus()?.authLastModified)}</div>
</div>
</div>
<div class="flex items-start gap-2">
<svg class="w-4 h-4 mt-0.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6l4 2" />
</svg>
<div>
<div class="font-medium text-gray-800 dark:text-gray-200">Current coverage</div>
<div>
{securityStatus()?.hasAuthentication ? 'Password login required.' : 'Password login disabled.'}
{' '}
{securityStatus()?.oidcEnabled ? 'OIDC available.' : 'OIDC off.'}
</div>
</div>
</div>
<div class="flex items-start gap-2">
<svg class="w-4 h-4 mt-0.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-3-3v6m9 3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<div class="font-medium text-gray-800 dark:text-gray-200">Disable password auth</div>
<div>
Confirm OIDC or proxy auth works, then set <code class="px-1 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">DISABLE_AUTH=true</code> in your deployment.
</div>
</div>
</div>
</div>
</div>
</Card>
@@ -1965,6 +2091,21 @@ const Settings: Component = () => {
</p>
</Card>
</Show>
<div class="mb-4 space-y-2 text-xs text-gray-600 dark:text-gray-400">
<p>
Exports and imports {securityStatus()?.exportProtected && !securityStatus()?.unprotectedExportAllowed ? 'require an API token and a passphrase' : 'follow the current server policy'}. Generating a token lets you:
</p>
<ul class="list-disc pl-5 space-y-1">
<li>Authenticate scripts with the <code class="px-1 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">X-API-Token</code> header.</li>
<li>Unlock encrypted export/import flows in Settings → Security → Backup &amp; restore.</li>
<li>Keep UI logins separate from automation secrets.</li>
</ul>
<Show when={securityStatus()?.unprotectedExportAllowed}>
<p class="text-amber-700 dark:text-amber-300">
Unprotected exports are currently allowed. Set <code class="px-1 py-0.5 bg-amber-100 dark:bg-amber-900/50 rounded">ALLOW_UNPROTECTED_EXPORT=false</code> or configure an API token to harden backups.
</p>
</Show>
</div>
<GenerateAPIToken currentTokenHint={securityStatus()?.apiTokenHint} />
</div>
</Card>
+19 -12
View File
@@ -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);
}}
+13 -1
View File
@@ -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,
}
};
};
+6 -4
View File
@@ -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 {