mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 04:06:21 +00:00
258 lines
9.8 KiB
TypeScript
258 lines
9.8 KiB
TypeScript
import { Component, createSignal, Show, onMount } from 'solid-js';
|
|
import { SectionHeader } from '@/components/shared/SectionHeader';
|
|
import { isPulseHttps } from '@/utils/url';
|
|
import { logger } from '@/utils/logger';
|
|
import { apiFetchJSON } from '@/utils/apiClient';
|
|
import { SECURITY_DOC_URL } from '@/utils/docsLinks';
|
|
import {
|
|
getSecurityFeatureStatePresentation,
|
|
getSecurityScorePresentation,
|
|
getSecurityScoreSymbol,
|
|
getSecurityScoreTextClass,
|
|
getSecurityWarningPresentation,
|
|
shouldShowGlobalSecurityWarning,
|
|
} from '@/utils/securityScorePresentation';
|
|
|
|
import type { SecurityStatus } from '@/types/config';
|
|
|
|
interface SecurityState {
|
|
hasAuthentication: boolean;
|
|
hasHTTPS: boolean;
|
|
hasAPIToken: boolean;
|
|
hasAuditLogging: boolean;
|
|
credentialsEncrypted: boolean;
|
|
exportProtected: boolean;
|
|
score: number;
|
|
maxScore: number;
|
|
publicAccess?: boolean;
|
|
isPrivateNetwork?: boolean;
|
|
clientIP?: string;
|
|
}
|
|
|
|
export const SecurityWarning: Component = () => {
|
|
const [dismissed, setDismissed] = createSignal(false);
|
|
const [status, setStatus] = createSignal<SecurityState | null>(null);
|
|
const [showDetails, setShowDetails] = createSignal(false);
|
|
|
|
onMount(async () => {
|
|
// Check if user has previously dismissed
|
|
const dismissedUntil = localStorage.getItem('securityWarningDismissed');
|
|
if (dismissedUntil) {
|
|
const dismissDate = new Date(dismissedUntil);
|
|
if (dismissDate > new Date()) {
|
|
setDismissed(true);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Fetch security status
|
|
try {
|
|
const data = await apiFetchJSON<SecurityStatus>('/api/security/status');
|
|
|
|
// Calculate security score
|
|
let score = 0;
|
|
const maxScore = 5;
|
|
|
|
const runningOverHttps = isPulseHttps();
|
|
|
|
if (data.credentialsEncrypted !== false) score++; // Always true currently
|
|
if (data.exportProtected) score++;
|
|
if (data.apiTokenConfigured) score++;
|
|
if (data.hasHTTPS || runningOverHttps) score++;
|
|
if (data.hasAuthentication) score++;
|
|
|
|
setStatus({
|
|
hasAuthentication: data.hasAuthentication || false,
|
|
hasHTTPS: runningOverHttps,
|
|
hasAPIToken: data.apiTokenConfigured || false,
|
|
hasAuditLogging: data.hasAuditLogging || false,
|
|
credentialsEncrypted: true, // Always true in current implementation
|
|
exportProtected: data.exportProtected || false,
|
|
score,
|
|
maxScore,
|
|
publicAccess: data.publicAccess || false,
|
|
isPrivateNetwork: data.isPrivateNetwork,
|
|
clientIP: data.clientIP,
|
|
});
|
|
} catch (error) {
|
|
logger.error('Failed to fetch security status:', error);
|
|
}
|
|
});
|
|
|
|
const handleDismiss = (duration: 'day' | 'week' | 'forever') => {
|
|
const now = new Date();
|
|
if (duration === 'day') {
|
|
now.setDate(now.getDate() + 1);
|
|
} else if (duration === 'week') {
|
|
now.setDate(now.getDate() + 7);
|
|
} else {
|
|
now.setFullYear(now.getFullYear() + 100); // "Forever"
|
|
}
|
|
localStorage.setItem('securityWarningDismissed', now.toISOString());
|
|
setDismissed(true);
|
|
};
|
|
|
|
// Show more aggressively if public access detected
|
|
const shouldShow = () => {
|
|
if (dismissed()) return false;
|
|
|
|
const currentStatus = status();
|
|
if (!currentStatus) return false;
|
|
|
|
return shouldShowGlobalSecurityWarning({
|
|
hasAuthentication: currentStatus.hasAuthentication,
|
|
exportProtected: currentStatus.exportProtected,
|
|
hasHTTPS: currentStatus.hasHTTPS,
|
|
publicAccess: currentStatus.publicAccess,
|
|
});
|
|
};
|
|
|
|
const scorePercentage = () => (status()!.score / status()!.maxScore) * 100;
|
|
const scorePresentation = () => getSecurityScorePresentation(scorePercentage());
|
|
const warningPresentation = () =>
|
|
getSecurityWarningPresentation({
|
|
score: scorePercentage(),
|
|
publicAccess: status()!.publicAccess || false,
|
|
hasAuthentication: status()!.hasAuthentication,
|
|
apiTokenConfigured: status()!.hasAPIToken,
|
|
exportProtected: status()!.exportProtected,
|
|
hasHTTPS: status()!.hasHTTPS,
|
|
});
|
|
|
|
return (
|
|
<Show when={shouldShow()}>
|
|
<div
|
|
class={`relative z-20 border-b shadow-sm ${warningPresentation().background} ${warningPresentation().border}`}
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<div class="max-w-7xl mx-auto px-4 py-3">
|
|
<div class="flex items-start space-x-3">
|
|
<span class={`text-2xl ${scorePresentation().tone.icon}`}>
|
|
{getSecurityScoreSymbol(scorePercentage())}
|
|
</span>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<SectionHeader
|
|
title={
|
|
<span>
|
|
Security score:{' '}
|
|
<span class={getSecurityScoreTextClass(scorePercentage())}>
|
|
{status()!.score}/{status()!.maxScore}
|
|
</span>
|
|
</span>
|
|
}
|
|
size="sm"
|
|
class="flex-1 min-w-[12rem]"
|
|
titleClass="text-base-content"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowDetails(!showDetails())}
|
|
class="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
|
>
|
|
{showDetails() ? 'Hide' : 'Show'} Details
|
|
</button>
|
|
</div>
|
|
|
|
<p class="text-sm text-base-content mt-1">
|
|
<span class={warningPresentation().messageClass}>{warningPresentation().message}</span>
|
|
</p>
|
|
|
|
<Show when={showDetails()}>
|
|
<div class="mt-3 space-y-1">
|
|
<div class="text-xs space-y-1">
|
|
<div class="flex items-center gap-2">
|
|
<span
|
|
class={getSecurityFeatureStatePresentation(status()!.credentialsEncrypted).className}
|
|
>
|
|
{getSecurityFeatureStatePresentation(status()!.credentialsEncrypted).label}
|
|
</span>
|
|
<span>Credentials encrypted at rest</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class={getSecurityFeatureStatePresentation(status()!.exportProtected).className}>
|
|
{getSecurityFeatureStatePresentation(status()!.exportProtected).label}
|
|
</span>
|
|
<span>Export requires authentication</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span
|
|
class={getSecurityFeatureStatePresentation(status()!.hasAuthentication).className}
|
|
>
|
|
{getSecurityFeatureStatePresentation(status()!.hasAuthentication).label}
|
|
</span>
|
|
<span>Authentication enabled</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class={getSecurityFeatureStatePresentation(status()!.hasHTTPS).className}>
|
|
{getSecurityFeatureStatePresentation(status()!.hasHTTPS).label}
|
|
</span>
|
|
<span>HTTPS connection</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class={getSecurityFeatureStatePresentation(status()!.hasAuditLogging).className}>
|
|
{getSecurityFeatureStatePresentation(status()!.hasAuditLogging).label}
|
|
</span>
|
|
<span>Audit logging enabled</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<div class="mt-3 flex flex-wrap items-center gap-3">
|
|
<a
|
|
href="/settings/security-overview"
|
|
class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
|
>
|
|
Enable Security →
|
|
</a>
|
|
<a
|
|
href={SECURITY_DOC_URL}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="text-sm text-muted hover:underline"
|
|
>
|
|
Learn More
|
|
</a>
|
|
<div class="relative group">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDismiss('day')}
|
|
class="text-sm text-muted hover:text-base-content"
|
|
>
|
|
Dismiss ▼
|
|
</button>
|
|
<div class="absolute left-0 top-full mt-1 bg-surface rounded shadow-sm border border-border opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDismiss('day')}
|
|
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-surface-hover"
|
|
>
|
|
For 1 day
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDismiss('week')}
|
|
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-surface-hover"
|
|
>
|
|
For 1 week
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDismiss('forever')}
|
|
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-surface-hover"
|
|
>
|
|
Forever
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
);
|
|
};
|