diff --git a/frontend/src/__tests__/auth-dialog-a11y.test.ts b/frontend/src/__tests__/auth-dialog-a11y.test.ts index 155e88a5a..3282493da 100644 --- a/frontend/src/__tests__/auth-dialog-a11y.test.ts +++ b/frontend/src/__tests__/auth-dialog-a11y.test.ts @@ -63,6 +63,17 @@ describe('auth gate dialogs (#731)', () => { expect(document.getElementById('login-error')?.getAttribute('role')).toBe('alert'); }); + test('signed-out gates render as a standalone screen with a decorative brand', async () => { + await showLoginModal(); + const modal = document.getElementById('login-modal')!; + expect(modal.classList.contains('auth-screen')).toBe(true); + const brands = modal.querySelectorAll('.modal-overlay > .auth-brand'); + expect(brands.length).toBe(1); + expect(brands[0]!.getAttribute('aria-hidden')).toBe('true'); + // Outside the dialog, so it does not change the dialog's name or content. + expect(modal.querySelector('[role="dialog"] .auth-brand')).toBeNull(); + }); + test('Tab wraps inside the login modal and Escape does not dismiss it', async () => { await showLoginModal(); const modal = document.getElementById('login-modal')!; diff --git a/frontend/src/app.ts b/frontend/src/app.ts index 351ced585..efbb5260c 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -71,6 +71,16 @@ function describePublicInfoFailure(err: unknown): string { * Initialize app */ export async function init(): Promise { + try { + await initApp(); + } finally { + // index.html hides the shell until auth state is known; clear it on every + // exit path (signed in, signed out, init error) so it is never left blank. + document.body.classList.remove('auth-pending'); + } +} + +async function initApp(): Promise { api.initAuth(); // Check if this is a password reset link. Must be scoped to the diff --git a/frontend/src/authDialog.ts b/frontend/src/authDialog.ts index e49816bf7..dd9763a06 100644 --- a/frontend/src/authDialog.ts +++ b/frontend/src/authDialog.ts @@ -15,7 +15,53 @@ import { openModal } from './modal'; +const SVG_NS = 'http://www.w3.org/2000/svg'; + +/** + * Decorative CUDly logo + name shown above the card on the signed-out + * screens. aria-hidden: the dialog's heading already names the screen. + */ +function buildAuthBrand(): HTMLElement { + const brand = document.createElement('div'); + brand.className = 'auth-brand'; + brand.setAttribute('aria-hidden', 'true'); + + const mark = document.createElementNS(SVG_NS, 'svg'); + mark.setAttribute('class', 'auth-brand-mark'); + mark.setAttribute('viewBox', '0 0 24 24'); + mark.setAttribute('width', '28'); + mark.setAttribute('height', '28'); + mark.setAttribute('fill', 'none'); + mark.setAttribute('stroke', 'currentColor'); + mark.setAttribute('stroke-width', '2'); + mark.setAttribute('stroke-linecap', 'round'); + mark.setAttribute('stroke-linejoin', 'round'); + const path = document.createElementNS(SVG_NS, 'path'); + path.setAttribute('d', 'M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z'); + mark.appendChild(path); + + const name = document.createElement('span'); + name.className = 'auth-brand-name'; + name.textContent = 'CUDly'; + + const tagline = document.createElement('span'); + tagline.className = 'auth-brand-tagline'; + tagline.textContent = 'Cloud commitment optimizer'; + + brand.append(mark, name, tagline); + return brand; +} + export function openAuthDialog(modal: HTMLElement, initialFocus?: string): void { + // Signed-out screens render as a standalone page: shell.css hides the app + // chrome while an .auth-screen is in the document and centres the card on + // a plain branded background, with the logo above it. + modal.classList.add('auth-screen'); + const overlay = modal.querySelector('.modal-overlay'); + if (overlay && !overlay.querySelector(':scope > .auth-brand')) { + overlay.prepend(buildAuthBrand()); + } + // The role goes on the visible card, not the zero-size wrapper or the // full-screen overlay; the focus trap goes on the wrapper, which survives // the innerHTML re-renders of the reset flow. diff --git a/frontend/src/index.html b/frontend/src/index.html index 7929a10b6..039c85e48 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -20,7 +20,7 @@ CUDly - Cloud Commitment Optimizer - + Skip to main content
diff --git a/frontend/src/styles/shell.css b/frontend/src/styles/shell.css index 8dacb84ec..5b86fa02b 100644 --- a/frontend/src/styles/shell.css +++ b/frontend/src/styles/shell.css @@ -492,6 +492,195 @@ border-color: var(--cudly-primary); } +/* --------------------------------------------------------------------------- + * Signed-out screens (login, MFA code, forgot / reset password, first-admin + * setup, API-unreachable notice): authDialog.ts marks the gate .auth-screen. + * While one is up the app chrome is not rendered at all, and the gate is a + * centred card on a plain branded background with the logo above it. + * ------------------------------------------------------------------------- */ +body:has(> .auth-screen) #app { + display: none; +} + +/* index.html ships and init() removes it once the + * auth state is known, so a signed-out visitor never sees a frame of the + * shell before .auth-screen exists. */ +body.auth-pending #app { + display: none; +} + +.auth-screen .modal-overlay { + flex-direction: column; + align-items: center; + justify-content: safe center; + gap: var(--cudly-sp-5); + padding: var(--cudly-sp-7) var(--cudly-sp-4); + overflow-y: auto; + background: + radial-gradient(60rem 30rem at 50% -10%, var(--cudly-info-bg), transparent 70%), + var(--cudly-bg); +} + +.auth-brand { + display: grid; + grid-template-columns: auto auto; + align-items: center; + justify-items: start; + column-gap: var(--cudly-sp-2); + flex-shrink: 0; +} + +.auth-brand-mark { + grid-row: span 2; + color: var(--cudly-primary); +} + +.auth-brand-name { + font-size: var(--cudly-fs-lg); + font-weight: var(--cudly-fw-semibold); + line-height: var(--cudly-lh-tight); + color: var(--cudly-text); +} + +.auth-brand-tagline { + font-size: var(--cudly-fs-xs); + color: var(--cudly-text-muted); +} + +.auth-screen .modal-content { + flex-shrink: 0; + width: 400px; + max-width: 100%; + max-height: none; + overflow: visible; + padding: var(--cudly-sp-6); + border: 1px solid var(--cudly-border); + border-radius: var(--cudly-r-lg); + box-shadow: var(--cudly-shadow-lg); +} + +.auth-screen .modal-content h2 { + margin-bottom: var(--cudly-sp-5); + text-align: center; + font-size: var(--cudly-fs-xl); + font-weight: var(--cudly-fw-semibold); +} + +.auth-screen .modal-content h3 { + margin-bottom: var(--cudly-sp-1); + font-size: var(--cudly-fs-md); + font-weight: var(--cudly-fw-semibold); +} + +.auth-screen .modal-content p { + margin-bottom: var(--cudly-sp-4); + color: var(--cudly-text-muted); + font-size: var(--cudly-fs-sm); + line-height: var(--cudly-lh-normal); +} + +.auth-screen .modal-content label { + display: block; + margin-bottom: var(--cudly-sp-4); + font-size: var(--cudly-fs-sm); + font-weight: var(--cudly-fw-medium); + color: var(--cudly-text); +} + +.auth-screen .modal-content :is(input[type="email"], input[type="password"], input[type="text"]) { + display: block; + width: 100%; + height: 2.5rem; + margin-top: var(--cudly-sp-1); + padding: 0 var(--cudly-sp-3); + border: 1px solid var(--cudly-border-strong); + border-radius: var(--cudly-r-md); + background: var(--cudly-surface); + color: var(--cudly-text); + font-size: var(--cudly-fs-sm); +} + +.auth-screen .modal-content .password-input-wrapper input { + padding-right: 2.75rem; +} + +.auth-screen .modal-content .toggle-password { + top: calc(50% + var(--cudly-sp-1) / 2); +} + +.auth-screen .modal-content :is(input[type="email"], input[type="password"], input[type="text"]):focus-visible { + outline: none; + border-color: var(--cudly-primary); + box-shadow: 0 0 0 3px var(--cudly-info-bg); +} + +/* Full-width primary action. */ +.auth-screen .modal-content button.primary { + display: block; + width: 100%; + height: 2.5rem; + margin-top: var(--cudly-sp-2); + border-radius: var(--cudly-r-md); + font-weight: var(--cudly-fw-semibold); +} + +.auth-screen .modal-content a { + color: var(--cudly-primary); + font-weight: var(--cudly-fw-medium); + text-decoration: none; +} + +.auth-screen .modal-content a:hover { + text-decoration: underline; +} + +/* "Forgot password?" sits under the password field, right-aligned. */ +.auth-screen .modal-content p:has(> #forgot-password-link) { + margin-top: calc(-1 * var(--cudly-sp-2)); + text-align: right; +} + +/* Secondary way out ("Back to login", "Already have an account?") under + * the primary button. */ +.auth-screen .modal-content :is(button, form) + p:has(> a:only-child) { + margin: var(--cudly-sp-4) 0 0; + text-align: center; +} + +.auth-screen .password-requirements { + margin: 0 0 var(--cudly-sp-4); + border-color: var(--cudly-border); + border-radius: var(--cudly-r-md); +} + +.auth-screen .error-message, +.auth-screen .success-message { + border-radius: var(--cudly-r-md); + font-size: var(--cudly-fs-sm); +} + +.auth-screen .modal-content code { + overflow-wrap: anywhere; +} + +@media (max-width: 480px) { + .auth-screen .modal-overlay { + padding: var(--cudly-sp-6) var(--cudly-sp-3); + } + + .auth-screen .modal-content { + width: 100%; + padding: var(--cudly-sp-5); + border-radius: var(--cudly-r-lg); + } +} + +/* Keep the alert-to-button gap (base.css) over the button's own margin. */ +.auth-screen .modal-content .error-message:not(.hidden) ~ button.primary, +.auth-screen .modal-content .success-message:not(.hidden) + button.primary { + margin-top: var(--cudly-sp-4); +} + /* Mid-width desktops (tablet landscape, small laptops): the top bar drops * the email text (the avatar stays, and the button keeps the address as its * accessible name and tooltip) and the brand stops reserving the sidebar diff --git a/frontend/tests-e2e/auth-pending-shell.spec.ts b/frontend/tests-e2e/auth-pending-shell.spec.ts new file mode 100644 index 000000000..ddbcf350c --- /dev/null +++ b/frontend/tests-e2e/auth-pending-shell.spec.ts @@ -0,0 +1,50 @@ +/** + * Signed-out screens are standalone cards, so the app shell must never paint + * while signed out. index.html marks and CSS hides + * #app until init() resolves auth state; before that, the shell showed for a + * frame before .auth-screen existed. + */ +import { test, expect } from '@playwright/test'; +import { mockApi, seedAuth } from './fixtures/recs'; + +test('signed out: the shell is hidden before the auth check resolves', async ({ page }) => { + await mockApi(page); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + await page.route('**/api/info', async (route) => { + await gate; + await route.fulfill({ json: { admin_exists: true } }); + }); + + await page.goto('/', { waitUntil: 'domcontentloaded' }); + // /api/info is still pending: no auth screen yet, and no shell either. + await expect(page.locator('.auth-screen')).toHaveCount(0); + await expect(page.locator('#app')).toBeHidden(); + await expect(page.locator('.app-topbar')).toBeHidden(); + + release(); + await expect(page.locator('.auth-screen .modal-content')).toBeVisible(); + await expect(page.locator('#app')).toBeHidden(); + await expect(page.locator('body.auth-pending')).toHaveCount(0); +}); + +test('signed in: the shell shows once auth resolves and the pending marker is gone', async ({ page }) => { + await seedAuth(page); + await mockApi(page); + await page.route('**/api/auth/me', (r) => + r.fulfill({ json: { id: 'user-smoke', email: 'smoke@example.com', groups: [], mfa_enabled: false } })); + await page.goto('/home'); + await expect(page.locator('.app-topbar')).toBeVisible(); + await expect(page.locator('#user-email-display')).toBeVisible(); + await expect(page.locator('body.auth-pending')).toHaveCount(0); +}); + +test('init error: the marker is cleared so the error card is not blank', async ({ page }) => { + await seedAuth(page); + await mockApi(page); + await page.route('**/api/auth/me', (r) => + r.fulfill({ status: 503, json: { error: 'service unavailable' } })); + await page.goto('/plans'); + await expect(page.locator('#init-error')).toBeVisible(); + await expect(page.locator('body.auth-pending')).toHaveCount(0); +});