diff --git a/frontend/src/lib/auth/loginError.test.ts b/frontend/src/lib/auth/loginError.test.ts new file mode 100644 index 00000000..762a158d --- /dev/null +++ b/frontend/src/lib/auth/loginError.test.ts @@ -0,0 +1,81 @@ +/** + * Coverage for the `?login_error=` → user-facing copy mapping. The + * i18n `t()` module is stubbed to echo the fallback string verbatim so + * the assertions read naturally without pulling in the full i18n bag. + */ +import { describe, expect, it, vi } from 'vitest'; + +// Stub $lib/i18n before importing the module under test — the `t` +// function in the real module needs the i18n bag to be initialized; +// here we just want to see which fallback string each case returns. +vi.mock('$lib/i18n/index.svelte', () => ({ + t: (_key: string, ...rest: unknown[]) => { + // Real `t` signatures: t(key, fallback) or t(key, params, fallback). + // Whichever shape is used, the fallback is the last string arg. + for (let i = rest.length - 1; i >= 0; i--) { + if (typeof rest[i] === 'string') return rest[i] as string; + } + return _key; + } +})); + +import { loginErrorMessage } from './loginError'; + +describe('loginErrorMessage', () => { + // ── OIDC-callback rejection reasons the backend emits post-refactor + // (project_oidc_callback_error_specific_reasons memory). These + // were the whole point of the refactor: distinct, targeted copy + // rather than the misleading "sign-in link expired" bucket. + it('email_not_verified_at_idp → verify-at-IdP prompt', () => { + const msg = loginErrorMessage('email_not_verified_at_idp'); + expect(msg).toMatch(/verified/i); + expect(msg).toMatch(/identity provider/i); + }); + + it('email_verification_required → server policy + admin hint', () => { + const msg = loginErrorMessage('email_verification_required'); + expect(msg).toMatch(/verified/i); + expect(msg).toMatch(/administrator/i); + }); + + // ── Auto-link refusals (docs/plan/oidc-account-linking.md § Auto-link) + it('auto_link_disabled → server-policy explanation', () => { + expect(loginErrorMessage('auto_link_disabled')).toMatch(/auto-link/i); + }); + + it('auto_link_email_not_verified → verify-then-retry', () => { + const msg = loginErrorMessage('auto_link_email_not_verified'); + expect(msg).toMatch(/verify|verified/i); + }); + + it('already_linked_elsewhere → admin escalation', () => { + expect(loginErrorMessage('already_linked_elsewhere')).toMatch(/administrator/i); + }); + + it('email_ambiguous → admin escalation', () => { + expect(loginErrorMessage('email_ambiguous')).toMatch(/administrator/i); + }); + + // ── Generic callback failure buckets — kept for backward compat + + // for any AccessDenied path that didn't get its own reason yet. + it('callback_denied → link-expired copy', () => { + expect(loginErrorMessage('callback_denied')).toMatch(/expired|already used|try/i); + }); + + it('callback_failed → generic retry', () => { + expect(loginErrorMessage('callback_failed')).toMatch(/try again/i); + }); + + // ── Forward-compat: unknown keys never blank out. A new backend + // reason without an explicit case here still surfaces SOMETHING + // the user can act on (retry). + it('unknown key → generic non-empty fallback', () => { + const msg = loginErrorMessage('never_heard_of_this_reason'); + expect(msg).toBeTruthy(); + expect(msg.length).toBeGreaterThan(10); + }); + + it('empty key → generic non-empty fallback', () => { + expect(loginErrorMessage('')).toBeTruthy(); + }); +}); diff --git a/frontend/src/lib/auth/loginError.ts b/frontend/src/lib/auth/loginError.ts new file mode 100644 index 00000000..d758812d --- /dev/null +++ b/frontend/src/lib/auth/loginError.ts @@ -0,0 +1,64 @@ +/** + * Stable `?login_error=` translation table. + * + * The OIDC callback handler (`src/interfaces/api/handlers/auth_handler.rs`) + * emits one of these snake_case keys on any rejection redirect. This + * module maps each to localized copy the login page renders on mount. + * + * Adding a new backend reason: pick a matching snake_case key in the + * handler, add a case here + an `auth.login_error_` i18n entry. + * Unknown keys silently fall back to the generic copy — a new backend + * reason without a FE entry surfaces something the user can act on, + * not a blank string. + * + * Extracted from `routes/login/+page.svelte` so a Vitest unit can + * exercise the mapping in isolation without a browser. + */ +import { t } from '$lib/i18n/index.svelte'; + +export function loginErrorMessage(key: string): string { + switch (key) { + case 'auto_link_disabled': + return t( + 'auth.login_error_auto_link_disabled', + 'This server does not auto-link SSO accounts. Sign in with your existing credentials, then connect SSO from your profile.' + ); + case 'auto_link_email_not_verified': + return t( + 'auth.login_error_auto_link_email_not_verified', + 'Your SSO provider did not confirm your email address. Verify your email at your identity provider, then try again.' + ); + case 'already_linked_elsewhere': + return t( + 'auth.login_error_already_linked_elsewhere', + 'A local account with this email already exists and is linked to a different SSO identity. Contact your administrator.' + ); + case 'email_ambiguous': + return t( + 'auth.login_error_email_ambiguous', + 'Multiple local accounts match this email address. Contact your administrator to resolve.' + ); + case 'callback_denied': + return t( + 'auth.login_error_callback_denied', + 'Your sign-in link expired or was already used. Please try signing in again.' + ); + case 'callback_failed': + return t( + 'auth.login_error_callback_failed', + "SSO sign-in couldn't complete. Please try again." + ); + case 'email_not_verified_at_idp': + return t( + 'auth.login_error_email_not_verified_at_idp', + 'Your identity provider reports that your email address is not verified. Confirm your email at your identity provider, then try signing in again.' + ); + case 'email_verification_required': + return t( + 'auth.login_error_email_verification_required', + 'This server requires a verified email. Your identity provider did not include an email-verification claim. Contact your administrator.' + ); + default: + return t('auth.login_error_generic', 'SSO sign-in was refused. Please try again.'); + } +} diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 3bbdb33f..08161465 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -21,6 +21,7 @@ type OidcProviders } from '$lib/api/endpoints/auth'; import { i18n, SUPPORTED_LOCALES, setLocale, t, type Locale } from '$lib/i18n/index.svelte'; + import { loginErrorMessage } from '$lib/auth/loginError'; import { session } from '$lib/stores/session.svelte'; import { hasSessionHint } from '$lib/api/csrf'; @@ -116,6 +117,16 @@ // config, or the local account is already linked to a different // identity). See docs/plan/oidc-account-linking.md § Auto-link. let loginErrorNotice = $state(null); + // True while we're mid-OIDC-callback and about to redirect into the + // app. Read synchronously at script-init from `?oidc_code=…` so the + // FIRST paint suppresses the form and shows a loader instead — + // without this the SPA briefly renders the empty username/password + // fields between the IdP redirect and the exchange-then-goto, + // making it look like the login screen "flashed." Cleared in + // onMount if the exchange fails so the normal form takes over. + let willRedirect = $state( + typeof window !== 'undefined' && page.url.searchParams.has('oidc_code') + ); // Refs used by the mode-driven auto-focus effect. Bound with // `bind:this` on the first input of each mode's form so the effect // can focus the "primary" field each time the mode changes without @@ -309,47 +320,8 @@ } } - // Reason keys mirror the OIDC callback's redirect arms in - // auth_handler.rs — snake_case, matching the URL param shape used - // by the sibling /profile?link_error= flow. Any unknown - // key falls back to the generic copy so a new backend reason never - // blanks out the notice. - function loginErrorMessage(key: string): string { - switch (key) { - case 'auto_link_disabled': - return t( - 'auth.login_error_auto_link_disabled', - 'This server does not auto-link SSO accounts. Sign in with your existing credentials, then connect SSO from your profile.' - ); - case 'auto_link_email_not_verified': - return t( - 'auth.login_error_auto_link_email_not_verified', - 'Your SSO provider did not confirm your email address. Verify your email at your identity provider, then try again.' - ); - case 'already_linked_elsewhere': - return t( - 'auth.login_error_already_linked_elsewhere', - 'A local account with this email already exists and is linked to a different SSO identity. Contact your administrator.' - ); - case 'email_ambiguous': - return t( - 'auth.login_error_email_ambiguous', - 'Multiple local accounts match this email address. Contact your administrator to resolve.' - ); - case 'callback_denied': - return t( - 'auth.login_error_callback_denied', - 'Your sign-in link expired or was already used. Please try signing in again.' - ); - case 'callback_failed': - return t( - 'auth.login_error_callback_failed', - "SSO sign-in couldn't complete. Please try again." - ); - default: - return t('auth.login_error_generic', 'SSO sign-in was refused. Please try again.'); - } - } + // `?login_error=` → localized copy lives in $lib/auth/loginError + // (extracted so a Vitest can exercise the mapping in isolation). onMount(async () => { // 0) Consume the one-shot `?source=session_expired` flag, if any. @@ -406,7 +378,10 @@ await goto(resolve(redirectTarget), { replaceState: true }); return; } - // Exchange failed — fall through to the normal login UI. + // Exchange failed — fall through to the normal login UI. Drop + // the loader guard so the form appears; if we leave it true + // the user stares at a spinner indefinitely. + willRedirect = false; } // 2) Existing-session probe: if already authenticated, skip the form. @@ -482,563 +457,588 @@ {t('app.title', 'OxiCloud')} -
-
-