diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 1fd431dcf..00f6b678a 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,35 +1,44 @@ { "version": 1, - "base_sha": "5000e0409b5795f732f32882d7c6929e7eff16b1", - "verified_at": "2026-09-06T16:16:58.486143Z", + "base_sha": "f4dc1e69a0e214dd0085c7696f42444b8fd91fa3", + "verified_at": "2026-09-06T16:58:13Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/alerts/useAlertOverviewState.ts" + "frontend-modern/src/components/Login.tsx", + "frontend-modern/src/useAppRuntimeState.ts", + "frontend-modern/src/utils/localStorage.ts" ], "content_sha256": { - "frontend-modern/src/features/alerts/useAlertOverviewState.ts": "64d0b891e7ad228e8590da859dc25e825b6164c8cf76a01983a219d6cd079b23" + "frontend-modern/src/components/Login.tsx": "621f0b97d775aacaa521a3c72ed02db3fde5ad3eda06d55950a32107723eb44c", + "frontend-modern/src/useAppRuntimeState.ts": "5a1d343d92c441e1302dab129be7256dd9a287c6a60fb0e17c6f9fa13fe20900", + "frontend-modern/src/utils/localStorage.ts": "182ed45685228781fd32725db50611b1115f9ee4fb1af23aeedcf41ca707bdbc" }, "routes": [ - "/qualification (isolated real OverviewTab, not installed /alerts)" + "/" ], "viewports": [ { - "width": 1440, - "height": 1000 - }, - { - "width": 900, - "height": 1000 + "width": 1280, + "height": 800 }, { "width": 390, - "height": 1000 + "height": 844 } ], "states": [ - "First diagnosis request pending; active set expanded; newer notifications-disabled response visible; older dispatch response completes without replacing current status." + "demo-mode login page with the \"Signing you in to the demo\u2026\" status while the automatic sign-in is in flight", + "application shell after the automatic demo sign-in (Demo instance banner, Proxmox overview)", + "login form with printed demo credentials after an explicit Logout, session marker \"suppressed\"", + "login form still shown after a reload following the Logout", + "login form with the \"Invalid username or password\" error after a rejected automatic sign-in (401 stub), Sign in button enabled" ], "interactions": [ - "Ran pulse-heavy-run -- node scripts/check-alert-diagnosis-ordering.mjs in Chromium. Clicked Add alert fixture control, completed older response after current warning rendered, asserted warning retained, dispatch absent, new alert present, status text fits viewport and no page errors. Inspected desktop and phone screenshots. Synthetic API only; no delivery action or recipient receipt." + "load / in a fresh browser context at 1280x800 and at 390x844; automatic POST /api/login with demo/demo, no typing", + "click the Logout control in the app header at both widths", + "reload the page after Logout", + "type demo/demo into the form and submit after Logout", + "open / in a second fresh context: automatic sign-in again", + "stub /api/login with 401 and load /: form fallback" ] } diff --git a/frontend-modern/src/components/Login.tsx b/frontend-modern/src/components/Login.tsx index 8e3b01786..d9981c83d 100644 --- a/frontend-modern/src/components/Login.tsx +++ b/frontend-modern/src/components/Login.tsx @@ -1,8 +1,17 @@ -import { Component, createSignal, Show, For, onMount, lazy, Suspense } from 'solid-js'; +import { + Component, + createEffect, + createSignal, + Show, + For, + onMount, + lazy, + Suspense, +} from 'solid-js'; import { logger } from '@/utils/logger'; import { PulseBrandMark } from '@/components/Brand/PulseBrandMark'; import { apiClient, apiFetchJSON } from '@/utils/apiClient'; -import { STORAGE_KEYS } from '@/utils/localStorage'; +import { SESSION_STORAGE_KEYS, STORAGE_KEYS } from '@/utils/localStorage'; import { TROUBLESHOOTING_DOC_URL } from '@/utils/docsLinks'; import Globe from 'lucide-solid/icons/globe'; import Key from 'lucide-solid/icons/key'; @@ -18,6 +27,10 @@ interface LoginProps { import type { SecurityStatus, SSOProviderInfo } from '@/types/config'; +// The public demo's credentials. They are shown on the login page, so there +// is nothing to protect by making the visitor type them. +const DEMO_CREDENTIALS = { username: 'demo', password: 'demo' } as const; + function getBrowserStorage(kind: 'localStorage' | 'sessionStorage'): Storage | undefined { if (typeof window === 'undefined') return undefined; try { @@ -68,8 +81,12 @@ export const Login: Component = (props) => { const [oidcLoading] = createSignal(false); const [oidcError, setOidcError] = createSignal(''); const [oidcMessage, setOidcMessage] = createSignal(''); + const [demoAutoLogin, setDemoAutoLogin] = createSignal(false); const ssoProviders = () => authStatus()?.ssoProviders || []; + const demoModeEnabled = () => + authStatus()?.presentationPolicy?.demoMode === true || + authStatus()?.sessionCapabilities?.demoMode === true; const resolveSSOError = (reason?: string | null) => { switch (reason) { @@ -214,6 +231,15 @@ export const Login: Component = (props) => { return; } + await submitCredentials(usernameValue, passwordValue, rememberLogin); + }; + + const submitCredentials = async ( + usernameValue: string, + passwordValue: string, + rememberLogin: boolean, + ) => { + setLoading(true); try { // Use the new login endpoint for better feedback const response = await apiClient.fetch('/api/login', { @@ -280,6 +306,26 @@ export const Login: Component = (props) => { } }; + // Demo mode: the public demo is read-only and its credentials are printed on + // this page anyway, so sign the visitor in instead of making them type + // demo/demo. Once per browser tab, and never straight after a sign-out. + createEffect(() => { + if (loadingAuth() || !demoModeEnabled() || showFirstRunSetup()) return; + const params = new URLSearchParams(window.location.search); + if (params.has('oidc') || params.has('saml')) return; + const storage = getBrowserStorage('sessionStorage'); + if (storage?.getItem(SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN)) return; + try { + storage?.setItem(SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN, 'attempted'); + } catch (_err) { + // If the marker cannot be stored the sign-in still runs once for this render. + } + setDemoAutoLogin(true); + void submitCredentials(DEMO_CREDENTIALS.username, DEMO_CREDENTIALS.password, false).finally( + () => setDemoAutoLogin(false), + ); + }); + // Debug logging logger.debug('[Login] Render', { loadingAuth: loadingAuth(), @@ -323,9 +369,8 @@ export const Login: Component = (props) => { oidcLoading, oidcError, oidcMessage, - demoModeEnabled: - authStatus()?.presentationPolicy?.demoMode === true || - authStatus()?.sessionCapabilities?.demoMode === true, + demoModeEnabled: demoModeEnabled(), + demoAutoLogin, showLocalLogin: shouldShowLocalLogin(), ssoProviders: ssoProviders(), }} @@ -369,6 +414,7 @@ const LoginForm: Component<{ oidcError: () => string; oidcMessage: () => string; demoModeEnabled: boolean; + demoAutoLogin: () => boolean; showLocalLogin: boolean; ssoProviders: SSOProviderInfo[]; }> = (props) => { @@ -386,6 +432,7 @@ const LoginForm: Component<{ oidcError, oidcMessage, demoModeEnabled, + demoAutoLogin, showLocalLogin, ssoProviders, } = props; @@ -414,16 +461,25 @@ const LoginForm: Component<{
Demo Mode
-
- Login with{' '} - - demo - {' '} - /{' '} - - demo - -
+ + Login with{' '} + + demo + {' '} + /{' '} + + demo + +
+ } + > +
+ Signing you in to the demo… +
+ diff --git a/frontend-modern/src/components/__tests__/Login.test.tsx b/frontend-modern/src/components/__tests__/Login.test.tsx index 0a7439005..8ec2e7d48 100644 --- a/frontend-modern/src/components/__tests__/Login.test.tsx +++ b/frontend-modern/src/components/__tests__/Login.test.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest'; import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; import { Login } from '@/components/Login'; import loginSource from '@/components/Login.tsx?raw'; -import { STORAGE_KEYS } from '@/utils/localStorage'; +import { SESSION_STORAGE_KEYS, STORAGE_KEYS } from '@/utils/localStorage'; // Mock fetch globally const mockFetch = vi.fn(); @@ -182,7 +182,10 @@ describe('Login', () => { expect(mockFetch).not.toHaveBeenCalledWith('/api/security/status'); }); - it('shows demo credentials when session capabilities mark the runtime as demo mode', async () => { + it('shows demo credentials when the visitor has signed out of the demo', async () => { + // A sign-out marks the tab so the page does not sign the visitor straight + // back in; the printed credentials are the way back. + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN, 'suppressed'); const mockOnLogin = vi.fn(); const securityStatus = { hasAuthentication: true, @@ -196,6 +199,78 @@ describe('Login', () => { expect(await screen.findByText('Demo Mode')).toBeInTheDocument(); expect(screen.getAllByText('demo')).toHaveLength(2); + expect(mockFetch).not.toHaveBeenCalledWith('/api/login', expect.anything()); + expect(mockOnLogin).not.toHaveBeenCalled(); + }); + + it('signs the visitor in with the demo credentials when the runtime is in demo mode', async () => { + const mockOnLogin = vi.fn(); + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + const securityStatus = { + hasAuthentication: true, + hideLocalLogin: false, + presentationPolicy: { demoMode: true }, + }; + + render(() => ( + + )); + + await waitFor(() => expect(mockOnLogin).toHaveBeenCalledOnce()); + const loginCall = mockFetch.mock.calls.find(([url]) => url === '/api/login'); + expect(loginCall).toBeDefined(); + expect(JSON.parse((loginCall?.[1] as RequestInit).body as string)).toEqual({ + username: 'demo', + password: 'demo', + rememberMe: false, + }); + expect(window.sessionStorage.getItem(SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN)).toBe('attempted'); + }); + + it('falls back to the form when the demo sign-in is rejected', async () => { + const mockOnLogin = vi.fn(); + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ success: false, message: 'Invalid username or password' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }), + ); + const securityStatus = { + hasAuthentication: true, + hideLocalLogin: false, + presentationPolicy: { demoMode: true }, + }; + + render(() => ( + + )); + + expect(await screen.findByText('Invalid username or password')).toBeInTheDocument(); + expect(screen.getAllByText('demo')).toHaveLength(2); + expect(screen.getByRole('button', { name: /sign in to pulse/i })).toBeEnabled(); + expect(mockOnLogin).not.toHaveBeenCalled(); + }); + + it('does not sign in to the demo twice in one browser tab', async () => { + window.sessionStorage.setItem(SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN, 'attempted'); + const mockOnLogin = vi.fn(); + const securityStatus = { + hasAuthentication: true, + hideLocalLogin: false, + presentationPolicy: { demoMode: true }, + }; + + render(() => ( + + )); + + expect(await screen.findByText('Demo Mode')).toBeInTheDocument(); + expect(mockFetch).not.toHaveBeenCalledWith('/api/login', expect.anything()); }); it('restores the remembered username without storing a password', async () => { diff --git a/frontend-modern/src/useAppRuntimeState.ts b/frontend-modern/src/useAppRuntimeState.ts index 2f2344433..8e8b8c7f8 100644 --- a/frontend-modern/src/useAppRuntimeState.ts +++ b/frontend-modern/src/useAppRuntimeState.ts @@ -14,7 +14,7 @@ import { PRIMARY_PLATFORM_NAV_IDS, type PlatformNavigationVisibility, } from '@/features/platformNavigation/platformNavigationModel'; -import { STORAGE_KEYS } from '@/utils/localStorage'; +import { SESSION_STORAGE_KEYS, STORAGE_KEYS } from '@/utils/localStorage'; import type { VersionInfo } from '@/api/updates'; import type { Organization } from '@/api/orgs'; import { OrgsAPI } from '@/api/orgs'; @@ -828,6 +828,11 @@ export const useAppRuntimeState = () => { ]; keysToRemove.forEach((key) => localStorage.removeItem(key)); sessionStorage.clear(); + try { + sessionStorage.setItem(SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN, 'suppressed'); + } catch (_err) { + // Storage may be unavailable; the demo login page then simply signs in again. + } localStorage.setItem('just_logged_out', 'true'); aiChatStore.setEnabled(false); diff --git a/frontend-modern/src/utils/localStorage.ts b/frontend-modern/src/utils/localStorage.ts index fe9161e82..2090b6ae4 100644 --- a/frontend-modern/src/utils/localStorage.ts +++ b/frontend-modern/src/utils/localStorage.ts @@ -142,6 +142,10 @@ export type LowPriorityNoticeOwner = 'github-star' | 'release-update'; export const SESSION_STORAGE_KEYS = { LOW_PRIORITY_NOTICE_OWNER: 'pulse-low-priority-notice-owner', + // Demo mode signs the visitor in once per browser tab. The value is + // 'attempted' after the login page has tried, or 'suppressed' after an + // explicit sign-out, so a visitor who signed out lands on the form. + DEMO_AUTO_LOGIN: 'pulse-demo-auto-login', } as const; /**