Refine security settings UI and credential rotation flow

This commit is contained in:
rcourtman
2025-09-29 17:42:10 +00:00
parent 7b00b712f0
commit 6433493232
5 changed files with 318 additions and 275 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ API_TOKEN=abc123... # API token (plain text, not hashed)
# Security settings
DISABLE_AUTH=true # Disable authentication entirely
ENABLE_AUDIT_LOG=true # Enable security audit logging
PULSE_AUDIT_LOG=true # Enable security audit logging
# Proxy/SSO Authentication (see docs/PROXY_AUTH.md for full details)
PROXY_AUTH_SECRET=secret123 # Shared secret between proxy and Pulse
@@ -12,15 +12,19 @@ interface SecurityCredentials {
interface QuickSecuritySetupProps {
onConfigured?: () => void;
defaultUsername?: string;
mode?: 'initial' | 'rotate';
}
export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) => {
const mode = props.mode ?? 'initial';
const isRotation = mode === 'rotate';
const [isSettingUp, setIsSettingUp] = createSignal(false);
const [credentials, setCredentials] = createSignal<SecurityCredentials | null>(null);
const [showCredentials, setShowCredentials] = createSignal(false);
const [copied, setCopied] = createSignal<'username' | 'password' | 'token' | null>(null);
const [useCustomPassword, setUseCustomPassword] = createSignal(false);
const [customUsername, setCustomUsername] = createSignal('admin');
const [customUsername, setCustomUsername] = createSignal(props.defaultUsername ?? 'admin');
const [customPassword, setCustomPassword] = createSignal('');
const [confirmPassword, setConfirmPassword] = createSignal('');
@@ -83,7 +87,10 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newCredentials),
body: JSON.stringify({
...newCredentials,
force: isRotation,
}),
credentials: 'include' // Include cookies for CSRF
});
@@ -97,7 +104,7 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
if (result.skipped) {
// Security was already configured, don't show credentials
showError('Security is already configured. Please remove existing security first if you want to reconfigure.');
showError(result.message || 'Security is already configured. Please remove existing security first if you want to reconfigure.');
if (props.onConfigured) {
props.onConfigured();
}
@@ -109,7 +116,7 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
setShowCredentials(true);
// Show success message
showSuccess('Security configured! Save your credentials before continuing.');
showSuccess(isRotation ? 'Admin credentials generated. Save them before continuing.' : 'Security configured. Save your credentials before continuing.');
// DON'T notify parent yet - wait until user dismisses credentials
// if (props.onConfigured) {
@@ -125,7 +132,7 @@ export const QuickSecuritySetup: Component<QuickSecuritySetupProps> = (props) =>
const downloadCredentials = () => {
if (!credentials()) return;
const content = `Pulse Security Credentials
const content = `Pulse Admin Credentials ${isRotation ? '(Rotated)' : ''}
Generated: ${new Date().toISOString()}
Basic Authentication:
@@ -162,8 +169,8 @@ Important:
</div>
<div class="flex-1">
<SectionHeader
title="Quick security setup"
description="Enable authentication with one click. This will:"
title={isRotation ? 'Generate new admin credentials' : 'Quick security setup'}
description={isRotation ? 'Create a fresh password and API token. This will:' : 'Enable authentication with one click. This will:'}
size="sm"
titleClass="text-gray-900 dark:text-gray-100"
descriptionClass="!text-xs text-gray-600 dark:text-gray-400"
@@ -171,15 +178,15 @@ Important:
<ul class="mt-2 space-y-1 text-xs text-gray-600 dark:text-gray-400">
<li class="flex items-center">
<span class="text-green-500 mr-2"></span>
Generate secure random password
{isRotation ? 'Generate a new secure password' : 'Generate secure random password'}
</li>
<li class="flex items-center">
<span class="text-green-500 mr-2"></span>
Enable basic authentication
{isRotation ? 'Replace the stored admin password' : 'Enable basic authentication'}
</li>
<li class="flex items-center">
<span class="text-green-500 mr-2"></span>
Create API token for automation
{isRotation ? 'Create a new API token for automation' : 'Create API token for automation'}
</li>
<li class="flex items-center">
<span class="text-green-500 mr-2"></span>
@@ -276,6 +283,11 @@ Important:
<p>{useCustomPassword()
? 'Your password will be hashed before storage'
: 'Credentials will be shown only once. Save them immediately!'}</p>
<Show when={isRotation}>
<p class="mt-1">
Existing sessions will be logged out once Pulse restarts with the new credentials.
</p>
</Show>
</div>
</div>
</div>
@@ -291,10 +303,10 @@ Important:
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Setting up security...
{isRotation ? 'Rotating credentials...' : 'Setting up security...'}
</span>
) : (
'Enable Security Now'
(isRotation ? 'Rotate credentials' : 'Enable Security Now')
)}
</button>
</div>
@@ -304,7 +316,7 @@ Important:
<div class="space-y-4">
<div class="flex items-center justify-between">
<SectionHeader
title="🎉 Security enabled successfully!"
title={isRotation ? 'Admin credentials generated' : 'Security enabled successfully'}
size="md"
class="flex-1"
titleClass="text-gray-900 dark:text-gray-100"
@@ -313,7 +325,7 @@ Important:
onClick={downloadCredentials}
class="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
>
Download Credentials
Download credentials
</button>
</div>
@@ -377,14 +389,14 @@ Important:
Use this token with the X-API-Token header for automation.
</p>
<p class="mt-1 text-xs font-semibold text-red-600 dark:text-red-400">
⚠️ This token will never be shown again. Save it now!
This token is only shown once. Save it now.
</p>
</div>
</div>
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
<p class="text-sm font-semibold text-green-800 dark:text-green-200 mb-2">
✅ Security configured successfully!
Credentials saved
</p>
<p class="text-xs text-green-700 dark:text-green-300">
The service needs to be restarted for security settings to take effect.
@@ -162,6 +162,7 @@ const Settings: Component = () => {
const [apiTokenInput, setApiTokenInput] = createSignal('');
const [apiTokenModalSource, setApiTokenModalSource] = createSignal<'export' | 'import' | null>(null);
const [showQuickSecuritySetup, setShowQuickSecuritySetup] = createSignal(false);
const [showQuickSecurityWizard, setShowQuickSecurityWizard] = createSignal(false);
const formatTimestamp = (timestamp?: string) => {
if (!timestamp) {
@@ -1589,39 +1590,47 @@ const Settings: Component = () => {
{/* System Settings Tab */}
<Show when={activeTab() === 'system'}>
<div class="space-y-6">
<div>
<SectionHeader title="System configuration" size="md" class="mb-4" />
{/* Environment Variable Info */}
<div class="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-lg">
<div class="flex items-start gap-2">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5" 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>
<div class="text-sm text-blue-800 dark:text-blue-200">
<p class="font-medium mb-1">Configuration Priority:</p>
<p> Some env vars override settings (API_TOKEN, PORTS, AUTH)</p>
<p> Changes made here are saved to system.json immediately</p>
<p> Settings persist unless overridden by env vars</p>
</div>
<SectionHeader title="System configuration" size="md" class="mb-2" />
<Card
tone="info"
padding="md"
border={false}
class="border border-blue-200 dark:border-blue-800"
>
<div class="flex items-start gap-3">
<svg
class="w-5 h-5 text-blue-600 dark:text-blue-400 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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-sm text-blue-800 dark:text-blue-200">
<p class="font-medium mb-1">Configuration Priority</p>
<ul class="space-y-1">
<li> Some env vars override settings (API_TOKEN, PORTS, AUTH)</li>
<li> Changes made here are saved to system.json immediately</li>
<li> Settings persist unless overridden by env vars</li>
</ul>
</div>
</div>
<div class="space-y-4">
{/* Network Settings */}
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4 flex items-center gap-2">
</Card>
<div class="grid gap-4 lg:grid-cols-5">
<Card padding="lg" class="space-y-6 lg:col-span-3">
<section class="space-y-3">
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"></path>
</svg>
Network Settings
</h4>
<div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">CORS Allowed Origins</label>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-2">For reverse proxy setups (* = allow all, empty = same-origin only)</p>
<p class="text-xs text-gray-600 dark:text-gray-400">For reverse proxy setups (* = allow all, empty = same-origin only)</p>
<div class="relative">
<input
type="text"
@@ -1635,8 +1644,8 @@ const Settings: Component = () => {
disabled={envOverrides().allowedOrigins}
placeholder="* or https://example.com"
class={`w-full px-3 py-1.5 text-sm border rounded-lg ${
envOverrides().allowedOrigins
? 'border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 cursor-not-allowed opacity-75'
envOverrides().allowedOrigins
? 'border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 cursor-not-allowed opacity-75'
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800'
}`}
/>
@@ -1655,67 +1664,71 @@ const Settings: Component = () => {
)}
</div>
</div>
{/* Iframe Embedding Settings */}
<div class="mt-4">
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">Iframe Embedding</label>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-2">Allow Pulse to be embedded in iframes (e.g., Homepage dashboard)</p>
<div class="space-y-3">
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
</section>
<section class="space-y-3">
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="14" rx="2"></rect>
<path d="M7 20h10"></path>
</svg>
Embedding
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">Allow Pulse to be embedded in iframes (e.g., Homepage dashboard)</p>
<div class="space-y-3">
<div class="flex items-center gap-2">
<input
type="checkbox"
id="allowEmbedding"
checked={allowEmbedding()}
onChange={(e) => {
setAllowEmbedding(e.currentTarget.checked);
setHasUnsavedChanges(true);
}}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
/>
<label for="allowEmbedding" class="text-sm text-gray-700 dark:text-gray-300">
Allow iframe embedding
</label>
</div>
<Show when={allowEmbedding()}>
<div class="space-y-2">
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">Allowed Embed Origins (optional)</label>
<p class="text-xs text-gray-600 dark:text-gray-400">Comma-separated list of origins that can embed Pulse (leave empty for same-origin only)</p>
<input
type="checkbox"
id="allowEmbedding"
checked={allowEmbedding()}
type="text"
value={allowedEmbedOrigins()}
onChange={(e) => {
setAllowEmbedding(e.currentTarget.checked);
setAllowedEmbedOrigins(e.currentTarget.value);
setHasUnsavedChanges(true);
}}
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
placeholder="https://my.domain, https://dashboard.example.com"
class="w-full px-3 py-1.5 text-sm border rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
/>
<label for="allowEmbedding" class="text-sm text-gray-700 dark:text-gray-300">
Allow iframe embedding
</label>
<p class="text-xs text-gray-500 dark:text-gray-400">
Example: If Pulse is at <code>pulse.my.domain</code> and your dashboard is at <code>my.domain</code>, add <code>https://my.domain</code> here.
</p>
</div>
<Show when={allowEmbedding()}>
<div>
<label class="text-xs font-medium text-gray-700 dark:text-gray-300">Allowed Embed Origins (optional)</label>
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Comma-separated list of origins that can embed Pulse (leave empty for same-origin only)</p>
<input
type="text"
value={allowedEmbedOrigins()}
onChange={(e) => {
setAllowedEmbedOrigins(e.currentTarget.value);
setHasUnsavedChanges(true);
}}
placeholder="https://my.domain, https://dashboard.example.com"
class="w-full px-3 py-1.5 text-sm border rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Example: If Pulse is at <code>pulse.my.domain</code> and your dashboard is at <code>my.domain</code>,
add <code>https://my.domain</code> here.
</p>
</div>
</Show>
</div>
</Show>
</div>
<div class="mt-3 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p class="text-xs text-amber-800 dark:text-amber-200 mb-2">
<strong>Port Configuration:</strong> Use <code class="font-mono bg-amber-100 dark:bg-amber-800 px-1 rounded">systemctl edit pulse</code>
</p>
<p class="text-xs text-amber-700 dark:text-amber-300 font-mono">
[Service]<br/>
Environment="FRONTEND_PORT=8080"<br/>
<span class="text-xs text-amber-600 dark:text-amber-400">Then restart: sudo systemctl restart pulse</span>
</p>
</div>
</div>
{/* Update Settings */}
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4 flex items-center gap-2">
</section>
<Card tone="warning" padding="sm" border={false} class="border border-amber-200 dark:border-amber-800">
<p class="text-xs text-amber-800 dark:text-amber-200 mb-2">
<strong>Port Configuration:</strong> Use <code class="font-mono bg-amber-100 dark:bg-amber-800 px-1 rounded">systemctl edit pulse</code>
</p>
<p class="text-xs text-amber-700 dark:text-amber-300 font-mono">
[Service]<br/>
Environment="FRONTEND_PORT=8080"<br/>
<span class="text-xs text-amber-600 dark:text-amber-400">Then restart: sudo systemctl restart pulse</span>
</p>
</Card>
</Card>
<Card padding="lg" class="space-y-6 lg:col-span-2">
<section class="space-y-4">
<h4 class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
@@ -1723,10 +1736,9 @@ const Settings: Component = () => {
</svg>
Updates
</h4>
<div class="space-y-4">
{/* Version Info */}
<div class="flex items-center justify-between">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">Current Version</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
@@ -1754,8 +1766,7 @@ const Settings: Component = () => {
)}
</button>
</div>
{/* Docker Message */}
<Show when={versionInfo()?.isDocker && !updateInfo()?.available}>
<div class="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p class="text-xs text-blue-800 dark:text-blue-200">
@@ -1763,11 +1774,10 @@ const Settings: Component = () => {
</p>
</div>
</Show>
{/* Update Available */}
<Show when={updateInfo()?.available}>
<div class="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<div class="mb-2">
<div class="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg space-y-3">
<div>
<p class="text-sm font-medium text-green-800 dark:text-green-200">
Update Available: {updateInfo()?.latestVersion}
</p>
@@ -1775,10 +1785,9 @@ const Settings: Component = () => {
Released: {updateInfo()?.releaseDate ? new Date(updateInfo()!.releaseDate).toLocaleDateString() : 'Unknown'}
</p>
</div>
{/* Update Instructions based on deployment type */}
<div class="mt-3 p-2 bg-green-100 dark:bg-green-900/40 rounded">
<p class="text-xs font-medium text-green-800 dark:text-green-200 mb-1">How to update:</p>
<div class="p-2 bg-green-100 dark:bg-green-900/40 rounded space-y-2">
<p class="text-xs font-medium text-green-800 dark:text-green-200">How to update:</p>
<Show when={versionInfo()?.deploymentType === 'proxmoxve'}>
<p class="text-xs text-green-700 dark:text-green-300">
Type <code class="px-1 py-0.5 bg-green-200 dark:bg-green-800 rounded">update</code> in the LXC console
@@ -1806,10 +1815,15 @@ const Settings: Component = () => {
Pull latest changes and rebuild
</p>
</Show>
<Show when={!versionInfo()?.deploymentType && versionInfo()?.isDocker}>
<p class="text-xs text-green-700 dark:text-green-300">
Pull the latest Pulse Docker image and recreate your container.
</p>
</Show>
</div>
<Show when={updateInfo()?.releaseNotes}>
<details class="mt-2">
<details class="mt-1">
<summary class="text-xs text-green-700 dark:text-green-300 cursor-pointer">Release Notes</summary>
<pre class="mt-2 text-xs text-green-600 dark:text-green-400 whitespace-pre-wrap font-mono bg-green-100 dark:bg-green-900/30 p-2 rounded">
{updateInfo()?.releaseNotes}
@@ -1818,11 +1832,9 @@ const Settings: Component = () => {
</Show>
</div>
</Show>
{/* Update Settings */}
<div class="border-t border-gray-200 dark:border-gray-600 pt-4 space-y-4">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">Update Channel</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
@@ -1842,8 +1854,8 @@ const Settings: Component = () => {
<option value="rc">Release Candidate</option>
</select>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">Update Checks</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
@@ -1864,10 +1876,10 @@ const Settings: Component = () => {
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 peer-disabled:opacity-50"></div>
</label>
</div>
<Show when={autoUpdateEnabled()}>
<div class="pl-4 space-y-4 border-l-2 border-gray-200 dark:border-gray-600">
<div class="flex items-center justify-between">
<div class="space-y-4 rounded-md border border-gray-200 dark:border-gray-600 p-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">Check Interval</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
@@ -1888,8 +1900,8 @@ const Settings: Component = () => {
<option value="168">Weekly</option>
</select>
</div>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-gray-100">Check Time</label>
<p class="text-xs text-gray-600 dark:text-gray-400">
@@ -1910,11 +1922,10 @@ const Settings: Component = () => {
</Show>
</div>
</div>
</div>
</div>
</section>
</Card>
</div>
{/* Backup & Restore - Moved from Security tab */}
<Card padding="lg" border={false} class="border border-gray-200 dark:border-gray-700">
<SectionHeader
@@ -2128,46 +2139,63 @@ const Settings: Component = () => {
{/* Content */}
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<button type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log('Change password button clicked, current modal state:', showPasswordModal());
setShowPasswordModal(true);
console.log('Modal state after setting:', showPasswordModal());
}}
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 cursor-pointer"
>
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg group-hover:bg-blue-200 dark:group-hover:bg-blue-900/50 transition-colors">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="flex-1 text-sm text-gray-600 dark:text-gray-400">
<p class="font-semibold text-gray-900 dark:text-gray-100">Credential controls</p>
<p class="mt-1 leading-relaxed">
Update the administrator password for routine maintenance, or rotate both the password and API token when you need a full credential refresh.
</p>
</div>
<div class="flex flex-wrap items-start gap-4">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowPasswordModal(true);
}}
class="flex items-center gap-3 px-4 py-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-all"
>
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<div class="text-left">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Change password</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Keep existing API token</div>
</div>
</button>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400">Need to replace both the password and API token?</span>
<button
type="button"
onClick={() => setShowQuickSecurityWizard(!showQuickSecurityWizard())}
class={`inline-flex items-center gap-2 text-sm font-medium text-indigo-600 dark:text-indigo-300 hover:text-indigo-700 dark:hover:text-indigo-200 transition-colors ${showQuickSecurityWizard() ? 'underline' : ''}`}
>
<svg class="w-4 h-4" 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>
<span>Generate new credentials</span>
</button>
</div>
<div class="text-left">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Change Password</div>
<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>
<div class="mt-6 grid gap-3 text-xs text-gray-600 dark:text-gray-400 md:grid-cols-2">
<Show when={showQuickSecurityWizard()}>
<div class="mt-6">
<QuickSecuritySetup
mode="rotate"
defaultUsername={securityStatus()?.authUsername || 'admin'}
onConfigured={() => {
setShowQuickSecurityWizard(false);
loadSecurityStatus();
}}
/>
</div>
</Show>
<div class="mt-8 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" />
+2 -1
View File
@@ -17,7 +17,8 @@ export interface AuthConfig {
PULSE_AUTH_USER: string; // Admin username
PULSE_AUTH_PASS: string; // Bcrypt hashed password
API_TOKEN: string; // API authentication token
ENABLE_AUDIT_LOG?: boolean; // Enable audit logging
ENABLE_AUDIT_LOG?: boolean; // @deprecated - use PULSE_AUDIT_LOG
PULSE_AUDIT_LOG?: boolean; // Enable audit logging
}
/**
+112 -110
View File
@@ -20,7 +20,7 @@ import (
func detectServiceName() string {
// Try common service names
services := []string{"pulse-backend", "pulse", "pulse.service", "pulse-backend.service"}
for _, service := range services {
cmd := exec.Command("systemctl", "status", service)
if err := cmd.Run(); err == nil {
@@ -31,7 +31,7 @@ func detectServiceName() string {
return service
}
}
// Default to pulse-backend if no service found
return "pulse-backend"
}
@@ -68,9 +68,22 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
return
}
// Check if password auth is already configured
// Allow adding password auth on top of API-only access
if r.config.AuthUser != "" && r.config.AuthPass != "" {
// Parse request body
var setupRequest struct {
Username string `json:"username"`
Password string `json:"password"`
APIToken string `json:"apiToken"`
EnableNotifications bool `json:"enableNotifications"`
DarkMode bool `json:"darkMode"`
Force bool `json:"force"`
}
if err := json.NewDecoder(req.Body).Decode(&setupRequest); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if r.config.AuthUser != "" && r.config.AuthPass != "" && !setupRequest.Force {
log.Info().Msg("Security setup skipped - password auth already configured")
response := map[string]interface{}{
"success": true,
@@ -82,33 +95,22 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
return
}
// Parse request body
var setupRequest struct {
Username string `json:"username"`
Password string `json:"password"`
APIToken string `json:"apiToken"`
EnableNotifications bool `json:"enableNotifications"`
DarkMode bool `json:"darkMode"`
if setupRequest.Force {
log.Info().Msg("Quick security setup invoked with force=true - rotating credentials")
}
if err := json.NewDecoder(req.Body).Decode(&setupRequest); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate inputs
if setupRequest.Username == "" || setupRequest.Password == "" || setupRequest.APIToken == "" {
http.Error(w, "Username, password, and API token are required", http.StatusBadRequest)
return
}
// Validate password complexity
if err := internalauth.ValidatePasswordComplexity(setupRequest.Password); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Hash the password
hashedPassword, err := internalauth.HashPassword(setupRequest.Password)
if err != nil {
@@ -116,59 +118,59 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
http.Error(w, "Failed to process password", http.StatusInternalServerError)
return
}
// Validate the bcrypt hash is complete
if err := validateBcryptHash(hashedPassword); err != nil {
log.Error().Err(err).Msg("Generated invalid bcrypt hash")
http.Error(w, fmt.Sprintf("Password hashing error: %v", err), http.StatusInternalServerError)
return
}
// Store the raw API token for displaying to the user
rawAPIToken := setupRequest.APIToken
// Hash the API token for storage
hashedAPIToken := internalauth.HashAPIToken(rawAPIToken)
if r.config.APIToken != "" && r.config.AuthUser == "" && r.config.AuthPass == "" {
// We had API-only access before, now replacing with full security
log.Info().Msg("Replacing API-only token with new secure token")
}
// Update runtime config immediately with hashed token - no restart needed!
r.config.AuthUser = setupRequest.Username
r.config.AuthPass = hashedPassword
r.config.APIToken = hashedAPIToken
r.config.APITokenEnabled = true
log.Info().Msg("Runtime config updated with new security settings - active immediately")
// Save system settings to system.json
systemSettings := config.SystemSettings{
ConnectionTimeout: 10, // Default
AutoUpdateEnabled: false, // Default
ConnectionTimeout: 10, // Default
AutoUpdateEnabled: false, // Default
}
if err := r.persistence.SaveSystemSettings(systemSettings); err != nil {
log.Error().Err(err).Msg("Failed to save system settings")
// Continue anyway - not critical for auth setup
}
// Detect environment
isSystemd := os.Getenv("INVOCATION_ID") != ""
isDocker := os.Getenv("PULSE_DOCKER") == "true"
isRoot := isRunningAsRoot()
// Detect actual service name if systemd
serviceName := ""
if isSystemd {
serviceName = detectServiceName()
log.Info().Str("service", serviceName).Msg("Detected systemd service name")
}
// Choose appropriate method based on environment
if isDocker {
// Docker: Save to /data/.env with proper quoting
envPath := filepath.Join(r.config.ConfigPath, ".env")
// CRITICAL: Use single quotes to prevent shell expansion of $ in bcrypt hash
envContent := fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup
# Generated on %s
@@ -176,48 +178,48 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
PULSE_AUTH_USER='%s'
PULSE_AUTH_PASS='%s'
API_TOKEN=%s
ENABLE_AUDIT_LOG=true
PULSE_AUDIT_LOG=true
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken)
// Ensure directory exists
os.MkdirAll(r.config.ConfigPath, 0755)
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil {
log.Error().Err(err).Str("path", envPath).Msg("Failed to write .env file in Docker")
http.Error(w, "Failed to save security configuration", http.StatusInternalServerError)
return
}
log.Info().Str("path", envPath).Msg("Docker security configuration saved")
response := map[string]interface{}{
"success": true,
"method": "docker",
"deploymentType": "docker",
"success": true,
"method": "docker",
"deploymentType": "docker",
"requiresManualRestart": false,
"message": "Security enabled immediately! Your settings are saved and active.",
"note": "Configuration saved to /data/.env for persistence across restarts.",
"message": "Security enabled immediately! Your settings are saved and active.",
"note": "Configuration saved to /data/.env for persistence across restarts.",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
} else if isSystemd && !isRoot {
// Systemd but not root (ProxmoxVE script scenario)
// Don't attempt sudo, just save config and provide instructions
envPath := filepath.Join(r.config.ConfigPath, ".env")
envContent := fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup
# Generated on %s
PULSE_AUTH_USER='%s'
PULSE_AUTH_PASS='%s'
API_TOKEN=%s
ENABLE_AUDIT_LOG=true
PULSE_AUDIT_LOG=true
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken)
// Save to config directory (usually /etc/pulse)
os.MkdirAll(r.config.ConfigPath, 0755)
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil {
// Try data directory as fallback
envPath = filepath.Join(r.config.DataPath, ".env")
@@ -228,103 +230,103 @@ ENABLE_AUDIT_LOG=true
return
}
}
// Create response - security is active immediately
response := map[string]interface{}{
"success": true,
"method": "systemd-nonroot",
"serviceName": serviceName,
"envFile": envPath,
"deploymentType": updates.GetDeploymentType(),
"success": true,
"method": "systemd-nonroot",
"serviceName": serviceName,
"envFile": envPath,
"deploymentType": updates.GetDeploymentType(),
"requiresManualRestart": false,
"message": "Security enabled immediately! Your settings are saved and active.",
"note": fmt.Sprintf("Configuration saved to %s for persistence across restarts.", envPath),
"message": "Security enabled immediately! Your settings are saved and active.",
"note": fmt.Sprintf("Configuration saved to %s for persistence across restarts.", envPath),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
} else if isSystemd && isRoot {
// Systemd with root - can apply directly
// Create systemd override
overridePath := fmt.Sprintf("/etc/systemd/system/%s.service.d/override.conf", serviceName)
overrideDir := filepath.Dir(overridePath)
if err := os.MkdirAll(overrideDir, 0755); err != nil {
log.Error().Err(err).Msg("Failed to create override directory")
http.Error(w, "Failed to create systemd override directory", http.StatusInternalServerError)
return
}
overrideContent := fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup
# Generated on %s
[Service]
Environment="PULSE_AUTH_USER=%s"
Environment="PULSE_AUTH_PASS=%s"
Environment="API_TOKEN=%s"
Environment="ENABLE_AUDIT_LOG=true"
Environment="PULSE_AUDIT_LOG=true"
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken)
if err := os.WriteFile(overridePath, []byte(overrideContent), 0644); err != nil {
log.Error().Err(err).Msg("Failed to write systemd override")
http.Error(w, "Failed to write systemd override", http.StatusInternalServerError)
return
}
// Reload systemd
exec.Command("systemctl", "daemon-reload").Run()
response := map[string]interface{}{
"success": true,
"method": "systemd-root",
"serviceName": serviceName,
"deploymentType": updates.GetDeploymentType(),
"automatic": true,
"success": true,
"method": "systemd-root",
"serviceName": serviceName,
"deploymentType": updates.GetDeploymentType(),
"automatic": true,
"requiresManualRestart": false,
"message": "Security enabled immediately! Your settings are saved and active.",
"note": "Systemd override created for persistence across restarts.",
"message": "Security enabled immediately! Your settings are saved and active.",
"note": "Systemd override created for persistence across restarts.",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
} else {
// Manual installation or development
envPath := filepath.Join(r.config.ConfigPath, ".env")
if r.config.ConfigPath == "" {
envPath = "/etc/pulse/.env"
}
envContent := fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup
# Generated on %s
PULSE_AUTH_USER='%s'
PULSE_AUTH_PASS='%s'
API_TOKEN=%s
ENABLE_AUDIT_LOG=true
PULSE_AUDIT_LOG=true
`, time.Now().Format(time.RFC3339), setupRequest.Username, hashedPassword, hashedAPIToken)
// Try to create directory if needed
os.MkdirAll(filepath.Dir(envPath), 0755)
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil {
log.Error().Err(err).Msg("Failed to write .env file")
// Still return success with manual instructions
}
// Get deployment type for restart instructions
deploymentType := updates.GetDeploymentType()
response := map[string]interface{}{
"success": true,
"method": "manual",
"envFile": envPath,
"deploymentType": deploymentType,
"success": true,
"method": "manual",
"envFile": envPath,
"deploymentType": deploymentType,
"requiresManualRestart": false,
"message": "Security enabled immediately! Your settings are saved and active.",
"note": fmt.Sprintf("Configuration saved to %s for persistence across restarts.", envPath),
"message": "Security enabled immediately! Your settings are saved and active.",
"note": fmt.Sprintf("Configuration saved to %s for persistence across restarts.", envPath),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
@@ -339,7 +341,7 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
if !r.config.DisableAuth && (r.config.AuthUser != "" || r.config.AuthPass != "") && !CheckAuth(r.config, w, rq) {
return
}
// Check if using proxy auth and if so, verify admin status
if r.config.ProxyAuthSecret != "" {
if valid, username, isAdmin := CheckProxyAuth(r.config, rq); valid {
@@ -351,7 +353,7 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
Str("method", rq.Method).
Str("username", username).
Msg("Non-admin user attempted to regenerate API token")
// Return forbidden error
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
@@ -360,12 +362,12 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
}
}
}
if rq.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Apply rate limiting to prevent abuse
clientIP := GetClientIP(rq)
if !authLimiter.Allow(clientIP) {
@@ -373,7 +375,7 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
http.Error(w, "Too many attempts. Please try again later.", http.StatusTooManyRequests)
return
}
// Generate new token using the auth package
rawToken, err := internalauth.GenerateAPIToken()
if err != nil {
@@ -381,26 +383,26 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
// Hash the token for storage
hashedToken := internalauth.HashAPIToken(rawToken)
// Update runtime config immediately with hashed token - no restart needed!
r.config.APIToken = hashedToken
r.config.APITokenEnabled = true
log.Info().Msg("Runtime config updated with new hashed API token - active immediately")
// Determine env file path
envPath := filepath.Join(r.config.ConfigPath, ".env")
if r.config.ConfigPath == "" {
envPath = "/etc/pulse/.env"
}
// Docker uses /data/.env
if _, err := os.Stat("/data/.env"); err == nil {
envPath = "/data/.env"
}
// Read existing .env file
content, err := os.ReadFile(envPath)
if err != nil {
@@ -408,7 +410,7 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
http.Error(w, "Security configuration not found", http.StatusNotFound)
return
}
// Update the API_TOKEN line with the hashed token
lines := strings.Split(string(content), "\n")
var updated bool
@@ -419,12 +421,12 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
break
}
}
if !updated {
// API_TOKEN line not found, add it
lines = append(lines, fmt.Sprintf("API_TOKEN=%s", hashedToken))
}
// Write updated content back
newContent := strings.Join(lines, "\n")
if err := os.WriteFile(envPath, []byte(newContent), 0600); err != nil {
@@ -432,20 +434,20 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques
http.Error(w, "Failed to save new token", http.StatusInternalServerError)
return
}
log.Info().Msg("API token regenerated successfully")
// Get deployment type for restart instructions
deploymentType := updates.GetDeploymentType()
response := map[string]interface{}{
"success": true,
"token": rawToken, // Return the raw token to the user (only shown once!)
"deploymentType": deploymentType,
"success": true,
"token": rawToken, // Return the raw token to the user (only shown once!)
"deploymentType": deploymentType,
"requiresRestart": false,
"message": "New API token generated and active immediately! Save this token - it won't be shown again.",
"message": "New API token generated and active immediately! Save this token - it won't be shown again.",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
}