diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 5e8b35ad1..00f6b678a 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,35 +1,44 @@ { "version": 1, - "base_sha": "4fd68c72b26f033f5b1b0fcb7d7b2b0d67e85ec8", - "verified_at": "2026-09-06T14:12:06.315693Z", + "base_sha": "f4dc1e69a0e214dd0085c7696f42444b8fd91fa3", + "verified_at": "2026-09-06T16:58:13Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.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/deliveryDiagnosisPresentation.ts": "bc3eadf8f790517b377430a42eb67e8fdfcadb53bb13b87cd971d6a9ab91d607" + "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": [ - "Scripted ready and cooldown diagnoses with lastNotified render Dispatch requested; cooldown says next eligible. Missing timestamp remains Notification pending. No Notified label." + "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": [ - "Loaded real Overview in Chromium via scripts/check-alert-dispatch-copy.mjs; asserted status text ranges fit each viewport and no page errors. Inspected desktop and phone screenshots. No delivery actions invoked; no backend receipt claimed. Screenshots retained in lane outcome evidence." + "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; /**