Implement UI toggle for Hide Local Login (related to issue #750)

This commit is contained in:
courtmanr@gmail.com 2025-11-25 08:14:19 +00:00
parent 8b7ff2ad48
commit 3acd29c3f3
7 changed files with 382 additions and 332 deletions

View file

@ -82,6 +82,7 @@ Environment variables take precedence over `system.json`.
| `LOG_LEVEL` | Log verbosity | `info` | | `LOG_LEVEL` | Log verbosity | `info` |
| `DISCOVERY_ENABLED` | Auto-discover nodes | `false` | | `DISCOVERY_ENABLED` | Auto-discover nodes | `false` |
| `ALLOWED_ORIGINS` | CORS allowed domains | `""` (Same origin) | | `ALLOWED_ORIGINS` | CORS allowed domains | `""` (Same origin) |
| `PULSE_AUTH_HIDE_LOCAL_LOGIN` | Hide username/password form (useful for SSO) | `false` |
--- ---

View file

@ -13,6 +13,8 @@ Enable Single Sign-On (SSO) with providers like Authentik, Keycloak, Okta, and A
* **Client ID & Secret**: From your IdP. * **Client ID & Secret**: From your IdP.
4. **Save**: The login page will now show a "Continue with Single Sign-On" button. 4. **Save**: The login page will now show a "Continue with Single Sign-On" button.
> **Tip**: To hide the username/password form and only show the SSO button, set `PULSE_AUTH_HIDE_LOCAL_LOGIN=true` in your environment. You can still access the local login by appending `?show_local=true` to the login URL.
## ⚙️ Configuration ## ⚙️ Configuration
| Setting | Description | | Setting | Description |

View file

@ -638,10 +638,16 @@ const Settings: Component<SettingsProps> = (props) => {
const [temperatureMonitoringEnabled, setTemperatureMonitoringEnabled] = createSignal(true); const [temperatureMonitoringEnabled, setTemperatureMonitoringEnabled] = createSignal(true);
const [savingTemperatureSetting, setSavingTemperatureSetting] = createSignal(false); const [savingTemperatureSetting, setSavingTemperatureSetting] = createSignal(false);
const [hostProxyStatus, setHostProxyStatus] = createSignal<HostProxyStatusResponse | null>(null); const [hostProxyStatus, setHostProxyStatus] = createSignal<HostProxyStatusResponse | null>(null);
const [hideLocalLogin, setHideLocalLogin] = createSignal(false);
const [savingHideLocalLogin, setSavingHideLocalLogin] = createSignal(false);
const temperatureMonitoringLocked = () => const temperatureMonitoringLocked = () =>
Boolean( Boolean(
envOverrides().temperatureMonitoringEnabled || envOverrides()['ENABLE_TEMPERATURE_MONITORING'], envOverrides().temperatureMonitoringEnabled || envOverrides()['ENABLE_TEMPERATURE_MONITORING'],
); );
const hideLocalLoginLocked = () =>
Boolean(envOverrides().hideLocalLogin || envOverrides()['PULSE_AUTH_HIDE_LOCAL_LOGIN']);
const pvePollingEnvLocked = () => const pvePollingEnvLocked = () =>
Boolean(envOverrides().pvePollingInterval || envOverrides().PVE_POLLING_INTERVAL); Boolean(envOverrides().pvePollingInterval || envOverrides().PVE_POLLING_INTERVAL);
let discoverySubnetInputRef: HTMLInputElement | undefined; let discoverySubnetInputRef: HTMLInputElement | undefined;
@ -705,6 +711,35 @@ const Settings: Component<SettingsProps> = (props) => {
}); });
}; };
const handleHideLocalLoginChange = async (enabled: boolean): Promise<void> => {
if (hideLocalLoginLocked() || savingHideLocalLogin()) {
return;
}
const previous = hideLocalLogin();
setHideLocalLogin(enabled);
setSavingHideLocalLogin(true);
try {
await SettingsAPI.updateSystemSettings({ hideLocalLogin: enabled });
if (enabled) {
notificationStore.success('Local login hidden', 2000);
} else {
notificationStore.info('Local login visible', 2000);
}
// Reload security status to reflect changes
await loadSecurityStatus();
} catch (error) {
logger.error('Failed to update hide local login setting', error);
notificationStore.error(
error instanceof Error ? error.message : 'Failed to update hide local login setting',
);
setHideLocalLogin(previous);
} finally {
setSavingHideLocalLogin(false);
}
};
const applySavedDiscoverySubnet = (subnet?: string | null) => { const applySavedDiscoverySubnet = (subnet?: string | null) => {
const raw = typeof subnet === 'string' ? subnet.trim() : ''; const raw = typeof subnet === 'string' ? subnet.trim() : '';
if (raw === '' || raw.toLowerCase() === 'auto') { if (raw === '' || raw.toLowerCase() === 'auto') {
@ -980,8 +1015,8 @@ const Settings: Component<SettingsProps> = (props) => {
diag.temperatureProxy.socketFound && diag.temperatureProxy.proxyReachable diag.temperatureProxy.socketFound && diag.temperatureProxy.proxyReachable
? 'healthy' ? 'healthy'
: diag.temperatureProxy.socketFound : diag.temperatureProxy.socketFound
? 'error' ? 'error'
: 'missing'; : 'missing';
const cooldowns: Record<string, TemperatureSocketCooldownInfo> = {}; const cooldowns: Record<string, TemperatureSocketCooldownInfo> = {};
const socketHosts = diag.temperatureProxy.socketHostCooldowns || []; const socketHosts = diag.temperatureProxy.socketHostCooldowns || [];
(socketHosts as TemperatureProxySocketHost[]).forEach((entry) => { (socketHosts as TemperatureProxySocketHost[]).forEach((entry) => {
@ -1018,11 +1053,11 @@ const Settings: Component<SettingsProps> = (props) => {
setHostProxyStatus( setHostProxyStatus(
diag?.temperatureProxy?.hostProxySummary diag?.temperatureProxy?.hostProxySummary
? { ? {
hostSocketPresent: Boolean(diag.temperatureProxy?.socketFound), hostSocketPresent: Boolean(diag.temperatureProxy?.socketFound),
containerSocketPresent: containerSocketPresent:
diag.temperatureProxy?.hostProxySummary?.containerSocketPresent ?? undefined, diag.temperatureProxy?.hostProxySummary?.containerSocketPresent ?? undefined,
summary: diag.temperatureProxy?.hostProxySummary ?? undefined, summary: diag.temperatureProxy?.hostProxySummary ?? undefined,
} }
: null, : null,
); );
} catch (err) { } catch (err) {
@ -1090,10 +1125,10 @@ const Settings: Component<SettingsProps> = (props) => {
const nodes = Array.isArray(data.nodes) const nodes = Array.isArray(data.nodes)
? (data.nodes as Array<Record<string, unknown>>).map((node) => ({ ? (data.nodes as Array<Record<string, unknown>>).map((node) => ({
name: typeof node.name === 'string' ? node.name : 'unknown', name: typeof node.name === 'string' ? node.name : 'unknown',
sshReady: Boolean(node.ssh_ready), sshReady: Boolean(node.ssh_ready),
error: typeof node.error === 'string' ? node.error : undefined, error: typeof node.error === 'string' ? node.error : undefined,
})) }))
: []; : [];
setProxyRegisterSummary(nodes); setProxyRegisterSummary(nodes);
@ -1191,83 +1226,83 @@ const Settings: Component<SettingsProps> = (props) => {
disabled?: boolean; disabled?: boolean;
}[]; }[];
}[] = [ }[] = [
{ {
id: 'platforms', id: 'platforms',
label: 'Platforms', label: 'Platforms',
items: [ items: [
{ id: 'proxmox', label: 'Proxmox', icon: ProxmoxIcon }, { id: 'proxmox', label: 'Proxmox', icon: ProxmoxIcon },
{ id: 'docker', label: 'Docker', icon: Boxes }, { id: 'docker', label: 'Docker', icon: Boxes },
{ id: 'hosts', label: 'Hosts', icon: Monitor, iconProps: { strokeWidth: 2 } }, { id: 'hosts', label: 'Hosts', icon: Monitor, iconProps: { strokeWidth: 2 } },
], ],
}, },
{ {
id: 'operations', id: 'operations',
label: 'Operations', label: 'Operations',
items: [ items: [
{ id: 'api', label: 'API Tokens', icon: BadgeCheck }, { id: 'api', label: 'API Tokens', icon: BadgeCheck },
{ {
id: 'diagnostics', id: 'diagnostics',
label: 'Diagnostics', label: 'Diagnostics',
icon: Activity, icon: Activity,
iconProps: { strokeWidth: 2 }, iconProps: { strokeWidth: 2 },
}, },
], ],
}, },
{ {
id: 'system', id: 'system',
label: 'System', label: 'System',
items: [ items: [
{ {
id: 'system-general', id: 'system-general',
label: 'General', label: 'General',
icon: Sliders, icon: Sliders,
iconProps: { strokeWidth: 2 }, iconProps: { strokeWidth: 2 },
}, },
{ {
id: 'system-network', id: 'system-network',
label: 'Network', label: 'Network',
icon: Network, icon: Network,
iconProps: { strokeWidth: 2 }, iconProps: { strokeWidth: 2 },
}, },
{ {
id: 'system-updates', id: 'system-updates',
label: 'Updates', label: 'Updates',
icon: RefreshCw, icon: RefreshCw,
iconProps: { strokeWidth: 2 }, iconProps: { strokeWidth: 2 },
}, },
{ {
id: 'system-backups', id: 'system-backups',
label: 'Backups', label: 'Backups',
icon: Clock, icon: Clock,
iconProps: { strokeWidth: 2 }, iconProps: { strokeWidth: 2 },
}, },
], ],
}, },
{ {
id: 'security', id: 'security',
label: 'Security', label: 'Security',
items: [ items: [
{ {
id: 'security-overview', id: 'security-overview',
label: 'Overview', label: 'Overview',
icon: Shield, icon: Shield,
iconProps: { strokeWidth: 2 }, iconProps: { strokeWidth: 2 },
}, },
{ {
id: 'security-auth', id: 'security-auth',
label: 'Authentication', label: 'Authentication',
icon: Lock, icon: Lock,
iconProps: { strokeWidth: 2 }, iconProps: { strokeWidth: 2 },
}, },
{ {
id: 'security-sso', id: 'security-sso',
label: 'Single Sign-On', label: 'Single Sign-On',
icon: Key, icon: Key,
iconProps: { strokeWidth: 2 }, iconProps: { strokeWidth: 2 },
}, },
], ],
}, },
]; ];
const flatTabs = tabGroups.flatMap((group) => group.items); const flatTabs = tabGroups.flatMap((group) => group.items);
@ -1960,6 +1995,9 @@ const Settings: Component<SettingsProps> = (props) => {
? systemSettings.temperatureMonitoringEnabled ? systemSettings.temperatureMonitoringEnabled
: true, : true,
); );
// Load hideLocalLogin setting
setHideLocalLogin(systemSettings.hideLocalLogin ?? false);
// Backup polling controls // Backup polling controls
if (typeof systemSettings.backupPollingEnabled === 'boolean') { if (typeof systemSettings.backupPollingEnabled === 'boolean') {
setBackupPollingEnabled(systemSettings.backupPollingEnabled); setBackupPollingEnabled(systemSettings.backupPollingEnabled);
@ -2558,15 +2596,13 @@ const Settings: Component<SettingsProps> = (props) => {
type="button" type="button"
aria-current={isActive() ? 'page' : undefined} aria-current={isActive() ? 'page' : undefined}
disabled={item.disabled} disabled={item.disabled}
class={`flex w-full items-center ${sidebarCollapsed() ? 'justify-center' : 'gap-2.5'} rounded-md ${ class={`flex w-full items-center ${sidebarCollapsed() ? 'justify-center' : 'gap-2.5'} rounded-md ${sidebarCollapsed() ? 'px-2 py-2.5' : 'px-3 py-2'
sidebarCollapsed() ? 'px-2 py-2.5' : 'px-3 py-2' } text-sm font-medium transition-colors ${item.disabled
} text-sm font-medium transition-colors ${
item.disabled
? 'opacity-60 cursor-not-allowed text-gray-400 dark:text-gray-600' ? 'opacity-60 cursor-not-allowed text-gray-400 dark:text-gray-600'
: isActive() : isActive()
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-200' ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-200'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/60 dark:hover:text-gray-100' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700/60 dark:hover:text-gray-100'
}`} }`}
onClick={() => { onClick={() => {
if (item.disabled) return; if (item.disabled) return;
setActiveTab(item.id); setActiveTab(item.id);
@ -2604,13 +2640,12 @@ const Settings: Component<SettingsProps> = (props) => {
<button <button
type="button" type="button"
disabled={disabled} disabled={disabled}
class={`px-3 py-2 text-xs font-medium border-b-2 transition-colors whitespace-nowrap ${ class={`px-3 py-2 text-xs font-medium border-b-2 transition-colors whitespace-nowrap ${disabled
disabled ? 'opacity-60 cursor-not-allowed text-gray-400 dark:text-gray-600 border-transparent'
? 'opacity-60 cursor-not-allowed text-gray-400 dark:text-gray-600 border-transparent' : isActive
: isActive ? 'text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400'
? 'text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400' : 'text-gray-600 dark:text-gray-400 border-transparent hover:text-blue-500 dark:hover:text-blue-300 hover:border-blue-300/70 dark:hover:border-blue-500/50'
: 'text-gray-600 dark:text-gray-400 border-transparent hover:text-blue-500 dark:hover:text-blue-300 hover:border-blue-300/70 dark:hover:border-blue-500/50' }`}
}`}
onClick={() => { onClick={() => {
if (disabled) return; if (disabled) return;
setActiveTab(tab.id); setActiveTab(tab.id);
@ -2803,7 +2838,7 @@ const Settings: Component<SettingsProps> = (props) => {
Last scan{' '} Last scan{' '}
{formatRelativeTime( {formatRelativeTime(
discoveryScanStatus().lastResultAt ?? discoveryScanStatus().lastResultAt ??
discoveryScanStatus().lastScanStartedAt, discoveryScanStatus().lastScanStartedAt,
)} )}
</span> </span>
</Show> </Show>
@ -3090,7 +3125,7 @@ const Settings: Component<SettingsProps> = (props) => {
Last scan{' '} Last scan{' '}
{formatRelativeTime( {formatRelativeTime(
discoveryScanStatus().lastResultAt ?? discoveryScanStatus().lastResultAt ??
discoveryScanStatus().lastScanStartedAt, discoveryScanStatus().lastScanStartedAt,
)} )}
</span> </span>
</Show> </Show>
@ -3374,7 +3409,7 @@ const Settings: Component<SettingsProps> = (props) => {
Last scan{' '} Last scan{' '}
{formatRelativeTime( {formatRelativeTime(
discoveryScanStatus().lastResultAt ?? discoveryScanStatus().lastResultAt ??
discoveryScanStatus().lastScanStartedAt, discoveryScanStatus().lastScanStartedAt,
)} )}
</span> </span>
</Show> </Show>
@ -3584,8 +3619,8 @@ const Settings: Component<SettingsProps> = (props) => {
Current cadence: {pvePollingInterval()} seconds ( Current cadence: {pvePollingInterval()} seconds (
{pvePollingInterval() >= 60 {pvePollingInterval() >= 60
? `${(pvePollingInterval() / 60).toFixed( ? `${(pvePollingInterval() / 60).toFixed(
pvePollingInterval() % 60 === 0 ? 0 : 1, pvePollingInterval() % 60 === 0 ? 0 : 1,
)} minute${pvePollingInterval() / 60 === 1 ? '' : 's'}` )} minute${pvePollingInterval() / 60 === 1 ? '' : 's'}`
: 'under a minute'} : 'under a minute'}
). ).
</p> </p>
@ -3596,11 +3631,10 @@ const Settings: Component<SettingsProps> = (props) => {
{(option) => ( {(option) => (
<button <button
type="button" type="button"
class={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${ class={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${pvePollingSelection() === option.value
pvePollingSelection() === option.value ? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-100'
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-100' : 'border-gray-200 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-900/50 dark:text-gray-200'
: 'border-gray-200 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-900/50 dark:text-gray-200' } ${pvePollingEnvLocked() ? 'opacity-60 cursor-not-allowed' : ''}`}
} ${pvePollingEnvLocked() ? 'opacity-60 cursor-not-allowed' : ''}`}
disabled={pvePollingEnvLocked()} disabled={pvePollingEnvLocked()}
onClick={() => { onClick={() => {
if (pvePollingEnvLocked()) return; if (pvePollingEnvLocked()) return;
@ -3615,11 +3649,10 @@ const Settings: Component<SettingsProps> = (props) => {
</For> </For>
<button <button
type="button" type="button"
class={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${ class={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${pvePollingSelection() === 'custom'
pvePollingSelection() === 'custom' ? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-100'
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-100' : 'border-gray-200 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-900/50 dark:text-gray-200'
: 'border-gray-200 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-900/50 dark:text-gray-200' } ${pvePollingEnvLocked() ? 'opacity-60 cursor-not-allowed' : ''}`}
} ${pvePollingEnvLocked() ? 'opacity-60 cursor-not-allowed' : ''}`}
disabled={pvePollingEnvLocked()} disabled={pvePollingEnvLocked()}
onClick={() => { onClick={() => {
if (pvePollingEnvLocked()) return; if (pvePollingEnvLocked()) return;
@ -3792,11 +3825,10 @@ const Settings: Component<SettingsProps> = (props) => {
</legend> </legend>
<div class="space-y-2"> <div class="space-y-2">
<label <label
class={`flex items-start gap-3 rounded-lg border p-2 transition-colors ${ class={`flex items-start gap-3 rounded-lg border p-2 transition-colors ${discoveryMode() === 'auto'
discoveryMode() === 'auto' ? 'border-blue-200 bg-blue-50/80 dark:border-blue-700 dark:bg-blue-900/20'
? 'border-blue-200 bg-blue-50/80 dark:border-blue-700 dark:bg-blue-900/20' : 'border-transparent hover:border-gray-200 dark:hover:border-gray-600'
: 'border-transparent hover:border-gray-200 dark:hover:border-gray-600' }`}
}`}
> >
<input <input
type="radio" type="radio"
@ -3825,11 +3857,10 @@ const Settings: Component<SettingsProps> = (props) => {
</label> </label>
<label <label
class={`flex items-start gap-3 rounded-lg border p-2 transition-colors ${ class={`flex items-start gap-3 rounded-lg border p-2 transition-colors ${discoveryMode() === 'custom'
discoveryMode() === 'custom' ? 'border-blue-200 bg-blue-50/80 dark:border-blue-700 dark:bg-blue-900/20'
? 'border-blue-200 bg-blue-50/80 dark:border-blue-700 dark:bg-blue-900/20' : 'border-transparent hover:border-gray-200 dark:hover:border-gray-600'
: 'border-transparent hover:border-gray-200 dark:hover:border-gray-600' }`}
}`}
> >
<input <input
type="radio" type="radio"
@ -3869,11 +3900,10 @@ const Settings: Component<SettingsProps> = (props) => {
return ( return (
<button <button
type="button" type="button"
class={`rounded border px-2.5 py-1 text-[0.7rem] transition-colors ${ class={`rounded border px-2.5 py-1 text-[0.7rem] transition-colors ${isActive
isActive ? 'border-blue-500 bg-blue-600 text-white dark:border-blue-400 dark:bg-blue-500'
? 'border-blue-500 bg-blue-600 text-white dark:border-blue-400 dark:bg-blue-500' : 'border-gray-300 text-gray-700 hover:border-blue-400 hover:bg-blue-50 dark:border-gray-600 dark:text-gray-300 dark:hover:border-blue-500 dark:hover:bg-blue-900/30'
: 'border-gray-300 text-gray-700 hover:border-blue-400 hover:bg-blue-50 dark:border-gray-600 dark:text-gray-300 dark:hover:border-blue-500 dark:hover:bg-blue-900/30' }`}
}`}
onClick={async () => { onClick={async () => {
if (envOverrides().discoverySubnet) { if (envOverrides().discoverySubnet) {
return; return;
@ -3958,11 +3988,10 @@ const Settings: Component<SettingsProps> = (props) => {
? 'auto (scan every network phase)' ? 'auto (scan every network phase)'
: '192.168.1.0/24, 10.0.0.0/24' : '192.168.1.0/24, 10.0.0.0/24'
} }
class={`w-full rounded-lg border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 ${ class={`w-full rounded-lg border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 ${envOverrides().discoverySubnet
envOverrides().discoverySubnet ? 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-600 dark:bg-amber-900/20 dark:text-amber-200 cursor-not-allowed opacity-60'
? 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-600 dark:bg-amber-900/20 dark:text-amber-200 cursor-not-allowed opacity-60' : 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-900/70'
: 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-900/70' }`}
}`}
disabled={envOverrides().discoverySubnet} disabled={envOverrides().discoverySubnet}
onInput={(e) => { onInput={(e) => {
if (envOverrides().discoverySubnet) { if (envOverrides().discoverySubnet) {
@ -4084,11 +4113,10 @@ const Settings: Component<SettingsProps> = (props) => {
}} }}
disabled={envOverrides().allowedOrigins} disabled={envOverrides().allowedOrigins}
placeholder="* or https://example.com" placeholder="* or https://example.com"
class={`w-full px-3 py-1.5 text-sm border rounded-lg ${ class={`w-full px-3 py-1.5 text-sm border rounded-lg ${envOverrides().allowedOrigins
envOverrides().allowedOrigins ? 'border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 cursor-not-allowed opacity-75'
? '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'
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800' }`}
}`}
/> />
{envOverrides().allowedOrigins && ( {envOverrides().allowedOrigins && (
<div class="mt-2 p-2 bg-amber-100 dark:bg-amber-900/30 border border-amber-300 dark:border-amber-700 rounded text-xs text-amber-800 dark:text-amber-200"> <div class="mt-2 p-2 bg-amber-100 dark:bg-amber-900/30 border border-amber-300 dark:border-amber-700 rounded text-xs text-amber-800 dark:text-amber-200">
@ -4305,11 +4333,10 @@ const Settings: Component<SettingsProps> = (props) => {
versionInfo()?.isDocker || versionInfo()?.isDocker ||
versionInfo()?.isSourceBuild versionInfo()?.isSourceBuild
} }
class={`px-4 py-2 text-sm rounded-lg transition-colors flex items-center gap-2 ${ class={`px-4 py-2 text-sm rounded-lg transition-colors flex items-center gap-2 ${versionInfo()?.isDocker || versionInfo()?.isSourceBuild
versionInfo()?.isDocker || versionInfo()?.isSourceBuild ? 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
? 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed' : 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-blue-600 text-white hover:bg-blue-700' }`}
}`}
> >
{checkingForUpdates() ? ( {checkingForUpdates() ? (
<> <>
@ -5125,6 +5152,18 @@ const Settings: Component<SettingsProps> = (props) => {
</div> </div>
</div> </div>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<Toggle
label="Hide local login form"
description="Hide the username/password form on the login page. Users will only see SSO options unless ?show_local=true is used."
checked={hideLocalLogin()}
onChange={(e: ToggleChangeEvent) => handleHideLocalLoginChange(e.target.checked)}
disabled={hideLocalLoginLocked() || savingHideLocalLogin()}
locked={hideLocalLoginLocked()}
lockedMessage="This setting is managed by the PULSE_AUTH_HIDE_LOCAL_LOGIN environment variable"
/>
</div>
<Show when={!authDisabledByEnv() && showQuickSecurityWizard()}> <Show when={!authDisabledByEnv() && showQuickSecurityWizard()}>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700"> <div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<QuickSecuritySetup <QuickSecuritySetup
@ -5503,9 +5542,8 @@ const Settings: Component<SettingsProps> = (props) => {
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span>Proxy socket</span> <span>Proxy socket</span>
<span <span
class={`px-2 py-0.5 rounded text-white text-xs ${ class={`px-2 py-0.5 rounded text-white text-xs ${temp().socketFound ? 'bg-green-500' : 'bg-red-500'
temp().socketFound ? 'bg-green-500' : 'bg-red-500' }`}
}`}
> >
{temp().socketFound ? 'Detected' : 'Missing'} {temp().socketFound ? 'Detected' : 'Missing'}
</span> </span>
@ -5513,9 +5551,8 @@ const Settings: Component<SettingsProps> = (props) => {
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span>Daemon</span> <span>Daemon</span>
<span <span
class={`px-2 py-0.5 rounded text-white text-xs ${ class={`px-2 py-0.5 rounded text-white text-xs ${temp().proxyReachable ? 'bg-green-500' : 'bg-yellow-500'
temp().proxyReachable ? 'bg-green-500' : 'bg-yellow-500' }`}
}`}
> >
{temp().proxyReachable ? 'Responding' : 'No response'} {temp().proxyReachable ? 'Responding' : 'No response'}
</span> </span>
@ -5546,71 +5583,70 @@ const Settings: Component<SettingsProps> = (props) => {
<Show when={typeof temp().legacySshKeyCount === 'number'}> <Show when={typeof temp().legacySshKeyCount === 'number'}>
<div>Legacy SSH keys: {temp().legacySshKeyCount ?? 0}</div> <div>Legacy SSH keys: {temp().legacySshKeyCount ?? 0}</div>
</Show> </Show>
<Show when={temp().legacySSHDetected}> <Show when={temp().legacySSHDetected}>
<div class="text-red-500"> <div class="text-red-500">
Legacy SSH temperature collection detected Legacy SSH temperature collection detected
</div>
</Show>
</div>
<Show
when={
temp().controlPlaneStates &&
(temp().controlPlaneStates as TemperatureProxyControlPlaneState[]).length > 0
}
>
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-2">
<div class="flex items-center justify-between">
<div class="font-semibold text-gray-700 dark:text-gray-200">
Control plane sync
</div>
<span
class={`px-2 py-0.5 rounded text-white text-xs ${temp().controlPlaneEnabled ? 'bg-green-500' : 'bg-gray-500'
}`}
>
{temp().controlPlaneEnabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<For each={temp().controlPlaneStates || []}>
{(state) => (
<div class="rounded border border-gray-200 dark:border-gray-700 px-2 py-1.5 space-y-1">
<div class="flex items-center justify-between">
<div class="font-medium text-gray-700 dark:text-gray-200">
{state.instance || 'Proxy'}
</div>
<span
class={`px-2 py-0.5 rounded text-white text-xs ${controlPlaneStatusClass(
state.status,
)}`}
>
{controlPlaneStatusLabel(state.status)}
</span>
</div>
<Show when={state.lastSync}>
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
Last sync: {formatIsoRelativeTime(state.lastSync)}
</div>
</Show>
<Show when={typeof state.secondsBehind === 'number' && (state.secondsBehind || 0) > 0}>
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
Behind by ~{formatUptime(state.secondsBehind || 0)}
</div>
</Show>
<Show when={state.refreshIntervalSeconds}>
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
Target interval: {formatUptime(state.refreshIntervalSeconds || 0)}
</div>
</Show>
</div>
)}
</For>
</div> </div>
</Show> </Show>
</div> <Show
<Show when={
when={ temp().httpProxies && (temp().httpProxies as TemperatureProxyHTTPStatus[]).length > 0
temp().controlPlaneStates && }
(temp().controlPlaneStates as TemperatureProxyControlPlaneState[]).length > 0 >
}
>
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-2">
<div class="flex items-center justify-between">
<div class="font-semibold text-gray-700 dark:text-gray-200">
Control plane sync
</div>
<span
class={`px-2 py-0.5 rounded text-white text-xs ${
temp().controlPlaneEnabled ? 'bg-green-500' : 'bg-gray-500'
}`}
>
{temp().controlPlaneEnabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<For each={temp().controlPlaneStates || []}>
{(state) => (
<div class="rounded border border-gray-200 dark:border-gray-700 px-2 py-1.5 space-y-1">
<div class="flex items-center justify-between">
<div class="font-medium text-gray-700 dark:text-gray-200">
{state.instance || 'Proxy'}
</div>
<span
class={`px-2 py-0.5 rounded text-white text-xs ${controlPlaneStatusClass(
state.status,
)}`}
>
{controlPlaneStatusLabel(state.status)}
</span>
</div>
<Show when={state.lastSync}>
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
Last sync: {formatIsoRelativeTime(state.lastSync)}
</div>
</Show>
<Show when={typeof state.secondsBehind === 'number' && (state.secondsBehind || 0) > 0}>
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
Behind by ~{formatUptime(state.secondsBehind || 0)}
</div>
</Show>
<Show when={state.refreshIntervalSeconds}>
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
Target interval: {formatUptime(state.refreshIntervalSeconds || 0)}
</div>
</Show>
</div>
)}
</For>
</div>
</Show>
<Show
when={
temp().httpProxies && (temp().httpProxies as TemperatureProxyHTTPStatus[]).length > 0
}
>
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-2"> <div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-2">
<div class="font-semibold text-gray-700 dark:text-gray-200"> <div class="font-semibold text-gray-700 dark:text-gray-200">
HTTPS proxies HTTPS proxies
@ -5630,9 +5666,8 @@ const Settings: Component<SettingsProps> = (props) => {
</Show> </Show>
</div> </div>
<span <span
class={`px-2 py-0.5 rounded text-white text-xs ${ class={`px-2 py-0.5 rounded text-white text-xs ${proxy.reachable ? 'bg-green-500' : 'bg-red-500'
proxy.reachable ? 'bg-green-500' : 'bg-red-500' }`}
}`}
> >
{proxy.reachable ? 'Healthy' : 'Error'} {proxy.reachable ? 'Healthy' : 'Error'}
</span> </span>
@ -5658,49 +5693,49 @@ const Settings: Component<SettingsProps> = (props) => {
</a> </a>
</div> </div>
</Show> </Show>
<Show <Show
when={ when={
temp().socketHostCooldowns && temp().socketHostCooldowns &&
(temp().socketHostCooldowns as TemperatureProxySocketHost[]).length > 0 (temp().socketHostCooldowns as TemperatureProxySocketHost[]).length > 0
} }
> >
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-2"> <div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-2">
<div class="font-semibold text-gray-700 dark:text-gray-200"> <div class="font-semibold text-gray-700 dark:text-gray-200">
Socket cooldowns Socket cooldowns
</div> </div>
<For each={temp().socketHostCooldowns || []}> <For each={temp().socketHostCooldowns || []}>
{(entry) => ( {(entry) => (
<div class="rounded border border-amber-200 dark:border-amber-700 px-2 py-1.5 space-y-1"> <div class="rounded border border-amber-200 dark:border-amber-700 px-2 py-1.5 space-y-1">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="font-medium text-gray-700 dark:text-gray-200"> <div class="font-medium text-gray-700 dark:text-gray-200">
{entry.node || entry.host || 'Host'} {entry.node || entry.host || 'Host'}
</div>
<Show when={entry.cooldownUntil}>
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
Until {entry.cooldownUntil}
</div> </div>
</Show> <Show when={entry.cooldownUntil}>
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
Until {entry.cooldownUntil}
</div>
</Show>
</div>
<span class="px-2 py-0.5 rounded text-white text-xs bg-amber-500">
Cooling
</span>
</div> </div>
<span class="px-2 py-0.5 rounded text-white text-xs bg-amber-500"> <Show when={typeof entry.secondsRemaining === 'number'}>
Cooling <div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
</span> Retrying in ~{formatUptime(entry.secondsRemaining || 0)}
</div>
</Show>
<Show when={entry.lastError}>
<div class="text-[0.65rem] text-red-500">
{entry.lastError}
</div>
</Show>
</div> </div>
<Show when={typeof entry.secondsRemaining === 'number'}> )}
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400"> </For>
Retrying in ~{formatUptime(entry.secondsRemaining || 0)} </div>
</div> </Show>
</Show>
<Show when={entry.lastError}>
<div class="text-[0.65rem] text-red-500">
{entry.lastError}
</div>
</Show>
</div>
)}
</For>
</div>
</Show>
<Show <Show
when={proxyNodeChecksSupported()} when={proxyNodeChecksSupported()}
fallback={ fallback={
@ -5848,11 +5883,10 @@ const Settings: Component<SettingsProps> = (props) => {
<div class="text-xs space-y-2 text-gray-600 dark:text-gray-400"> <div class="text-xs space-y-2 text-gray-600 dark:text-gray-400">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<span <span
class={`px-2 py-0.5 rounded text-xs ${ class={`px-2 py-0.5 rounded text-xs ${apiDiag().enabled
apiDiag().enabled ? 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
? 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100' }`}
}`}
> >
{apiDiag().enabled {apiDiag().enabled
? 'Token auth enabled' ? 'Token auth enabled'
@ -5911,8 +5945,8 @@ const Settings: Component<SettingsProps> = (props) => {
Last used:{' '} Last used:{' '}
{token.lastUsedAt {token.lastUsedAt
? formatRelativeTime( ? formatRelativeTime(
new Date(token.lastUsedAt).getTime(), new Date(token.lastUsedAt).getTime(),
) )
: 'Never'} : 'Never'}
</div> </div>
</div> </div>
@ -6005,11 +6039,10 @@ const Settings: Component<SettingsProps> = (props) => {
{entry.name} {entry.name}
</span> </span>
<span <span
class={`px-2 py-0.5 rounded text-xs ${ class={`px-2 py-0.5 rounded text-xs ${entry.status === 'online'
entry.status === 'online' ? 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
? 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100' : 'bg-red-100 text-red-700 dark:bg-red-700/40 dark:text-red-100'
: 'bg-red-100 text-red-700 dark:bg-red-700/40 dark:text-red-100' }`}
}`}
> >
{entry.status || 'unknown'} {entry.status || 'unknown'}
</span> </span>
@ -6128,11 +6161,10 @@ const Settings: Component<SettingsProps> = (props) => {
<div class="text-xs text-gray-600 dark:text-gray-400 space-y-2"> <div class="text-xs text-gray-600 dark:text-gray-400 space-y-2">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<span <span
class={`px-2 py-0.5 rounded text-xs ${ class={`px-2 py-0.5 rounded text-xs ${alerts().legacyThresholdsDetected
alerts().legacyThresholdsDetected ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100' : 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
: 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100' }`}
}`}
> >
Legacy thresholds{' '} Legacy thresholds{' '}
{alerts().legacyThresholdsDetected {alerts().legacyThresholdsDetected
@ -6140,21 +6172,19 @@ const Settings: Component<SettingsProps> = (props) => {
: 'migrated'} : 'migrated'}
</span> </span>
<span <span
class={`px-2 py-0.5 rounded text-xs ${ class={`px-2 py-0.5 rounded text-xs ${alerts().missingCooldown
alerts().missingCooldown ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100' : 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
: 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100' }`}
}`}
> >
Cooldown{' '} Cooldown{' '}
{alerts().missingCooldown ? 'missing' : 'configured'} {alerts().missingCooldown ? 'missing' : 'configured'}
</span> </span>
<span <span
class={`px-2 py-0.5 rounded text-xs ${ class={`px-2 py-0.5 rounded text-xs ${alerts().missingGroupingWindow
alerts().missingGroupingWindow ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100' : 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
: 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100' }`}
}`}
> >
Grouping window{' '} Grouping window{' '}
{alerts().missingGroupingWindow ? 'disabled' : 'enabled'} {alerts().missingGroupingWindow ? 'disabled' : 'enabled'}
@ -6210,9 +6240,8 @@ const Settings: Component<SettingsProps> = (props) => {
{node.name} {node.name}
</span> </span>
<span <span
class={`px-2 py-0.5 rounded text-white text-xs ${ class={`px-2 py-0.5 rounded text-white text-xs ${node.connected ? 'bg-green-500' : 'bg-red-500'
node.connected ? 'bg-green-500' : 'bg-red-500' }`}
}`}
> >
{node.connected ? 'Connected' : 'Failed'} {node.connected ? 'Connected' : 'Failed'}
</span> </span>
@ -6248,9 +6277,8 @@ const Settings: Component<SettingsProps> = (props) => {
{pbs.name} {pbs.name}
</span> </span>
<span <span
class={`px-2 py-0.5 rounded text-white text-xs ${ class={`px-2 py-0.5 rounded text-white text-xs ${pbs.connected ? 'bg-green-500' : 'bg-red-500'
pbs.connected ? 'bg-green-500' : 'bg-red-500' }`}
}`}
> >
{pbs.connected ? 'Connected' : 'Failed'} {pbs.connected ? 'Connected' : 'Failed'}
</span> </span>
@ -6494,13 +6522,13 @@ const Settings: Component<SettingsProps> = (props) => {
: undefined; : undefined;
const clusterEndpoints = Array.isArray(node.clusterEndpoints) const clusterEndpoints = Array.isArray(node.clusterEndpoints)
? (node.clusterEndpoints as Array<Record<string, unknown>>).map( ? (node.clusterEndpoints as Array<Record<string, unknown>>).map(
(ep, epIndex: number) => ({ (ep, epIndex: number) => ({
...ep, ...ep,
NodeName: `node-${epIndex + 1}`, NodeName: `node-${epIndex + 1}`,
Host: `node-${epIndex + 1}`, Host: `node-${epIndex + 1}`,
IP: sanitizeIP(typeof ep.IP === 'string' ? ep.IP : ''), IP: sanitizeIP(typeof ep.IP === 'string' ? ep.IP : ''),
}), }),
) )
: node.clusterEndpoints; : node.clusterEndpoints;
const sanitizedId = `${nodeType}-${index}`; const sanitizedId = `${nodeType}-${index}`;
@ -6868,9 +6896,9 @@ const Settings: Component<SettingsProps> = (props) => {
host: host:
typeof node.host === 'string' typeof node.host === 'string'
? (node.host as string).replace( ? (node.host as string).replace(
/https?:\/\/[^:\/]+/, /https?:\/\/[^:\/]+/,
'https://REDACTED', 'https://REDACTED',
) )
: node.host, : node.host,
error: error:
sanitizeText( sanitizeText(
@ -6897,9 +6925,9 @@ const Settings: Component<SettingsProps> = (props) => {
host: host:
typeof pbsNode.host === 'string' typeof pbsNode.host === 'string'
? (pbsNode.host as string).replace( ? (pbsNode.host as string).replace(
/https?:\/\/[^:\/]+/, /https?:\/\/[^:\/]+/,
'https://REDACTED', 'https://REDACTED',
) )
: pbsNode.host, : pbsNode.host,
error: error:
sanitizeText( sanitizeText(
@ -7003,9 +7031,9 @@ const Settings: Component<SettingsProps> = (props) => {
node: sanitizeHostname(alertNode), node: sanitizeHostname(alertNode),
details: details details: details
? details.replace( ? details.replace(
/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/g, /\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/g,
'xxx.xxx.xxx.xxx', 'xxx.xxx.xxx.xxx',
) )
: details, : details,
}; };
}); });
@ -7118,12 +7146,12 @@ const Settings: Component<SettingsProps> = (props) => {
total: s.total, total: s.total,
zfsPool: s.zfsPool zfsPool: s.zfsPool
? { ? {
state: s.zfsPool.state, state: s.zfsPool.state,
readErrors: s.zfsPool.readErrors, readErrors: s.zfsPool.readErrors,
writeErrors: s.zfsPool.writeErrors, writeErrors: s.zfsPool.writeErrors,
checksumErrors: s.zfsPool.checksumErrors, checksumErrors: s.zfsPool.checksumErrors,
deviceCount: s.zfsPool.devices?.length || 0, deviceCount: s.zfsPool.devices?.length || 0,
} }
: undefined, : undefined,
hasBackups: hasBackups:
(pveBackupsState()?.storageBackups ?? []).filter( (pveBackupsState()?.storageBackups ?? []).filter(
@ -7460,13 +7488,13 @@ const Settings: Component<SettingsProps> = (props) => {
nodes().map((n) => nodes().map((n) =>
n.id === editingNode()!.id n.id === editingNode()!.id
? { ? {
...n, ...n,
...nodeData, ...nodeData,
// Update hasPassword/hasToken based on whether credentials were provided // Update hasPassword/hasToken based on whether credentials were provided
hasPassword: nodeData.password ? true : n.hasPassword, hasPassword: nodeData.password ? true : n.hasPassword,
hasToken: nodeData.tokenValue ? true : n.hasToken, hasToken: nodeData.tokenValue ? true : n.hasToken,
status: 'pending', status: 'pending',
} }
: n, : n,
), ),
); );
@ -7532,12 +7560,12 @@ const Settings: Component<SettingsProps> = (props) => {
nodes().map((n) => nodes().map((n) =>
n.id === editingNode()!.id n.id === editingNode()!.id
? { ? {
...n, ...n,
...nodeData, ...nodeData,
hasPassword: nodeData.password ? true : n.hasPassword, hasPassword: nodeData.password ? true : n.hasPassword,
hasToken: nodeData.tokenValue ? true : n.hasToken, hasToken: nodeData.tokenValue ? true : n.hasToken,
status: 'pending', status: 'pending',
} }
: n, : n,
), ),
); );
@ -7599,12 +7627,12 @@ const Settings: Component<SettingsProps> = (props) => {
nodes().map((n) => nodes().map((n) =>
n.id === editingNode()!.id n.id === editingNode()!.id
? { ? {
...n, ...n,
...nodeData, ...nodeData,
hasPassword: nodeData.password ? true : n.hasPassword, hasPassword: nodeData.password ? true : n.hasPassword,
hasToken: nodeData.tokenValue ? true : n.hasToken, hasToken: nodeData.tokenValue ? true : n.hasToken,
status: 'pending', status: 'pending',
} }
: n, : n,
), ),
); );

View file

@ -47,6 +47,7 @@ export interface SystemConfig {
allowEmbedding?: boolean; // Allow iframe embedding allowEmbedding?: boolean; // Allow iframe embedding
allowedEmbedOrigins?: string; // Comma-separated list of allowed origins for embedding allowedEmbedOrigins?: string; // Comma-separated list of allowed origins for embedding
webhookAllowedPrivateCIDRs?: string; // Comma-separated list of private CIDR ranges allowed for webhooks (e.g., "192.168.1.0/24,10.0.0.0/8") webhookAllowedPrivateCIDRs?: string; // Comma-separated list of private CIDR ranges allowed for webhooks (e.g., "192.168.1.0/24,10.0.0.0/8")
hideLocalLogin?: boolean; // Hide local login form (username/password)
} }
/** /**
@ -121,6 +122,7 @@ export interface SecurityStatus {
disabled?: boolean; // legacy field (removed backend support) disabled?: boolean; // legacy field (removed backend support)
deprecatedDisableAuth?: boolean; deprecatedDisableAuth?: boolean;
message?: string; message?: string;
hideLocalLogin?: boolean; // Hide local login form
} }
/** /**

View file

@ -2852,8 +2852,22 @@ func (h *ConfigHandlers) HandleGetSystemSettings(w http.ResponseWriter, r *http.
backupEnabled := h.config.EnableBackupPolling backupEnabled := h.config.EnableBackupPolling
settings.BackupPollingEnabled = &backupEnabled settings.BackupPollingEnabled = &backupEnabled
// Create response structure that includes environment overrides
response := struct {
config.SystemSettings
EnvOverrides map[string]bool `json:"envOverrides,omitempty"`
}{
SystemSettings: settings,
EnvOverrides: make(map[string]bool),
}
// Check for environment variable overrides
if os.Getenv("PULSE_AUTH_HIDE_LOCAL_LOGIN") != "" {
response.EnvOverrides["hideLocalLogin"] = true
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(settings) json.NewEncoder(w).Encode(response)
} }
// HandleVerifyTemperatureSSH tests SSH connectivity to nodes for temperature monitoring // HandleVerifyTemperatureSSH tests SSH connectivity to nodes for temperature monitoring

View file

@ -654,6 +654,8 @@ func Load() (*Config, error) {
} else { } else {
cfg.SSHPort = 22 // Default SSH port cfg.SSHPort = 22 // Default SSH port
} }
// Load HideLocalLogin
cfg.HideLocalLogin = systemSettings.HideLocalLogin
// APIToken no longer loaded from system.json - only from .env // APIToken no longer loaded from system.json - only from .env
log.Info(). log.Info().
Str("updateChannel", cfg.UpdateChannel). Str("updateChannel", cfg.UpdateChannel).

View file

@ -831,6 +831,7 @@ type SystemSettings struct {
DNSCacheTimeout int `json:"dnsCacheTimeout,omitempty"` // DNS cache timeout in seconds (0 = default 5 minutes) DNSCacheTimeout int `json:"dnsCacheTimeout,omitempty"` // DNS cache timeout in seconds (0 = default 5 minutes)
SSHPort int `json:"sshPort,omitempty"` // Default SSH port for temperature monitoring (0 = use 22) SSHPort int `json:"sshPort,omitempty"` // Default SSH port for temperature monitoring (0 = use 22)
WebhookAllowedPrivateCIDRs string `json:"webhookAllowedPrivateCIDRs,omitempty"` // Comma-separated list of private CIDR ranges allowed for webhooks (e.g., "192.168.1.0/24,10.0.0.0/8") WebhookAllowedPrivateCIDRs string `json:"webhookAllowedPrivateCIDRs,omitempty"` // Comma-separated list of private CIDR ranges allowed for webhooks (e.g., "192.168.1.0/24,10.0.0.0/8")
HideLocalLogin bool `json:"hideLocalLogin"` // Hide local login form (username/password)
// APIToken removed - now handled via .env file only // APIToken removed - now handled via .env file only
} }