Files
Oxicloud/frontend/src/routes/login/+page.svelte
T

1121 lines
39 KiB
Svelte
Raw Normal View History

2026-06-17 17:06:30 -06:00
<script lang="ts">
// Route-scoped styles: kept off the global critical path (Vite code-splits
// this into the /login route chunk, loaded only when this page renders).
import '$lib/styles/ported/auth.css';
2026-06-17 17:06:30 -06:00
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
2026-06-17 17:06:30 -06:00
import { page } from '$app/state';
import type { Pathname } from '$app/types';
2026-06-17 22:07:18 -06:00
import { onMount } from 'svelte';
import { ApiError } from '$lib/api/client';
2026-06-17 22:07:18 -06:00
import {
2026-08-09 05:00:30 +02:00
bindDpopIfPossible,
2026-06-17 22:07:18 -06:00
exchangeOidcCode,
fetchMe,
getAuthStatus,
getOidcProviders,
login,
register,
sendMagicLink,
setupAdmin,
type OidcProviders
} from '$lib/api/endpoints/auth';
import { i18n, SUPPORTED_LOCALES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
2026-08-14 13:13:43 +02:00
import { loginErrorMessage } from '$lib/auth/loginError';
2026-06-17 17:06:30 -06:00
import { session } from '$lib/stores/session.svelte';
2026-08-09 14:09:54 +02:00
import { hasSessionHint } from '$lib/api/csrf';
2026-06-17 17:06:30 -06:00
2026-06-17 22:07:18 -06:00
type Mode = 'login' | 'register' | 'setup';
let mode = $state<Mode>('login');
// First-run admin setup is only offered after the status probe confirms it.
let setupAvailable = $state(false);
// Suppress the auth UI until the onMount probes (session/oidc/status) settle,
// to avoid flashing the login form before a redirect or the setup wizard.
let booting = $state(true);
// Login
2026-06-17 17:06:30 -06:00
let username = $state('');
let password = $state('');
2026-06-17 22:07:18 -06:00
let showPassword = $state(false);
let capsOn = $state(false);
2026-06-17 17:06:30 -06:00
let error = $state('');
let busy = $state(false);
// Register. Since PR 18 both `username` and `password` are optional on
// the backend — email-only signup mints a welcome magic-link. Leaving
// the password blank is a deliberate first-class UX path here.
2026-06-17 22:07:18 -06:00
let regUsername = $state('');
let regEmail = $state('');
let regPassword = $state('');
let regConfirm = $state('');
let regError = $state('');
let regShowPassword = $state(false);
let regShowConfirm = $state(false);
let regCapsOn = $state(false);
// True when the user has chosen the passwordless-signup branch —
// hides the confirm-password field and switches the submit label.
const regEmailOnly = $derived(regPassword.length === 0);
2026-06-17 22:07:18 -06:00
// Admin setup (first run)
let setupEmail = $state('');
let setupPassword = $state('');
let setupConfirm = $state('');
let setupShowPassword = $state(false);
let setupShowConfirm = $state(false);
let setupCapsOn = $state(false);
let setupError = $state('');
let setupSuccess = $state('');
const setupMatchState = $derived(
setupConfirm.length === 0 ? '' : setupPassword === setupConfirm ? 'ok' : 'bad'
);
// Magic-link submit status (rendered inline after a link is sent).
2026-06-17 22:07:18 -06:00
let magicStatus = $state<{ text: string; ok: boolean } | null>(null);
// OIDC + auth-method flags exposed by /api/auth/oidc/providers.
2026-06-17 22:07:18 -06:00
let oidc = $state<OidcProviders>({ enabled: false });
// Default `true` here: on older backends the field is absent, and the
// legacy behaviour was always-on password login.
2026-06-17 22:07:18 -06:00
const passwordLoginEnabled = $derived(oidc.password_login_enabled !== false);
// Default `false`: only render magic-link UI when the backend
// affirmatively enables it (SMTP wired + allowlist + non-OIDC deployment).
const magicLinkLoginEnabled = $derived(oidc.magic_link_login_enabled === true);
// Single-form UX: the identifier + password fields double as the
// magic-link path. When the password is empty (and the server offers
// magic-link), submit sends a link to the identifier instead of
// attempting password login. This eliminates the duplicate
// identifier input the old two-form layout carried.
const submitAsMagicLink = $derived(
magicLinkLoginEnabled && (password.length === 0 || !passwordLoginEnabled)
);
// The login failure remap for "email not verified". The server
// auto-sends a verification magic-link on this branch (piggybacked
// on the successful password proof — see login handler), so the
// resend "affordance" is simply resubmitting the form. Kept as a
// flag to let the UI render a specific hint.
let emailNotVerified = $state<{ email: string } | null>(null);
// One-shot "your session expired" banner. Triggered by the fetch
// interceptor via `?source=session_expired`. Set to true only if
// the query param is present on mount; the URL is stripped
// immediately after so revisits / manual logouts don't re-show
// the stale message.
let sessionExpiredNotice = $state(false);
// One-shot "logged out" success banner, distinct from the
// session-expired one above. Triggered by AppShell::onLogout via
// `?source=logged_out`. Consumed on mount (URL stripped) so the
// notice never re-appears on reload.
let loggedOutNotice = $state(false);
// Also gates the existing-session probe below — after an explicit
// logout we know the session is dead; probing would 401 → refresh
// → 401 and clobber this landing with `?source=session_expired`
// via the interceptor.
let skipExistingSessionProbe = $state(false);
2026-08-08 20:31:19 +02:00
// One-shot notice populated from ?login_error=<key> on mount.
// Set by the OIDC callback's AutoLinkRefused redirect when the
// IdP-returned email matches an existing local account but the
// auto-link decision tree refused (verified=false, disabled by
// config, or the local account is already linked to a different
// identity). See docs/plan/oidc-account-linking.md § Auto-link.
let loginErrorNotice = $state<string | null>(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
// walking the DOM.
let loginIdentifierInput = $state<HTMLInputElement | null>(null);
let registerEmailInput = $state<HTMLInputElement | null>(null);
let setupEmailInput = $state<HTMLInputElement | null>(null);
// "Account created, follow the email link" banner. Set by the
// register submit handler right before switching mode='login',
// so the message stays on screen for the whole time the user is
// looking at the login form (instead of vanishing on the register
// form under a hard-to-read timeout). Cleared on the next
// successful login OR when the user dismisses it.
let postRegisterNotice = $state<string | null>(null);
2026-06-17 22:07:18 -06:00
// The redirect target is an in-SPA destination (e.g. /files or a deep link a
// guard bounced us from). It's user-supplied via the query string so its exact
// value isn't a known route literal — cast to Pathname for resolve().
const redirectTarget = $derived((page.url.searchParams.get('redirect') || '/files') as Pathname);
2026-06-17 22:07:18 -06:00
const matchState = $derived(
regConfirm.length === 0 ? '' : regPassword === regConfirm ? 'ok' : 'bad'
);
2026-06-17 17:06:30 -06:00
function csrfCookiePresent(): boolean {
return document.cookie.split('; ').some((c) => c.startsWith('oxicloud_csrf='));
}
2026-06-17 22:07:18 -06:00
function onPwKey(e: KeyboardEvent) {
capsOn = e.getModifierState?.('CapsLock') ?? false;
}
function onRegPwKey(e: KeyboardEvent) {
regCapsOn = e.getModifierState?.('CapsLock') ?? false;
}
function onSetupPwKey(e: KeyboardEvent) {
setupCapsOn = e.getModifierState?.('CapsLock') ?? false;
}
// Unified login submit. Two modes dispatched from ONE form:
// * password filled → POST /api/auth/login
// * password empty → POST /api/auth/magic-link/send (backend
// accepts either a username or an email as identifier)
// The `submitAsMagicLink` derived tracks which mode is active;
// button label + hint text render off it.
2026-06-17 22:07:18 -06:00
async function onLogin(e: SubmitEvent) {
2026-06-17 17:06:30 -06:00
e.preventDefault();
error = '';
emailNotVerified = null;
magicStatus = null;
if (submitAsMagicLink) {
await submitMagicLink();
return;
}
2026-06-17 17:06:30 -06:00
busy = true;
try {
const data = await login(username, password);
if (!csrfCookiePresent()) {
error = t(
'auth.cookie_rejected',
'Login succeeded but the browser rejected the session cookie. If you are on HTTP, set OXICLOUD_COOKIE_SECURE=false or use HTTPS.'
);
return;
}
session.setUser(data.user);
postRegisterNotice = null;
// When the backend flags `force_password_change` the user's
// current credential is an admin-set temporary password;
// route them to the profile page (`?forcePasswordChange=1`
// lights up an in-page banner) instead of the requested
// destination. The redirect target is preserved as `next`
// so the profile flow can bounce back after they pick a
// real password.
if (data.force_password_change) {
const next = encodeURIComponent(redirectTarget);
await goto(resolve(`/profile?forcePasswordChange=1&next=${next}`), {
replaceState: true
});
} else {
await goto(resolve(redirectTarget), { replaceState: true });
}
2026-06-17 17:06:30 -06:00
} catch (err) {
if (err instanceof ApiError && err.errorType === 'EmailNotVerified') {
// Server auto-sent a verification magic-link on the
// piggyback-of-successful-password path (see the login
// handler). Just tell the user; resubmitting the form
// re-triggers the same auto-send.
emailNotVerified = { email: username };
error = t(
'auth.email_not_verified',
'Your email is not verified. We sent a verification link to your inbox — click it, then sign in again. If it did not arrive, submit the form again.'
);
} else if (err instanceof ApiError && err.errorType === 'PasswordLoginDisabled') {
error = t(
'auth.password_login_disabled',
'Password login is disabled on this server. Leave the password blank to receive a sign-in link, or use SSO.'
);
} else {
error = err instanceof Error ? err.message : t('auth.login_error', 'Error logging in');
}
} finally {
busy = false;
}
}
// Password-empty branch of the unified submit. Uses the same
// `username` identifier the password form does — the backend
// dispatches on `@` (username vs email). Anti-enum uniform 200.
async function submitMagicLink() {
if (!username) return;
busy = true;
try {
const result = await sendMagicLink(username);
magicStatus =
result === 'sent'
? {
text: t(
'auth.magic_sent',
'If an account exists, a sign-in link has been sent. Check your inbox.'
),
ok: true
}
: {
text: t(
'auth.magic_unavailable',
'Sign-in by email is not available on this server.'
),
ok: false
};
} catch {
magicStatus = { text: t('auth.magic_error', 'Something went wrong. Try again.'), ok: false };
2026-06-17 17:06:30 -06:00
} finally {
busy = false;
}
}
2026-06-17 22:07:18 -06:00
async function onRegister(e: SubmitEvent) {
e.preventDefault();
regError = '';
if (regPassword !== regConfirm) {
regError = t('auth.passwords_mismatch', 'Passwords do not match');
return;
}
busy = true;
try {
// Username is optional since PR 18 — pass undefined when the
// field is left blank so the backend keeps `username = None`
// (the user can claim a handle later via profile settings).
await register(regEmail, regPassword, regUsername.trim() || undefined);
2026-06-17 22:07:18 -06:00
regUsername = regEmail = regPassword = regConfirm = '';
// Move the success notice to the LOGIN screen so it's actually
// readable — the register form is about to be replaced, so a
// message shown here would flash and disappear.
postRegisterNotice = t(
'auth.account_success',
'If the address is available, a confirmation email is on its way. Follow the link to finish.'
);
mode = 'login';
2026-06-17 22:07:18 -06:00
} catch (err) {
regError =
err instanceof Error ? err.message : t('auth.register_error', 'Registration failed');
} finally {
busy = false;
}
}
async function onSetup(e: SubmitEvent) {
e.preventDefault();
setupError = '';
setupSuccess = '';
if (setupPassword !== setupConfirm) {
setupError = t('auth.passwords_mismatch', 'Passwords do not match');
return;
}
busy = true;
try {
await setupAdmin(setupEmail, setupPassword);
setupEmail = setupPassword = setupConfirm = '';
// Admin now exists — fold the setup affordance away and return to login.
setupAvailable = false;
setupSuccess = t('auth.admin_success', 'Administrator created. You can now sign in.');
2026-06-17 22:07:18 -06:00
setTimeout(() => {
mode = 'login';
setupSuccess = '';
}, 2000);
} catch (err) {
setupError =
err instanceof Error ? err.message : t('auth.admin_create_error', 'Setup failed');
} finally {
busy = false;
}
}
2026-08-14 13:13:43 +02:00
// `?login_error=<key>` → localized copy lives in $lib/auth/loginError
// (extracted so a Vitest can exercise the mapping in isolation).
2026-08-08 20:31:19 +02:00
onMount(async () => {
// 0) Consume the one-shot `?source=session_expired` flag, if any.
// Strip it from the URL so the banner never re-appears on
// reloads / manual logout redirects. Uses history.replaceState
// (no navigation, no scroll jump).
const sourceParam = page.url.searchParams.get('source');
if (sourceParam === 'session_expired' || sourceParam === 'logged_out') {
if (sourceParam === 'session_expired') sessionExpiredNotice = true;
else loggedOutNotice = true;
// Either flag means we KNOW there's no live session — skip
// the existing-session probe further down.
skipExistingSessionProbe = true;
const stripped = new URL(page.url);
stripped.searchParams.delete('source');
window.history.replaceState(
window.history.state,
'',
stripped.pathname + stripped.search + stripped.hash
);
2026-06-17 22:07:18 -06:00
}
2026-08-08 20:31:19 +02:00
// One-shot auto-link refusal notice. Reason key is a stable
// snake_case identifier the OIDC callback emitted; map each to
// localized copy and strip the param so a reload doesn't
// re-surface the same notice.
const loginErrorKey = page.url.searchParams.get('login_error');
if (loginErrorKey) {
loginErrorNotice = loginErrorMessage(loginErrorKey);
const stripped = new URL(page.url);
stripped.searchParams.delete('login_error');
window.history.replaceState(
window.history.state,
'',
stripped.pathname + stripped.search + stripped.hash
);
}
2026-06-17 22:07:18 -06:00
// 1) OIDC code-exchange fallback: the IdP round-trip may land back here
// with ?oidc_code=. Exchange it for a session and redirect into the app.
const oidcCode = page.url.searchParams.get('oidc_code');
if (oidcCode) {
const user = await exchangeOidcCode(oidcCode);
if (user) {
session.setUser(user);
2026-08-09 05:00:30 +02:00
// OIDC callback creates the session UNBOUND (redirect flow can't
// carry a JKT in the callback body). Post-redirect bind here
// attaches the browser keypair — one-shot, idempotent (server
// returns 409 if already bound). Awaited so subsequent requests
// under `DPOP=required` land with a bound session, not an
// unbound one that would 401 on the very next thumbnail. See
// `docs/plan/dpop.md` Gate 3.
await bindDpopIfPossible();
await goto(resolve(redirectTarget), { replaceState: true });
2026-06-17 22:07:18 -06:00
return;
}
// 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;
2026-06-17 22:07:18 -06:00
}
// 2) Existing-session probe: if already authenticated, skip the form.
2026-08-09 14:09:54 +02:00
// Skipped when we KNOW the session is gone: explicit logout /
// interceptor-detected expiry (both set `skipExistingSessionProbe`),
// OR the CSRF hint cookie is absent (fresh browser, no cookies at
// all — a probe would just 401). Probing anyway would trip the
// apiFetch → 401 → refresh → 401 → sessionExpiredHandler chain
// that clobbers whatever notice we're about to paint.
if (!skipExistingSessionProbe && hasSessionHint()) {
try {
const me = await fetchMe();
if (me) {
session.setUser(me);
await goto(resolve(redirectTarget), { replaceState: true });
return;
}
} catch {
/* probe failed — show the login page */
2026-06-17 22:07:18 -06:00
}
}
// 3) Bootstrap probe: a fresh install (no admin) must be set up first.
const [providers, status] = await Promise.all([getOidcProviders(), getAuthStatus()]);
oidc = providers;
setupAvailable = !status.initialized;
if (setupAvailable) mode = 'setup';
2026-08-03 00:22:19 +02:00
// Auto-redirect to the IdP in standalone-OIDC posture is enforced
// server-side via the `auto_redirect_if_standalone_oidc` auth policy
// (see interfaces/web/mod.rs::oidc_standalone_login_redirect). Keeping
// a client-side copy would make the policy toggle a no-op — the SPA
// would auto-redirect regardless of what the admin configured.
2026-06-17 22:07:18 -06:00
booting = false;
});
// Auto-focus the primary input for the current mode. Fires once the
// booting probes settle AND on every mode swap. The `booting` guard
// avoids stealing focus from something else during the loading
// splash; the input-ref guard covers the render-order case where
// the effect fires before the DOM has the target.
2026-08-09 11:16:14 +02:00
//
// `activeElement` guard: if the user (or Playwright's `.fill()`, or
// browser autofill) already has focus in a form field, don't yank
// it away. Concrete bug this prevents: boot probes are slow → user
// types their email into the (initially unfocused) input → probes
// finish → `booting` flips false → this effect fires and refocuses
// the input, which resets the caret and can concatenate subsequent
// keystrokes onto the wrong field if the user was mid-tab. Mode-
// swap re-runs still refocus correctly because the old form's
// inputs unmount first, resetting `activeElement` to `<body>`.
$effect(() => {
if (booting) return;
2026-08-09 11:16:14 +02:00
const active = document.activeElement;
if (
active &&
active !== document.body &&
(active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')
) {
return;
}
const target =
mode === 'login'
? loginIdentifierInput
: mode === 'register'
? registerEmailInput
: setupEmailInput;
target?.focus();
});
2026-06-17 17:06:30 -06:00
</script>
<svelte:head>
<title>{t('app.title', 'OxiCloud')}</title>
</svelte:head>
{#if willRedirect}
<!-- Full-viewport overlay that visually continues the boot splash
(see app.html `#app-splash`) — same background, same spinner —
so the OIDC callback landing goes spinner → spinner → /files
with no intermediate flash of the auth-panel shell (logo +
language selector). Auth-container below is skipped entirely
during this window; if the exchange fails, `willRedirect`
flips false and the normal login form takes over. -->
<div
class="auth-splash"
role="status"
aria-label="Signing in"
data-testid="login-redirect-loader"
>
<div class="auth-splash__spinner" aria-hidden="true"></div>
</div>
{:else}
<div class="auth-container">
<div class="auth-panel">
<div class="auth-logo">
<div class="auth-logo-icon">
<svg viewBox="95 67 320 320" aria-hidden="true">
<path
d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z"
/>
</svg>
</div>
<div class="auth-logo-text"><span class="brand-oxi">Oxi</span>Cloud</div>
2026-06-17 17:06:30 -06:00
</div>
<!-- Form paints immediately alongside the logo — the onMount
probes (OIDC code exchange, session probe, providers
lookup) run concurrently and either redirect the user
away or upgrade the visible affordances (OIDC button,
magic-link toggle) in place. Guarding the whole form
behind `booting` caused a "logo only, then form" flash
on first paint. -->
{#if loginErrorNotice}
<!-- Dedicated error view — hides the login form entirely
2026-08-08 20:31:19 +02:00
until the user dismisses. Lands the user on a focused
"this went wrong" screen instead of a form buried
under a red banner. Sibling of the callback redirect
that surfaced this notice in the first place.
Reuses `.auth-title` and `.auth-button` for theme
consistency with the normal login/register/setup views. -->
<div class="auth-error-view" role="alert" data-testid="login-error-notice">
<h1 class="auth-title">{t('auth.login_error_title', 'Sign-in failed')}</h1>
<p class="auth-error-view__message">{loginErrorNotice}</p>
2026-08-08 20:31:19 +02:00
<button
type="button"
class="auth-button"
data-testid="login-error-back-btn"
onclick={() => (loginErrorNotice = null)}
2026-08-08 20:31:19 +02:00
>
{t('auth.login_error_back_to_login', 'Back to login')}
</button>
2026-08-08 20:31:19 +02:00
</div>
{:else}
<h1 class="auth-title">
{#if mode === 'login'}
{t('auth.sign_in', 'Sign in')}
{:else if mode === 'register'}
{t('auth.register', 'Create account')}
{:else}
{t('auth.setup_title', 'Initial setup')}
{/if}
</h1>
{#if sessionExpiredNotice}
<div
class="auth-error auth-error--dismissible"
style="display: flex"
role="alert"
data-testid="login-session-expired-notice"
>
<span>{t('auth.session_expired', 'Your session expired. Please sign in again.')}</span>
<button
type="button"
class="auth-notice-dismiss"
aria-label={t('common.dismiss', 'Dismiss')}
data-testid="login-session-expired-dismiss-btn"
onclick={() => (sessionExpiredNotice = false)}>×</button
>
</div>
{/if}
{#if loggedOutNotice}
<div
class="auth-success auth-error--dismissible"
style="display: flex"
role="status"
data-testid="login-logged-out-notice"
2026-08-08 20:31:19 +02:00
>
<span>{t('auth.logged_out', 'Successfully signed out.')}</span>
<button
type="button"
class="auth-notice-dismiss"
aria-label={t('common.dismiss', 'Dismiss')}
data-testid="login-logged-out-dismiss-btn"
onclick={() => (loggedOutNotice = false)}>×</button
>
</div>
{/if}
2026-06-17 17:06:30 -06:00
{#if postRegisterNotice && mode === 'login'}
<div
class="auth-success auth-error--dismissible"
style="display: flex"
role="status"
data-testid="login-post-register-notice"
>
<span>{postRegisterNotice}</span>
<button
type="button"
class="auth-notice-dismiss"
aria-label={t('common.dismiss', 'Dismiss')}
data-testid="login-post-register-dismiss-btn"
onclick={() => (postRegisterNotice = null)}>×</button
>
</div>
{/if}
{#if mode === 'login'}
<!-- Unified login form. One identifier + one (optional)
password field drive both flows:
* password filled → POST /api/auth/login
* password empty → POST /api/auth/magic-link/send
* password-only server → password field is required, no hint
* magic-link-only server → password field hides entirely -->
{#if passwordLoginEnabled || magicLinkLoginEnabled}
{#if error}
<div
class={emailNotVerified ? 'auth-success' : 'auth-error'}
style="display: block"
role="alert"
>
{error}
</div>
{/if}
{#if magicStatus}
<div
class={magicStatus.ok
? 'auth-status auth-status-success'
: 'auth-status auth-status-error'}
role={magicStatus.ok ? 'status' : 'alert'}
>
{magicStatus.text}
</div>
{/if}
<form class="auth-form" data-testid="login-form" onsubmit={onLogin} novalidate>
<div class="auth-input-group">
<label class="auth-label" for="login-username">
{t('auth.login_identifier', 'Username or email')}
</label>
<div class="auth-input-wrap auth-input-wrap--user">
<input
id="login-username"
class="auth-input"
data-testid="login-username-input"
type="text"
bind:value={username}
bind:this={loginIdentifierInput}
autocomplete="username"
placeholder={t(
'auth.login_identifier_placeholder',
'Enter your username or email'
)}
required
disabled={busy}
/>
</div>
</div>
{#if passwordLoginEnabled}
<div class="auth-input-group">
<label class="auth-label" for="login-password">
{#if magicLinkLoginEnabled}
{t('auth.password_or_link_hint', 'Password (leave blank for a sign-in link)')}
{:else}
{t('auth.password', 'Password')}
{/if}
</label>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="login-password"
class="auth-input"
data-testid="login-password-input"
type={showPassword ? 'text' : 'password'}
bind:value={password}
onkeydown={onPwKey}
onkeyup={onPwKey}
autocomplete="current-password"
required={!magicLinkLoginEnabled}
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={showPassword}
data-testid="login-password-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (showPassword = !showPassword)}
></button>
</div>
{#if capsOn}
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
{/if}
</div>
{/if}
<button
class="auth-button"
type="submit"
data-testid="login-submit-btn"
disabled={busy}
aria-busy={busy}
>
{#if busy}
{submitAsMagicLink
? t('auth.sending', 'Sending…')
: t('auth.signing_in', 'Signing in…')}
{:else if submitAsMagicLink}
{t('auth.magicLinkSubmit', 'Send sign-in link')}
{:else}
{t('auth.sign_in', 'Sign in')}
{/if}
</button>
</form>
{/if}
{#if oidc.enabled}
{#if passwordLoginEnabled}
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
{/if}
<!-- Backend OIDC authorize endpoint (not a SvelteKit route). -->
<a
class="auth-button auth-button-oidc"
data-testid="login-oidc-btn"
href={oidc.authorize_endpoint}
rel="external"
2026-08-08 20:31:19 +02:00
>
{t(
'auth.sso_login_provider',
{ provider: oidc.provider_name ?? 'SSO' },
'Sign in with {{provider}}'
)}
</a>
{/if}
{#if passwordLoginEnabled}
<div class="auth-toggle">
{t('auth.no_account', 'No account?')}
<button
class="auth-toggle-link"
data-testid="login-to-register-btn"
onclick={() => (mode = 'register')}
>
{t('auth.register', 'Create one')}
</button>
2026-08-08 20:31:19 +02:00
</div>
{/if}
{#if setupAvailable}
<div class="auth-toggle">
{t('auth.admin_setup', 'First time?')}
<button
class="auth-toggle-link"
data-testid="login-to-setup-btn"
onclick={() => (mode = 'setup')}
>
{t('auth.setup', 'Set up administrator')}
</button>
2026-08-08 20:31:19 +02:00
</div>
{/if}
{:else if mode === 'register'}
{#if regError}<div class="auth-error" style="display: block" role="alert">
{regError}
</div>{/if}
<form
class="auth-form"
data-testid="login-register-form"
onsubmit={onRegister}
novalidate
>
<!-- Email is the only required identifier since PR 18 — the
backend accepts email-only signup and mints a welcome
magic-link. Username is optional at this stage; the user
can claim a handle later via profile settings. -->
2026-08-08 20:31:19 +02:00
<div class="auth-input-group">
<label class="auth-label" for="reg-email">{t('auth.email', 'Email')}</label>
<input
id="reg-email"
class="auth-input"
data-testid="login-register-email-input"
type="email"
bind:value={regEmail}
bind:this={registerEmailInput}
autocomplete="email"
required
disabled={busy}
/>
</div>
<div class="auth-input-group">
<label class="auth-label" for="reg-username">
{t('auth.username_optional', 'Username (optional)')}
2026-08-08 20:31:19 +02:00
</label>
<input
id="reg-username"
class="auth-input"
data-testid="login-register-username-input"
bind:value={regUsername}
autocomplete="username"
disabled={busy}
/>
2026-08-08 20:31:19 +02:00
</div>
<!-- Password fields hide entirely when policy forbids password
login — the whole form becomes email-only in that mode. -->
2026-08-08 20:31:19 +02:00
{#if passwordLoginEnabled}
<div class="auth-input-group">
<label class="auth-label" for="reg-password">
{t(
'auth.password_optional',
'Password (optional — leave blank for a sign-in link)'
)}
2026-08-08 20:31:19 +02:00
</label>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="reg-password"
2026-08-08 20:31:19 +02:00
class="auth-input"
data-testid="login-register-password-input"
type={regShowPassword ? 'text' : 'password'}
bind:value={regPassword}
onkeydown={onRegPwKey}
onkeyup={onRegPwKey}
autocomplete="new-password"
2026-08-08 20:31:19 +02:00
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={regShowPassword}
data-testid="login-register-password-toggle-btn"
2026-08-08 20:31:19 +02:00
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (regShowPassword = !regShowPassword)}
2026-08-08 20:31:19 +02:00
></button>
</div>
{#if regCapsOn}
2026-08-08 20:31:19 +02:00
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
{/if}
</div>
{#if !regEmailOnly}
<div class="auth-input-group">
<label class="auth-label" for="reg-confirm"
>{t('auth.confirm_password', 'Confirm password')}</label
>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="reg-confirm"
class="auth-input"
data-testid="login-register-confirm-input"
type={regShowConfirm ? 'text' : 'password'}
bind:value={regConfirm}
onkeydown={onRegPwKey}
onkeyup={onRegPwKey}
autocomplete="new-password"
required
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={regShowConfirm}
data-testid="login-register-confirm-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (regShowConfirm = !regShowConfirm)}
></button>
</div>
{#if matchState}
<div
class="auth-match show {matchState === 'ok'
? 'auth-match--ok'
: 'auth-match--bad'}"
>
{matchState === 'ok'
? t('auth.passwords_match', 'Passwords match')
: t('auth.passwords_mismatch', "Passwords don't match")}
</div>
{/if}
</div>
{/if}
2026-08-08 20:31:19 +02:00
{/if}
<button
class="auth-button"
type="submit"
data-testid="login-register-submit-btn"
2026-08-08 20:31:19 +02:00
disabled={busy}
aria-busy={busy}
>
{!passwordLoginEnabled || regEmailOnly
? t('auth.register_email_only', 'Send me a sign-in link')
: t('auth.register', 'Create account')}
2026-08-08 20:31:19 +02:00
</button>
</form>
<div class="auth-toggle">
{t('auth.have_account', 'Already have an account?')}
2026-08-08 20:31:19 +02:00
<button
class="auth-toggle-link"
data-testid="login-register-to-login-btn"
onclick={() => (mode = 'login')}
2026-08-08 20:31:19 +02:00
>
{t('auth.sign_in', 'Sign in')}
2026-08-08 20:31:19 +02:00
</button>
2026-06-17 22:07:18 -06:00
</div>
{:else}
<div class="setup-steps">
<div class="setup-step">
<div class="step-number active">1</div>
<div class="step-title active">{t('auth.setup_step1', 'Admin')}</div>
</div>
<div class="setup-step">
<div class="step-number">2</div>
<div class="step-title">{t('auth.setup_step2', 'System')}</div>
</div>
<div class="setup-step">
<div class="step-number">3</div>
<div class="step-title">{t('auth.setup_step3', 'Completed')}</div>
</div>
2026-06-17 22:07:18 -06:00
</div>
{#if setupError}<div class="auth-error" style="display: block" role="alert">
{setupError}
</div>{/if}
{#if setupSuccess}<div class="auth-success" style="display: block">
{setupSuccess}
</div>{/if}
<form class="auth-form" data-testid="login-setup-form" onsubmit={onSetup} novalidate>
<div class="auth-input-group">
<label class="auth-label" for="setup-username">
{t('auth.admin_username', 'Administrator username')}
</label>
<div class="auth-input-wrap auth-input-wrap--user">
<input
id="setup-username"
class="auth-input"
data-testid="login-setup-username-input"
type="text"
value="admin"
readonly
/>
</div>
</div>
<div class="auth-input-group">
<label class="auth-label" for="setup-email">
{t('auth.admin_email', 'Administrator email')}
</label>
<div class="auth-input-wrap auth-input-wrap--mail">
<input
id="setup-email"
class="auth-input"
data-testid="login-setup-email-input"
type="email"
bind:value={setupEmail}
bind:this={setupEmailInput}
autocomplete="email"
required
disabled={busy}
/>
</div>
</div>
<div class="auth-input-group">
<label class="auth-label" for="setup-password">
{t('auth.admin_password', 'Administrator password')}
</label>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="setup-password"
class="auth-input"
data-testid="login-setup-password-input"
type={setupShowPassword ? 'text' : 'password'}
bind:value={setupPassword}
onkeydown={onSetupPwKey}
onkeyup={onSetupPwKey}
2026-08-08 20:31:19 +02:00
autocomplete="new-password"
minlength="8"
required
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={setupShowPassword}
data-testid="login-setup-password-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (setupShowPassword = !setupShowPassword)}
></button>
</div>
{#if setupCapsOn}
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
{/if}
2026-06-17 22:07:18 -06:00
</div>
<div class="auth-input-group">
<label class="auth-label" for="setup-confirm">
{t('auth.confirm_password', 'Confirm password')}
</label>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="setup-confirm"
class="auth-input"
data-testid="login-setup-confirm-input"
type={setupShowConfirm ? 'text' : 'password'}
bind:value={setupConfirm}
onkeydown={onSetupPwKey}
onkeyup={onSetupPwKey}
autocomplete="new-password"
required
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={setupShowConfirm}
data-testid="login-setup-confirm-toggle-btn"
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (setupShowConfirm = !setupShowConfirm)}
></button>
</div>
{#if setupMatchState}
<div
class="auth-match show {setupMatchState === 'ok'
? 'auth-match--ok'
: 'auth-match--bad'}"
2026-08-08 20:31:19 +02:00
>
{setupMatchState === 'ok'
? t('auth.passwords_match', 'Passwords match')
: t('auth.passwords_mismatch', "Passwords don't match")}
2026-08-08 20:31:19 +02:00
</div>
{/if}
2026-08-08 20:31:19 +02:00
</div>
<button
class="auth-button"
type="submit"
data-testid="login-setup-submit-btn"
disabled={busy}
aria-busy={busy}
>
{t('auth.create_admin', 'Create administrator')}
</button>
</form>
2026-08-08 20:31:19 +02:00
<div class="auth-toggle">
{t('auth.back_to_login', 'Already configured?')}
<button
class="auth-toggle-link"
data-testid="login-setup-to-login-btn"
onclick={() => (mode = 'login')}
>
{t('auth.sign_in', 'Sign in')}
</button>
</div>
{/if}
2026-08-08 20:31:19 +02:00
{/if}
2026-06-17 22:07:18 -06:00
<div class="auth-lang">
<select
aria-label={t('settings.language', 'Language')}
data-testid="login-language-select"
value={i18n.locale}
onchange={(e) => setLocale(e.currentTarget.value as Locale)}
>
{#each SUPPORTED_LOCALES as loc (loc)}
<option value={loc}>{loc}</option>
{/each}
</select>
</div>
2026-06-17 22:07:18 -06:00
</div>
2026-06-17 17:06:30 -06:00
</div>
{/if}
2026-06-17 22:07:18 -06:00
<style>
.auth-lang {
margin-top: var(--space-5);
text-align: center;
}
.auth-lang select {
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text-muted);
}
.auth-error--dismissible {
align-items: center;
gap: var(--space-2);
justify-content: space-between;
}
.auth-notice-dismiss {
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
font-size: var(--font-size-lg);
line-height: 1;
padding: 0 var(--space-1);
}
.auth-notice-dismiss:hover {
opacity: 0.7;
}
2026-08-08 20:31:19 +02:00
/* Dedicated error view — centered in the auth-panel with the
message padded off the title/button. `.auth-button` (from
ported/auth.css) already carries the full-width primary
styling used by the login submit, so the back button lands
right on the existing theme. */
.auth-error-view {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
text-align: center;
}
.auth-error-view__message {
color: var(--color-text-secondary);
margin: 0;
}
/* OIDC-callback continuation splash — VISUALLY IDENTICAL to the
boot splash in app.html so the transition boot-splash →
post-exchange overlay is imperceptible: same background, same
spinner dimensions, same colors, same animation cadence.
Tokens match by design — `--color-bg-page` resolves to the
same `light-dark(#f5f7fa, #0f172a)` the boot splash hard-codes;
ditto `--color-border` and `--color-accent`. If you ever retone
the splash, update BOTH sites at once (app.html + this file). */
.auth-splash {
position: fixed;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
background: var(--color-bg-page);
}
.auth-splash__spinner {
width: 38px;
height: 38px;
border: 3px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
2026-06-17 22:07:18 -06:00
</style>