mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-24 20:22:53 +00:00
Improve security settings UX and fix alerts typing
This commit is contained in:
@@ -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 & 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>
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user