test(login/register): via password or magic-link
Password login ┌─────┬────────────────────────────────────────────────────┬────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L1 │ Login by username │ auth_login.hurl Case 1 │ 200 + access_token, user.email match │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L2 │ Login by email (dispatch on @) │ auth_login.hurl Case 2 │ 200, same session shape as L1 │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L3 │ Bad password on username path │ auth_login.hurl Case 3 │ 403 anti-enum │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L4 │ Bad password on email path │ auth_login.hurl Case 4 │ 403 anti-enum (same shape as L3) │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L5 │ Unknown username │ auth_login.hurl Case 5 │ 403 anti-enum (same shape as L3) │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L6 │ Unknown email │ auth_login.hurl Case 6 │ 403 anti-enum (same shape as L3) │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L7 │ /api/auth/oidc/providers reports methods correctly │ auth_login.hurl Case 7 │ password_login_enabled: true, magic_link_login_enabled: true, require_verified_email: false │ └─────┴────────────────────────────────────────────────────┴────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────┘ Password registration ┌─────┬───────────────────────────────────────────────────┬──────────────────────────────┬─────────────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤ │ R1 │ Classic username + email + password → uniform 200 │ registration.hurl Step 2 │ anti-enum message contains "request received" │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤ │ R2 │ Login after register works │ registration.hurl Step 2b │ 200 + session for the new user │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤ │ R3 │ Email collision → uniform 200 (no rewrite) │ registration.hurl Steps 8-10 │ attacker password doesn't work; original account intact │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ R4 │ Username collision → uniform 200 │ registration.hurl Step 11 │ same anti-enum shape │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ R5 │ Off-domain rejection │ registration.hurl Step 12 │ 403 RegistrationDomainNotAllowed │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ R6 │ Case-insensitive domain match │ registration.hurl Step 12b │ uniform 200 on charlie@EXAMPLE.COM │ └─────┴───────────────────────────────────────────────────┴──────────────────────────────┴────────────────────────────┘ Magic-link registration (email-only signup) ┌─────┬──────────────────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR1 │ Email-only signup → welcome mail queued │ registration.hurl Step 3 │ uniform 200 + browser-binding cookie set │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR2 │ Welcome mail contains magic-link URL │ registration.hurl Step 4 │ captured from mock SMTP │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR3 │ PR 22 cross-browser confirmation page │ registration.hurl Step 5a │ 200 HTML "different browser" │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR4 │ Cookie-bound redemption lands on SPA │ registration.hurl Step 5b │ 302 → /files (SvelteKit route, post-migration) │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR5 │ email_verified_at stamped after redemption │ registration.hurl Step 6 │ field present on /api/auth/me │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR6 │ Second magic-link post-signup │ registration.hurl Step 7 │ uniform 200 │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR7 │ Profile PATCH — no-op, name set, empty-string rejected, username-taken 409, claim-once 409, etc. │ registration.hurl Steps 6a–6i │ full profile lifecycle │ └─────┴──────────────────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────┘ Magic-link login (existing account) ┌─────┬──────────────────────────────────────────────────────────┬──────────────────────────────────────┬───────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML1 │ Baseline password login still works │ auth_magic_link_login.hurl Steps 1-2 │ 200 │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML2 │ magic-link/send with email identifier │ auth_magic_link_login.hurl Step 3 │ uniform 200 + cookie │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML3 │ magic-link/send with username identifier (dispatch on @) │ auth_magic_link_login.hurl Step 4 │ uniform 200 │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML4 │ Password-user policy: mail actually sent │ auth_magic_link_login.hurl Step 5 │ SMTP capture proves permit_magic_link_for_password_users in effect │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML5 │ Redemption creates a session │ auth_magic_link_login.hurl Steps 6-7 │ 302 → /files, /api/auth/me returns the same user │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML6 │ Anti-enum on unknown identifier │ auth_magic_link_login.hurl Step 8 │ same uniform 200 shape as ML3 │ └─────┴──────────────────────────────────────────────────────────┴──────────────────────────────────────┴───────────────────────────────────────┘ OIDC ┌─────┬────────────────────────────────────────────────────────────────────────┬───────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O1 │ Setup local admin (bootstrap) │ oidc.hurl Step 1 │ 201 │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O2 │ Providers endpoint — OIDC visible │ oidc.hurl Step 2 │ enabled: true, provider_name: MockSSO, password_login_enabled: true, magic_link_login_enabled: false (OIDC-master rule) │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O2b │ Magic-link/send refused (endpoint layer) │ oidc.hurl Step 2b │ 403 MagicLinkLoginDisabled — proves the policy gate fires, not a 503 SMTP-unwired │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O3 │ Authorize redirect includes PKCE + state │ oidc.hurl Step 3 │ 307 to fake IdP │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O4 │ IdP round-trip + JIT provisioning │ oidc.hurl Step 4 │ Callback lands on /login?oidc_code=… │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O5 │ Code exchange → session cookies │ oidc.hurl Step 5 │ 200 + all three cookies │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O6 │ JIT profile mapping (name, given/family, picture, groups → admin role) │ oidc.hurl Step 6 │ every claim reflected on /api/auth/me │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O7 │ Refresh rotation on OIDC session │ oidc.hurl Step 7 │ new access/refresh/CSRF cookies │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O8 │ Refreshed cookies authenticate │ oidc.hurl Step 8 │ 200 on /api/auth/me │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O9 │ Repeat login = same local user (no dup) │ oidc.hurl Step 9 │ user_id stable │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O10 │ Anti-takeover: unverified email → refused │ oidc.hurl Step 10 │ 401/403 │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O11 │ One-time code replay refused │ oidc.hurl Step 11 │ second /exchange → 401 │ └─────┴────────────────────────────────────────────────────────────────────────┴───────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘ test
This commit is contained in:
@@ -142,12 +142,26 @@ export async function apiJson<T>(input: RequestInfo | URL, init?: RequestInit):
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
/**
|
||||
* `error_type` field from the backend's `ErrorResponse` body, when
|
||||
* present. Callers switch on this to render specific UX for
|
||||
* distinguished failures (e.g. `EmailNotVerified` → "resend
|
||||
* verification link" prompt). Falls back to `undefined` when the
|
||||
* response body isn't parseable or the endpoint doesn't emit one.
|
||||
*/
|
||||
readonly errorType?: string;
|
||||
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly statusText: string,
|
||||
readonly resource: RequestInfo | URL
|
||||
readonly resource: RequestInfo | URL,
|
||||
errorType?: string,
|
||||
serverMessage?: string
|
||||
) {
|
||||
super(`API ${status} ${statusText} for ${urlString(resource as RequestInfo | URL)}`);
|
||||
super(
|
||||
serverMessage ?? `API ${status} ${statusText} for ${urlString(resource as RequestInfo | URL)}`
|
||||
);
|
||||
this.name = 'ApiError';
|
||||
this.errorType = errorType;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ it('exercises the auth endpoints (success paths)', async () => {
|
||||
await auth.getAuthStatus().catch(() => {});
|
||||
await auth.setupAdmin('e@x.test', 'p').catch(() => {});
|
||||
await auth.exchangeOidcCode('code').catch(() => {});
|
||||
await auth.register('u', 'e@x.test', 'p').catch(() => {});
|
||||
await auth.register('e@x.test', 'p', 'u').catch(() => {});
|
||||
await auth.sendMagicLink('e@x.test').catch(() => {});
|
||||
await auth.logout().catch(() => {});
|
||||
const fc = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
|
||||
@@ -3,10 +3,32 @@
|
||||
* primitives here intentionally bypass it (see client.ts) so a 401 surfaces as
|
||||
* a genuine failure to the caller.
|
||||
*/
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { ApiError, apiFetch } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
import type { AuthResponse, User } from '$lib/api/types';
|
||||
|
||||
/**
|
||||
* Best-effort parse of the backend `ErrorResponse` shape
|
||||
* (`{ status, error, message, error_type }`). Returns whatever it could
|
||||
* extract; never throws — a malformed body just yields undefineds.
|
||||
*/
|
||||
async function parseErrorBody(res: Response): Promise<{ errorType?: string; message?: string }> {
|
||||
try {
|
||||
const body = (await res.clone().json()) as {
|
||||
error_type?: unknown;
|
||||
message?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
const errorType = typeof body.error_type === 'string' ? body.error_type : undefined;
|
||||
const rawMessage =
|
||||
(typeof body.message === 'string' ? body.message : undefined) ??
|
||||
(typeof body.error === 'string' ? body.error : undefined);
|
||||
return { errorType, message: rawMessage };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
|
||||
/**
|
||||
@@ -48,7 +70,13 @@ export async function login(emailOrUsername: string, password: string): Promise<
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ username: emailOrUsername, password })
|
||||
});
|
||||
if (!res.ok) throw new Error(`login failed: ${res.status}`);
|
||||
if (!res.ok) {
|
||||
// Surface the backend `error_type` so the login page can offer
|
||||
// specific UX: `EmailNotVerified` → "resend verification link",
|
||||
// `PasswordLoginDisabled` → nudge toward magic-link / SSO, etc.
|
||||
const { errorType, message } = await parseErrorBody(res);
|
||||
throw new ApiError(res.status, res.statusText, '/api/auth/login', errorType, message);
|
||||
}
|
||||
return (await res.json()) as AuthResponse;
|
||||
}
|
||||
|
||||
@@ -56,6 +84,20 @@ export interface OidcProviders {
|
||||
enabled: boolean;
|
||||
provider_name?: string;
|
||||
password_login_enabled?: boolean;
|
||||
/**
|
||||
* True when the server accepts magic-link login requests. The backend
|
||||
* composes three factors: SMTP wired, `OXICLOUD_AUTH_METHODS` allowlist
|
||||
* includes `magic_link`, and OIDC is NOT enabled at the deployment
|
||||
* (OIDC-enabled deployments must not offer magic-link — it would bypass
|
||||
* any 2FA / step-up the IdP enforces).
|
||||
*/
|
||||
magic_link_login_enabled?: boolean;
|
||||
/**
|
||||
* True when `OXICLOUD_REQUIRE_VERIFIED_EMAIL` is set. The login page
|
||||
* uses this to explain the `EmailNotVerified` login response and
|
||||
* surface a "resend verification link" affordance.
|
||||
*/
|
||||
require_verified_email?: boolean;
|
||||
authorize_endpoint?: string;
|
||||
}
|
||||
|
||||
@@ -135,16 +177,21 @@ export async function exchangeOidcCode(code: string): Promise<User | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user. Raw `fetch` (NOT apiFetch) so a 401/validation failure
|
||||
* surfaces to the caller instead of tripping the global refresh-and-redirect
|
||||
* interceptor — mirrors the login primitive.
|
||||
* Register a new user. Since PR 18 both `username` and `password` are optional
|
||||
* on the backend: an email-only signup is valid and mints a welcome magic-link.
|
||||
* Raw `fetch` (NOT apiFetch) so a 401/validation failure surfaces to the caller
|
||||
* instead of tripping the global refresh-and-redirect interceptor — mirrors
|
||||
* the login primitive.
|
||||
*/
|
||||
export async function register(username: string, email: string, password: string): Promise<void> {
|
||||
export async function register(email: string, password?: string, username?: string): Promise<void> {
|
||||
const body: Record<string, unknown> = { email, role: 'user' };
|
||||
if (password) body.password = password;
|
||||
if (username) body.username = username;
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||
body: JSON.stringify({ username, email, password, role: 'user' })
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
|
||||
|
||||
@@ -307,10 +307,15 @@
|
||||
}
|
||||
|
||||
.auth-toggle-link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--color-accent-text);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: var(--weight-medium);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.auth-toggle-link:hover {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { page } from '$app/state';
|
||||
import type { Pathname } from '$app/types';
|
||||
import { onMount } from 'svelte';
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import {
|
||||
exchangeOidcCode,
|
||||
fetchMe,
|
||||
@@ -37,16 +38,20 @@
|
||||
let error = $state('');
|
||||
let busy = $state(false);
|
||||
|
||||
// Register
|
||||
// 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.
|
||||
let regUsername = $state('');
|
||||
let regEmail = $state('');
|
||||
let regPassword = $state('');
|
||||
let regConfirm = $state('');
|
||||
let regError = $state('');
|
||||
let regSuccess = $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);
|
||||
|
||||
// Admin setup (first run)
|
||||
let setupEmail = $state('');
|
||||
@@ -61,14 +66,51 @@
|
||||
setupConfirm.length === 0 ? '' : setupPassword === setupConfirm ? 'ok' : 'bad'
|
||||
);
|
||||
|
||||
// Magic link
|
||||
let magicOpen = $state(false);
|
||||
let magicEmail = $state('');
|
||||
// Magic-link submit status (rendered inline after a link is sent).
|
||||
let magicStatus = $state<{ text: string; ok: boolean } | null>(null);
|
||||
|
||||
// OIDC
|
||||
// OIDC + auth-method flags exposed by /api/auth/oidc/providers.
|
||||
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.
|
||||
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);
|
||||
// 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);
|
||||
|
||||
// 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
|
||||
@@ -94,9 +136,21 @@
|
||||
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.
|
||||
async function onLogin(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
emailNotVerified = null;
|
||||
magicStatus = null;
|
||||
if (submitAsMagicLink) {
|
||||
await submitMagicLink();
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
const data = await login(username, password);
|
||||
@@ -108,9 +162,58 @@
|
||||
return;
|
||||
}
|
||||
session.setUser(data.user);
|
||||
postRegisterNotice = null;
|
||||
await goto(resolve(redirectTarget), { replaceState: true });
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : t('auth.login_error', 'Error logging in');
|
||||
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 };
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
@@ -119,17 +222,25 @@
|
||||
async function onRegister(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
regError = '';
|
||||
regSuccess = '';
|
||||
if (regPassword !== regConfirm) {
|
||||
regError = t('auth.passwords_mismatch', 'Passwords do not match');
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
await register(regUsername, regEmail, regPassword);
|
||||
regSuccess = t('auth.account_success', 'Account created. You can now sign in.');
|
||||
// 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);
|
||||
regUsername = regEmail = regPassword = regConfirm = '';
|
||||
setTimeout(() => (mode = 'login'), 2000);
|
||||
// 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';
|
||||
} catch (err) {
|
||||
regError =
|
||||
err instanceof Error ? err.message : t('auth.register_error', 'Registration failed');
|
||||
@@ -165,38 +276,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function onMagicLink(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!magicEmail) return;
|
||||
magicStatus = null;
|
||||
busy = true;
|
||||
try {
|
||||
const result = await sendMagicLink(magicEmail);
|
||||
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
|
||||
};
|
||||
if (result === 'sent') magicEmail = '';
|
||||
} catch {
|
||||
magicStatus = { text: t('auth.magic_error', 'Something went wrong. Try again.'), ok: false };
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
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).
|
||||
if (page.url.searchParams.get('source') === 'session_expired') {
|
||||
sessionExpiredNotice = true;
|
||||
const stripped = new URL(page.url);
|
||||
stripped.searchParams.delete('source');
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
stripped.pathname + stripped.search + stripped.hash
|
||||
);
|
||||
}
|
||||
|
||||
// 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');
|
||||
@@ -230,6 +325,22 @@
|
||||
|
||||
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.
|
||||
$effect(() => {
|
||||
if (booting) return;
|
||||
const target =
|
||||
mode === 'login'
|
||||
? loginIdentifierInput
|
||||
: mode === 'register'
|
||||
? registerEmailInput
|
||||
: setupEmailInput;
|
||||
target?.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -249,9 +360,13 @@
|
||||
<div class="auth-logo-text"><span class="brand-oxi">Oxi</span>Cloud</div>
|
||||
</div>
|
||||
|
||||
{#if booting}
|
||||
<p class="auth-subtitle">{t('common.loading', 'Loading…')}</p>
|
||||
{:else}
|
||||
<!-- 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. -->
|
||||
<h1 class="auth-title">
|
||||
{#if mode === 'login'}
|
||||
{t('auth.sign_in', 'Sign in')}
|
||||
@@ -262,19 +377,73 @@
|
||||
{/if}
|
||||
</h1>
|
||||
|
||||
{#if page.url.searchParams.get('source') === 'session_expired'}
|
||||
<div class="auth-error" style="display: block">
|
||||
{t('auth.session_expired', 'Your session expired. Please sign in again.')}
|
||||
{#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 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'}
|
||||
{#if passwordLoginEnabled}
|
||||
{#if error}<div class="auth-error" style="display: block" role="alert">{error}</div>{/if}
|
||||
<!-- 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.username', 'Username or email')}
|
||||
{t('auth.login_identifier', 'Username or email')}
|
||||
</label>
|
||||
<div class="auth-input-wrap auth-input-wrap--user">
|
||||
<input
|
||||
@@ -283,42 +452,54 @@
|
||||
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>
|
||||
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="login-password">{t('auth.password', 'Password')}</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
|
||||
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>
|
||||
{#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 capsOn}
|
||||
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="auth-button"
|
||||
@@ -327,62 +508,17 @@
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
>
|
||||
{busy ? t('auth.signing_in', 'Signing in…') : t('auth.sign_in', 'Sign in')}
|
||||
{#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>
|
||||
|
||||
<button
|
||||
class="auth-magic-toggle"
|
||||
data-testid="login-magic-toggle-btn"
|
||||
onclick={() => (magicOpen = !magicOpen)}
|
||||
>
|
||||
{t('auth.magic_prompt', 'No password? Sign in with an email link')}
|
||||
</button>
|
||||
{#if magicOpen}
|
||||
<div class="auth-magic-reveal">
|
||||
<p class="auth-hint">
|
||||
{t(
|
||||
'auth.magic_hint',
|
||||
"No password? Enter your email and we'll send you a one-time sign-in link."
|
||||
)}
|
||||
</p>
|
||||
<form class="auth-form" data-testid="login-magic-form" onsubmit={onMagicLink}>
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="magic-email">
|
||||
{t('auth.magic_email_label', 'Email address')}
|
||||
</label>
|
||||
<div class="auth-input-wrap auth-input-wrap--mail">
|
||||
<input
|
||||
id="magic-email"
|
||||
class="auth-input"
|
||||
data-testid="login-magic-email-input"
|
||||
type="email"
|
||||
bind:value={magicEmail}
|
||||
autocomplete="email"
|
||||
placeholder={t('auth.email', 'you@example.com')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="auth-button auth-button-secondary"
|
||||
type="submit"
|
||||
data-testid="login-magic-send-btn"
|
||||
disabled={busy}
|
||||
>
|
||||
{t('auth.magic_send', 'Send link')}
|
||||
</button>
|
||||
</form>
|
||||
{#if magicStatus}
|
||||
<div
|
||||
class={magicStatus.ok
|
||||
? 'auth-status auth-status-success'
|
||||
: 'auth-status auth-status-error'}
|
||||
>
|
||||
{magicStatus.text}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if oidc.enabled}
|
||||
@@ -433,19 +569,11 @@
|
||||
{#if regError}<div class="auth-error" style="display: block" role="alert">
|
||||
{regError}
|
||||
</div>{/if}
|
||||
{#if regSuccess}<div class="auth-success" style="display: block">{regSuccess}</div>{/if}
|
||||
<form class="auth-form" data-testid="login-register-form" onsubmit={onRegister} novalidate>
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="reg-username">{t('auth.username', 'Username')}</label>
|
||||
<input
|
||||
id="reg-username"
|
||||
class="auth-input"
|
||||
data-testid="login-register-username-input"
|
||||
bind:value={regUsername}
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<!-- 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. -->
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="reg-email">{t('auth.email', 'Email')}</label>
|
||||
<input
|
||||
@@ -454,74 +582,101 @@
|
||||
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-password">{t('auth.password', 'Password')}</label>
|
||||
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
|
||||
<input
|
||||
id="reg-password"
|
||||
class="auth-input"
|
||||
data-testid="login-register-password-input"
|
||||
type={regShowPassword ? 'text' : 'password'}
|
||||
bind:value={regPassword}
|
||||
onkeydown={onRegPwKey}
|
||||
onkeyup={onRegPwKey}
|
||||
autocomplete="new-password"
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="auth-pw-toggle"
|
||||
aria-pressed={regShowPassword}
|
||||
data-testid="login-register-password-toggle-btn"
|
||||
aria-label={t('auth.toggle_password', 'Show password')}
|
||||
onclick={() => (regShowPassword = !regShowPassword)}
|
||||
></button>
|
||||
</div>
|
||||
{#if regCapsOn}
|
||||
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
|
||||
{/if}
|
||||
<label class="auth-label" for="reg-username">
|
||||
{t('auth.username_optional', 'Username (optional)')}
|
||||
</label>
|
||||
<input
|
||||
id="reg-username"
|
||||
class="auth-input"
|
||||
data-testid="login-register-username-input"
|
||||
bind:value={regUsername}
|
||||
autocomplete="username"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<!-- Password fields hide entirely when policy forbids password
|
||||
login — the whole form becomes email-only in that mode. -->
|
||||
{#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)'
|
||||
)}
|
||||
</label>
|
||||
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
|
||||
<input
|
||||
id="reg-password"
|
||||
class="auth-input"
|
||||
data-testid="login-register-password-input"
|
||||
type={regShowPassword ? 'text' : 'password'}
|
||||
bind:value={regPassword}
|
||||
onkeydown={onRegPwKey}
|
||||
onkeyup={onRegPwKey}
|
||||
autocomplete="new-password"
|
||||
disabled={busy}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="auth-pw-toggle"
|
||||
aria-pressed={regShowPassword}
|
||||
data-testid="login-register-password-toggle-btn"
|
||||
aria-label={t('auth.toggle_password', 'Show password')}
|
||||
onclick={() => (regShowPassword = !regShowPassword)}
|
||||
></button>
|
||||
</div>
|
||||
{#if regCapsOn}
|
||||
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
|
||||
{/if}
|
||||
</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")}
|
||||
{#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}
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class="auth-button"
|
||||
type="submit"
|
||||
@@ -529,7 +684,9 @@
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
>
|
||||
{t('auth.register', 'Create account')}
|
||||
{!passwordLoginEnabled || regEmailOnly
|
||||
? t('auth.register_email_only', 'Send me a sign-in link')
|
||||
: t('auth.register', 'Create account')}
|
||||
</button>
|
||||
</form>
|
||||
<div class="auth-toggle">
|
||||
@@ -591,6 +748,7 @@
|
||||
data-testid="login-setup-email-input"
|
||||
type="email"
|
||||
bind:value={setupEmail}
|
||||
bind:this={setupEmailInput}
|
||||
autocomplete="email"
|
||||
required
|
||||
disabled={busy}
|
||||
@@ -691,7 +849,6 @@
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="auth-lang">
|
||||
<select
|
||||
@@ -721,4 +878,24 @@
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -622,6 +622,7 @@
|
||||
"login_identifier_placeholder": "Enter your username or email",
|
||||
"password": "Password",
|
||||
"password_placeholder": "Enter your password",
|
||||
"password_or_link_hint": "Password (leave blank for a sign-in link)",
|
||||
"login_button": "Sign in",
|
||||
"no_account": "Don't have an account?",
|
||||
"register": "Sign up",
|
||||
@@ -676,6 +677,7 @@
|
||||
"session_expired": "Your session expired. Please sign in again.",
|
||||
"sign_in": "Sign in",
|
||||
"signing_in": "Signing in…",
|
||||
"sending": "Sending…",
|
||||
"toggle_password": "Show password"
|
||||
},
|
||||
"storage": {
|
||||
|
||||
Reference in New Issue
Block a user