mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Sign demo-mode visitors in instead of showing them demo/demo
"Try the live demo" on pulserelay.pro landed on a login form with the demo credentials printed above it. The demo runtime is read-only and its credentials are public, so making a first-time visitor type them was pure friction at the moment of curiosity. When the security status reports demo mode, the login page now submits demo/demo itself, once per browser tab, showing "Signing you in to the demo…" in the demo banner while the request is in flight. The marker lives in sessionStorage under SESSION_STORAGE_KEYS.DEMO_AUTO_LOGIN. The logout handler sets the same marker to "suppressed" right after it clears session storage, so a visitor who signs out lands on the form (and stays there across a reload) rather than being signed straight back in; a new tab signs in again. SSO callbacks (?oidc / ?saml), first-run setup and a non-demo runtime never trigger it, and a rejected sign-in falls back to the normal form with the server's error. The credential request moves out of the submit handler into submitCredentials so the form and the automatic path share one code path and one set of error messages. Verified on a demo-mode backend (DEMO_MODE=true, mock data, demo/demo) behind Vite at 1280x800 and 390x844: automatic sign-in from a fresh context, Logout to the form with the marker suppressed, reload keeps the form, manual sign-in from the form, second fresh context signs in again, and a stubbed 401 shows the form with the error. Login and useAppRuntimeState test files pass (41 tests). Contract-Neutral: demo-mode login convenience on the existing /api/login flow; no public API, config, or contract surface changes
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<LoginProps> = (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<LoginProps> = (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<LoginProps> = (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<LoginProps> = (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<{
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold text-sm text-base-content">Demo Mode</div>
|
||||
<div class="text-sm text-muted">
|
||||
Login with{' '}
|
||||
<code class="bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-1.5 py-0.5 rounded font-mono text-xs">
|
||||
demo
|
||||
</code>{' '}
|
||||
/{' '}
|
||||
<code class="bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-1.5 py-0.5 rounded font-mono text-xs">
|
||||
demo
|
||||
</code>
|
||||
</div>
|
||||
<Show
|
||||
when={demoAutoLogin()}
|
||||
fallback={
|
||||
<div class="text-sm text-muted">
|
||||
Login with{' '}
|
||||
<code class="bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-1.5 py-0.5 rounded font-mono text-xs">
|
||||
demo
|
||||
</code>{' '}
|
||||
/{' '}
|
||||
<code class="bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-1.5 py-0.5 rounded font-mono text-xs">
|
||||
demo
|
||||
</code>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="text-sm text-muted" role="status">
|
||||
Signing you in to the demo…
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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(() => (
|
||||
<Login onLogin={mockOnLogin} hasAuth={true} securityStatus={securityStatus as any} />
|
||||
));
|
||||
|
||||
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(() => (
|
||||
<Login onLogin={mockOnLogin} hasAuth={true} securityStatus={securityStatus as any} />
|
||||
));
|
||||
|
||||
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(() => (
|
||||
<Login onLogin={mockOnLogin} hasAuth={true} securityStatus={securityStatus as any} />
|
||||
));
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user