mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Merge commit '63246694e6494411c568f3ce1b3007a7016afeab'
Change-source: pulse-maintainer # Conflicts: # frontend-modern/browser-verification.json
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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