From 3acd29c3f30d231f96e41682bd8622f0a97ef461 Mon Sep 17 00:00:00 2001 From: "courtmanr@gmail.com" Date: Tue, 25 Nov 2025 08:14:19 +0000 Subject: [PATCH] Implement UI toggle for Hide Local Login (related to issue #750) --- docs/CONFIGURATION.md | 1 + docs/OIDC.md | 2 + .../src/components/Settings/Settings.tsx | 690 +++++++++--------- frontend-modern/src/types/config.ts | 2 + internal/api/config_handlers.go | 16 +- internal/config/config.go | 2 + internal/config/persistence.go | 1 + 7 files changed, 382 insertions(+), 332 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 07eb806..340df09 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -82,6 +82,7 @@ Environment variables take precedence over `system.json`. | `LOG_LEVEL` | Log verbosity | `info` | | `DISCOVERY_ENABLED` | Auto-discover nodes | `false` | | `ALLOWED_ORIGINS` | CORS allowed domains | `""` (Same origin) | +| `PULSE_AUTH_HIDE_LOCAL_LOGIN` | Hide username/password form (useful for SSO) | `false` | --- diff --git a/docs/OIDC.md b/docs/OIDC.md index 080dc1f..e34a07d 100644 --- a/docs/OIDC.md +++ b/docs/OIDC.md @@ -13,6 +13,8 @@ Enable Single Sign-On (SSO) with providers like Authentik, Keycloak, Okta, and A * **Client ID & Secret**: From your IdP. 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 | Setting | Description | diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 7693117..293c46b 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -638,10 +638,16 @@ const Settings: Component = (props) => { const [temperatureMonitoringEnabled, setTemperatureMonitoringEnabled] = createSignal(true); const [savingTemperatureSetting, setSavingTemperatureSetting] = createSignal(false); const [hostProxyStatus, setHostProxyStatus] = createSignal(null); + const [hideLocalLogin, setHideLocalLogin] = createSignal(false); + const [savingHideLocalLogin, setSavingHideLocalLogin] = createSignal(false); + const temperatureMonitoringLocked = () => Boolean( envOverrides().temperatureMonitoringEnabled || envOverrides()['ENABLE_TEMPERATURE_MONITORING'], ); + const hideLocalLoginLocked = () => + Boolean(envOverrides().hideLocalLogin || envOverrides()['PULSE_AUTH_HIDE_LOCAL_LOGIN']); + const pvePollingEnvLocked = () => Boolean(envOverrides().pvePollingInterval || envOverrides().PVE_POLLING_INTERVAL); let discoverySubnetInputRef: HTMLInputElement | undefined; @@ -705,6 +711,35 @@ const Settings: Component = (props) => { }); }; + const handleHideLocalLoginChange = async (enabled: boolean): Promise => { + 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 raw = typeof subnet === 'string' ? subnet.trim() : ''; if (raw === '' || raw.toLowerCase() === 'auto') { @@ -980,8 +1015,8 @@ const Settings: Component = (props) => { diag.temperatureProxy.socketFound && diag.temperatureProxy.proxyReachable ? 'healthy' : diag.temperatureProxy.socketFound - ? 'error' - : 'missing'; + ? 'error' + : 'missing'; const cooldowns: Record = {}; const socketHosts = diag.temperatureProxy.socketHostCooldowns || []; (socketHosts as TemperatureProxySocketHost[]).forEach((entry) => { @@ -1018,11 +1053,11 @@ const Settings: Component = (props) => { setHostProxyStatus( diag?.temperatureProxy?.hostProxySummary ? { - hostSocketPresent: Boolean(diag.temperatureProxy?.socketFound), - containerSocketPresent: - diag.temperatureProxy?.hostProxySummary?.containerSocketPresent ?? undefined, - summary: diag.temperatureProxy?.hostProxySummary ?? undefined, - } + hostSocketPresent: Boolean(diag.temperatureProxy?.socketFound), + containerSocketPresent: + diag.temperatureProxy?.hostProxySummary?.containerSocketPresent ?? undefined, + summary: diag.temperatureProxy?.hostProxySummary ?? undefined, + } : null, ); } catch (err) { @@ -1090,10 +1125,10 @@ const Settings: Component = (props) => { const nodes = Array.isArray(data.nodes) ? (data.nodes as Array>).map((node) => ({ - name: typeof node.name === 'string' ? node.name : 'unknown', - sshReady: Boolean(node.ssh_ready), - error: typeof node.error === 'string' ? node.error : undefined, - })) + name: typeof node.name === 'string' ? node.name : 'unknown', + sshReady: Boolean(node.ssh_ready), + error: typeof node.error === 'string' ? node.error : undefined, + })) : []; setProxyRegisterSummary(nodes); @@ -1191,83 +1226,83 @@ const Settings: Component = (props) => { disabled?: boolean; }[]; }[] = [ - { - id: 'platforms', - label: 'Platforms', - items: [ - { id: 'proxmox', label: 'Proxmox', icon: ProxmoxIcon }, - { id: 'docker', label: 'Docker', icon: Boxes }, - { id: 'hosts', label: 'Hosts', icon: Monitor, iconProps: { strokeWidth: 2 } }, - ], - }, - { - id: 'operations', - label: 'Operations', - items: [ - { id: 'api', label: 'API Tokens', icon: BadgeCheck }, - { - id: 'diagnostics', - label: 'Diagnostics', - icon: Activity, - iconProps: { strokeWidth: 2 }, - }, - ], - }, - { - id: 'system', - label: 'System', - items: [ - { - id: 'system-general', - label: 'General', - icon: Sliders, - iconProps: { strokeWidth: 2 }, - }, - { - id: 'system-network', - label: 'Network', - icon: Network, - iconProps: { strokeWidth: 2 }, - }, - { - id: 'system-updates', - label: 'Updates', - icon: RefreshCw, - iconProps: { strokeWidth: 2 }, - }, - { - id: 'system-backups', - label: 'Backups', - icon: Clock, - iconProps: { strokeWidth: 2 }, - }, - ], - }, - { - id: 'security', - label: 'Security', - items: [ - { - id: 'security-overview', - label: 'Overview', - icon: Shield, - iconProps: { strokeWidth: 2 }, - }, - { - id: 'security-auth', - label: 'Authentication', - icon: Lock, - iconProps: { strokeWidth: 2 }, - }, - { - id: 'security-sso', - label: 'Single Sign-On', - icon: Key, - iconProps: { strokeWidth: 2 }, - }, - ], - }, - ]; + { + id: 'platforms', + label: 'Platforms', + items: [ + { id: 'proxmox', label: 'Proxmox', icon: ProxmoxIcon }, + { id: 'docker', label: 'Docker', icon: Boxes }, + { id: 'hosts', label: 'Hosts', icon: Monitor, iconProps: { strokeWidth: 2 } }, + ], + }, + { + id: 'operations', + label: 'Operations', + items: [ + { id: 'api', label: 'API Tokens', icon: BadgeCheck }, + { + id: 'diagnostics', + label: 'Diagnostics', + icon: Activity, + iconProps: { strokeWidth: 2 }, + }, + ], + }, + { + id: 'system', + label: 'System', + items: [ + { + id: 'system-general', + label: 'General', + icon: Sliders, + iconProps: { strokeWidth: 2 }, + }, + { + id: 'system-network', + label: 'Network', + icon: Network, + iconProps: { strokeWidth: 2 }, + }, + { + id: 'system-updates', + label: 'Updates', + icon: RefreshCw, + iconProps: { strokeWidth: 2 }, + }, + { + id: 'system-backups', + label: 'Backups', + icon: Clock, + iconProps: { strokeWidth: 2 }, + }, + ], + }, + { + id: 'security', + label: 'Security', + items: [ + { + id: 'security-overview', + label: 'Overview', + icon: Shield, + iconProps: { strokeWidth: 2 }, + }, + { + id: 'security-auth', + label: 'Authentication', + icon: Lock, + iconProps: { strokeWidth: 2 }, + }, + { + id: 'security-sso', + label: 'Single Sign-On', + icon: Key, + iconProps: { strokeWidth: 2 }, + }, + ], + }, + ]; const flatTabs = tabGroups.flatMap((group) => group.items); @@ -1960,6 +1995,9 @@ const Settings: Component = (props) => { ? systemSettings.temperatureMonitoringEnabled : true, ); + // Load hideLocalLogin setting + setHideLocalLogin(systemSettings.hideLocalLogin ?? false); + // Backup polling controls if (typeof systemSettings.backupPollingEnabled === 'boolean') { setBackupPollingEnabled(systemSettings.backupPollingEnabled); @@ -2558,15 +2596,13 @@ const Settings: Component = (props) => { type="button" aria-current={isActive() ? 'page' : undefined} disabled={item.disabled} - class={`flex w-full items-center ${sidebarCollapsed() ? 'justify-center' : 'gap-2.5'} rounded-md ${ - sidebarCollapsed() ? 'px-2 py-2.5' : 'px-3 py-2' - } text-sm font-medium transition-colors ${ - item.disabled + class={`flex w-full items-center ${sidebarCollapsed() ? 'justify-center' : 'gap-2.5'} rounded-md ${sidebarCollapsed() ? 'px-2 py-2.5' : 'px-3 py-2' + } text-sm font-medium transition-colors ${item.disabled ? 'opacity-60 cursor-not-allowed text-gray-400 dark:text-gray-600' : isActive() ? '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' - }`} + }`} onClick={() => { if (item.disabled) return; setActiveTab(item.id); @@ -2604,13 +2640,12 @@ const Settings: Component = (props) => {