Pulse/frontend-modern/src/components/SecurityWarning.tsx
2025-10-11 23:29:47 +00:00

259 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Component, createSignal, Show, onMount } from 'solid-js';
import { Portal } from 'solid-js/web';
import { SectionHeader } from '@/components/shared/SectionHeader';
interface SecurityStatus {
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<SecurityStatus | 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 response = await fetch('/api/security/status');
if (response.ok) {
const data = await response.json();
// Calculate security score
let score = 0;
const maxScore = 5;
if (data.credentialsEncrypted !== false) score++; // Always true currently
if (data.exportProtected) score++;
if (data.apiTokenConfigured) score++;
if (data.hasHTTPS || window.location.protocol === 'https:') score++;
if (data.hasAuthentication) score++;
setStatus({
hasAuthentication: data.hasAuthentication || false,
hasHTTPS: window.location.protocol === 'https:',
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) {
console.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);
};
const getScoreColor = (score: number, max: number) => {
const percentage = (score / max) * 100;
if (percentage >= 80) return 'text-green-600 dark:text-green-400';
if (percentage >= 60) return 'text-yellow-600 dark:text-yellow-400';
if (percentage >= 40) return 'text-orange-600 dark:text-orange-400';
return 'text-red-600 dark:text-red-400';
};
const getScoreEmoji = (score: number, max: number) => {
const percentage = (score / max) * 100;
if (percentage >= 80) return '🛡️';
if (percentage >= 60) return '⚠️';
return '🚨';
};
// Show more aggressively if public access detected
const shouldShow = () => {
if (dismissed()) return false;
if (!status()) return false;
// Always show if public access without auth
if (status()!.publicAccess && !status()!.hasAuthentication) {
return true;
}
// Show if score is low
return status()!.score < 4;
};
if (!shouldShow()) {
return null;
}
return (
<Portal>
<div
class={`fixed top-0 left-0 right-0 z-50 border-b shadow-sm ${
status()!.publicAccess && !status()!.hasAuthentication
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
}`}
>
<div class="max-w-7xl mx-auto px-4 py-3">
<div class="flex items-start justify-between">
<div class="flex items-start space-x-3">
<span class="text-2xl">{getScoreEmoji(status()!.score, status()!.maxScore)}</span>
<div>
<div class="flex items-center gap-3">
<SectionHeader
title={
<span>
Security score:{' '}
<span class={getScoreColor(status()!.score, status()!.maxScore)}>
{status()!.score}/{status()!.maxScore}
</span>
</span>
}
size="sm"
class="flex-1"
titleClass="text-gray-900 dark:text-gray-100"
/>
<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-gray-700 dark:text-gray-300 mt-1">
{status()!.publicAccess ? (
<span class="font-semibold text-red-700 dark:text-red-300">
PUBLIC NETWORK ACCESS DETECTED - Your Proxmox credentials are exposed to
the internet!
</span>
) : (
'Your Pulse instance is accessible without authentication. Proxmox credentials could be exposed.'
)}
</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={status()!.credentialsEncrypted ? 'text-green-600' : 'text-red-600'}
>
{status()!.credentialsEncrypted ? '✅' : '❌'}
</span>
<span>Credentials encrypted at rest</span>
</div>
<div class="flex items-center gap-2">
<span class={status()!.exportProtected ? 'text-green-600' : 'text-red-600'}>
{status()!.exportProtected ? '✅' : '❌'}
</span>
<span>Export requires authentication</span>
</div>
<div class="flex items-center gap-2">
<span
class={status()!.hasAuthentication ? 'text-green-600' : 'text-red-600'}
>
{status()!.hasAuthentication ? '✅' : '❌'}
</span>
<span>Authentication enabled</span>
</div>
<div class="flex items-center gap-2">
<span class={status()!.hasHTTPS ? 'text-green-600' : 'text-red-600'}>
{status()!.hasHTTPS ? '✅' : '❌'}
</span>
<span>HTTPS connection</span>
</div>
<div class="flex items-center gap-2">
<span class={status()!.hasAuditLogging ? 'text-green-600' : 'text-red-600'}>
{status()!.hasAuditLogging ? '✅' : '❌'}
</span>
<span>Audit logging enabled</span>
</div>
</div>
</div>
</Show>
<div class="flex items-center gap-3 mt-3">
<a
href="/settings?tab=security"
class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
>
Enable Security
</a>
<a
href="https://github.com/rcourtman/Pulse/blob/main/docs/SECURITY.md"
target="_blank"
class="text-sm text-gray-600 dark:text-gray-400 hover:underline"
>
Learn More
</a>
<div class="relative group">
<button
type="button"
onClick={() => handleDismiss('day')}
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
Dismiss
</button>
<div class="absolute left-0 top-full mt-1 bg-white dark:bg-gray-800 rounded shadow-lg border border-gray-200 dark:border-gray-700 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-gray-100 dark:hover:bg-gray-700"
>
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-gray-100 dark:hover:bg-gray-700"
>
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-gray-100 dark:hover:bg-gray-700"
>
Forever
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Portal>
);
};