diff --git a/docs/SETUP.md b/docs/SETUP.md
index ae9fd45..dd42ab6 100644
--- a/docs/SETUP.md
+++ b/docs/SETUP.md
@@ -219,6 +219,18 @@ In **Authentication → URL Configuration**:
Sign-up confirmations and password-reset links are sent by Supabase itself, not the backend. Its built-in mailer only delivers to members of the Supabase project team, so set up a custom SMTP server under **Authentication → Emails → SMTP Settings** before real users can reset their passwords.
+For Brevo, use the SMTP login and an **SMTP key** as the password; REST API keys cannot authenticate SMTP.
+
+Supabase stores these credentials separately from the GitHub `SMTP_PASSWORD` secret used by backend notifications, so a backend deployment does not rotate the Auth mailer's key.
+
+If `/auth/v1/recover` returns a 5xx response:
+
+1. Find the request in **Logs → Auth** using its timestamp and `sb-request-id`.
+2. Use the logged error to distinguish SMTP failures from database timeouts or failed CAPTCHA verification.
+3. For SMTP errors, check the [Brevo SMTP settings](https://developers.brevo.com/docs/smtp-integration).
+4. For timeouts, check Supabase resource metrics and provider status.
+5. Verify recovery by requesting a reset link, opening the email, and signing in with the new password.
+
#### Create Storage Bucket
1. In Supabase dashboard: **Storage → New bucket**
diff --git a/frontend/src/components/AuthDialog.astro b/frontend/src/components/AuthDialog.astro
index 8512018..7bf7007 100644
--- a/frontend/src/components/AuthDialog.astro
+++ b/frontend/src/components/AuthDialog.astro
@@ -58,6 +58,8 @@ const t = lang === "cs" ? csCommon : enCommon;
-
+
window.__authDialogLabels = {
@@ -152,6 +161,13 @@ const t = lang === "cs" ? csCommon : enCommon;
sendResetLink: authSendResetLinkLabel,
rememberedPassword: authRememberedPasswordLabel,
resetLinkSent: authResetLinkSentLabel,
+ signingIn: authSigningInLabel,
+ creatingAccount: authCreatingAccountLabel,
+ sendingResetLink: authSendingResetLinkLabel,
+ captchaUnavailable: authCaptchaUnavailableLabel,
+ serviceUnavailable: authServiceUnavailableLabel,
+ resetUnavailable: authResetUnavailableLabel,
+ networkError: authNetworkErrorLabel,
};
@@ -168,6 +184,7 @@ const t = lang === "cs" ? csCommon : enCommon;
} from "../lib/turnstile";
let authDialogMode: AuthDialogMode = "signin";
+ let authDialogRevision = 0;
// Shared Turnstile widget for the auth dialog. Exposed on window so future
// features (e.g. profile page password change) can reuse the same helper.
@@ -183,19 +200,7 @@ const t = lang === "cs" ? csCommon : enCommon;
let turnstileRendered = false;
- /**
- * Render the Turnstile widget in `execution: "execute"` mode — the
- * widget sits dormant until we call `execute()` on submit. This gives
- * us a fresh, per-submission token:
- *
- * - It can't go stale between dialog open and click.
- * - Password manager fill events that happen BEFORE submit don't poison
- * a token that was already issued, because no token exists yet.
- * - If Cloudflare wants an interactive challenge for this session, the
- * widget surfaces a visible checkbox on execute (appearance =
- * "interaction-only" keeps it hidden when the invisible challenge
- * passes, visible only when Cloudflare asks for one).
- */
+ // Execute on submit so the token cannot expire while the user fills the form.
function renderAuthTurnstile() {
if (!turnstileEnabled()) return;
const force = shouldForceInteractive(AUTH_FAIL_KEY) || shouldForceInteractive(AUTH_SIGNUP_KEY, 1, SIGNUP_WINDOW_MS);
@@ -206,16 +211,11 @@ const t = lang === "cs" ? csCommon : enCommon;
turnstileRendered = true;
}
- /**
- * Make sure the widget exists before we try to pull a token from it.
- * Covers the case where the user clicks Sign In before the deferred
- * render in openAuthDialog has fired (slow network, cached script still
- * resolving, etc.).
- */
- async function ensureTurnstileRendered() {
- if (!turnstileEnabled() || turnstileRendered) return;
- await whenTurnstileReady();
+ async function ensureTurnstileRendered(revision: number): Promise {
+ if (turnstileRendered) return true;
+ if (!(await whenTurnstileReady()) || revision !== authDialogRevision) return false;
if (!turnstileRendered) renderAuthTurnstile();
+ return true;
}
async function openAuthDialog(mode: AuthDialogMode = "signin") {
@@ -223,11 +223,11 @@ const t = lang === "cs" ? csCommon : enCommon;
if (!supabase) return;
authDialogMode = mode;
updateAuthDialogMode();
+ setSubmitBusy(authSubmitInFlight);
(document.getElementById("auth-dialog") as HTMLDialogElement)?.showModal();
if (turnstileEnabled()) {
- whenTurnstileReady().then(() => {
- if (!turnstileRendered) renderAuthTurnstile();
- });
+ // Submission retries and reports any preload failure.
+ void ensureTurnstileRendered(authDialogRevision).catch(() => {});
}
}
@@ -277,110 +277,135 @@ const t = lang === "cs" ? csCommon : enCommon;
return `${window.location.origin}/auth/callback?next=${encodeURIComponent(next)}`;
}
- function setSubmitBusy(busy: boolean) {
+ function setSubmitBusy(busy: boolean, message?: string) {
const submit = document.getElementById("auth-dialog-submit") as HTMLButtonElement | null;
if (!submit) return;
const labels = (window as any).__authDialogLabels;
submit.disabled = busy;
+ submit.setAttribute("aria-busy", String(busy));
+ for (const id of ["auth-email", "auth-password", "auth-dialog-switch", "auth-dialog-forgot", "auth-dialog-google"]) {
+ const control = document.getElementById(id) as HTMLInputElement | HTMLButtonElement | null;
+ if (control) control.disabled = busy;
+ }
submit.classList.toggle("opacity-60", busy);
submit.classList.toggle("cursor-not-allowed", busy);
if (busy) {
- submit.dataset.prevText = submit.textContent || "";
- submit.textContent = labels?.verifying || "Verifying…";
+ submit.dataset.prevText ??= submit.textContent || "";
+ submit.textContent = message ?? labels.verifying;
} else if (submit.dataset.prevText) {
submit.textContent = submit.dataset.prevText;
delete submit.dataset.prevText;
}
}
- /**
- * Ask Turnstile for a fresh token right now. With the widget rendered
- * in `execution: "execute"` mode, each call starts a new challenge —
- * the resulting token is issued AFTER any password-manager fill
- * events, so it reflects the current session state and can't be stale
- * by the time it reaches Supabase. If Cloudflare wants a visible
- * interaction, the widget surfaces the checkbox and waitForToken
- * keeps waiting until the user completes it.
- *
- * Returns "" only if the widget isn't enabled or something goes
- * catastrophically wrong (Turnstile script never loaded, hostname
- * blocked, 15s elapsed with no response). Callers should treat "" as
- * "submit without a token and let the server return the real error".
- */
- async function requestFreshCaptchaToken(): Promise {
+ async function requestFreshCaptchaToken(revision: number): Promise {
if (!turnstileEnabled()) return "";
- await ensureTurnstileRendered();
+ if (!(await ensureTurnstileRendered(revision))) return "";
authTurnstile.execute();
return authTurnstile.waitForToken(15000);
}
+ // Supabase saves the session before returning, so closing the dialog must not unlock another submission.
let authSubmitInFlight = false;
+ function showAuthError(message: string) {
+ const errorEl = document.getElementById("auth-dialog-error");
+ if (errorEl) {
+ errorEl.textContent = message;
+ errorEl.className = "text-error text-sm font-body";
+ }
+ }
+
async function authDialogSubmit() {
if (authSubmitInFlight) return;
+ const emailInput = document.getElementById("auth-email") as HTMLInputElement;
+ const passwordInput = document.getElementById("auth-password") as HTMLInputElement;
+ if (!emailInput.reportValidity()) return;
+ if (authDialogMode !== "reset" && !passwordInput.reportValidity()) return;
+ const revision = authDialogRevision;
+ const focusedControl = document.activeElement;
authSubmitInFlight = true;
+ setSubmitBusy(true);
try {
- await authDialogSubmitInner();
+ await authDialogSubmitInner(revision);
+ } catch (error) {
+ window.observability?.captureException(error, { operation: "auth-dialog-submit" });
+ if (revision === authDialogRevision) showAuthError((window as any).__authDialogLabels.serviceUnavailable);
} finally {
authSubmitInFlight = false;
+ setSubmitBusy(false);
+ if (revision === authDialogRevision) {
+ authTurnstile.remove();
+ turnstileRendered = false;
+ if (authDialogElement?.open && document.activeElement === document.body && focusedControl instanceof HTMLElement) {
+ focusedControl.focus();
+ }
+ }
}
}
- async function authDialogSubmitInner() {
+ async function authDialogSubmitInner(revision: number) {
const supabase = await getSupabaseClient();
- if (!supabase) return;
+ if (revision !== authDialogRevision) return;
+ const labels = (window as any).__authDialogLabels;
+ if (!supabase) {
+ showAuthError(labels.serviceUnavailable);
+ return;
+ }
const email = (document.getElementById("auth-email") as HTMLInputElement)?.value;
const password = (document.getElementById("auth-password") as HTMLInputElement)?.value;
const errorEl = document.getElementById("auth-dialog-error");
if (errorEl) errorEl.classList.add("hidden");
- // Flip to busy immediately — we're about to trigger the captcha
- // challenge and then a network round-trip. If the challenge
- // escalates to a visible checkbox, the button stays in "Verifying…"
- // until the user completes it, which is the correct signal that the
- // sign-in is paused on them.
- setSubmitBusy(true);
- const captchaToken = await requestFreshCaptchaToken();
- const authOptions: any = captchaToken ? { captchaToken } : {};
+ const mode = authDialogMode;
+ const captchaToken = await requestFreshCaptchaToken(revision);
+ if (revision !== authDialogRevision) return;
+ if (turnstileEnabled() && !captchaToken) {
+ showAuthError(labels.captchaUnavailable);
+ return;
+ }
+ const authOptions = captchaToken ? { captchaToken } : {};
+ const submittingLabels: Record = {
+ signin: labels.signingIn,
+ signup: labels.creatingAccount,
+ reset: labels.sendingResetLink,
+ };
+ setSubmitBusy(true, submittingLabels[mode]);
- const finishFailure = (message: string) => {
- setSubmitBusy(false);
- recordFailure(AUTH_FAIL_KEY);
- if (errorEl) {
- errorEl.textContent = message;
- errorEl.className = "text-error text-sm font-body";
- }
- if (turnstileEnabled()) {
- authTurnstile.reset();
- // Device-wide escalation: after repeated failures across
- // sessions we force the visible widget on next dialog open too.
- if (shouldForceInteractive(AUTH_FAIL_KEY)) renderAuthTurnstile();
+ const finishFailure = (error: { message: string; status?: number }) => {
+ if (error.status && error.status >= 500) {
+ showAuthError(mode === "reset" ? labels.resetUnavailable : labels.serviceUnavailable);
+ } else if (!error.status) {
+ showAuthError(labels.networkError);
+ } else {
+ if (mode !== "reset" && error.status !== 429) recordFailure(AUTH_FAIL_KEY);
+ showAuthError(error.message);
}
};
- if (authDialogMode === "reset") {
+ if (mode === "reset") {
const { error: resetError } = await supabase.auth.resetPasswordForEmail(email, {
...authOptions,
redirectTo: passwordResetRedirectUrl(),
});
+ if (revision !== authDialogRevision) return;
if (resetError) {
- finishFailure(resetError.message);
+ finishFailure(resetError);
} else {
- setSubmitBusy(false);
(document.getElementById("auth-dialog") as HTMLDialogElement)?.close();
const labels = (window as any).__authDialogLabels;
(window as any).__showSnackbar?.(labels.resetLinkSent, "info", 8000);
}
- } else if (authDialogMode === "signup") {
+ } else if (mode === "signup") {
const { data, error: signUpError } = await supabase.auth.signUp({
email,
password,
options: { ...authOptions, data: { full_name: email.split("@")[0] } },
});
+ if (revision !== authDialogRevision) return;
if (signUpError) {
- finishFailure(signUpError.message);
+ finishFailure(signUpError);
} else {
- setSubmitBusy(false);
recordSuccess(AUTH_FAIL_KEY);
// Count the signup itself — more than 1 in 4h escalates next attempt.
recordFailure(AUTH_SIGNUP_KEY, SIGNUP_WINDOW_MS);
@@ -398,10 +423,10 @@ const t = lang === "cs" ? csCommon : enCommon;
password,
options: authOptions,
});
+ if (revision !== authDialogRevision) return;
if (signInError) {
- finishFailure(signInError.message);
+ finishFailure(signInError);
} else {
- setSubmitBusy(false);
recordSuccess(AUTH_FAIL_KEY);
(document.getElementById("auth-dialog") as HTMLDialogElement)?.close();
}
@@ -444,6 +469,12 @@ const t = lang === "cs" ? csCommon : enCommon;
// Clicks on the backdrop and on the dialog's own padding both target the , so hit-test
// the rect; the press must also start outside so a text-selection drag can't close it.
const authDialogElement = document.getElementById("auth-dialog") as HTMLDialogElement | null;
+ authDialogElement?.addEventListener("close", () => {
+ authDialogRevision++;
+ setSubmitBusy(false);
+ authTurnstile.remove();
+ turnstileRendered = false;
+ });
const outsideAuthDialog = (event: MouseEvent) => {
if (!authDialogElement) return false;
const rect = authDialogElement.getBoundingClientRect();
diff --git a/frontend/src/i18n/cs/common.json b/frontend/src/i18n/cs/common.json
index 36005af..c3b4e01 100644
--- a/frontend/src/i18n/cs/common.json
+++ b/frontend/src/i18n/cs/common.json
@@ -33,6 +33,11 @@
"confirmationSent": "Zkontrolujte svůj e-mail a potvrďte svůj účet.",
"signingIn": "Dokončuji přihlášení",
"verifying": "Ověřování…",
+ "creatingAccount": "Vytváření účtu…",
+ "sendingResetLink": "Odesílání odkazu…",
+ "captchaUnavailable": "Bezpečnostní ověření se nepodařilo dokončit. Zkuste to znovu. Pokud problém trvá, zkontrolujte, zda prohlížeč neblokuje Cloudflare Turnstile.",
+ "serviceUnavailable": "Služba účtů je dočasně nedostupná. Zkuste to prosím později.",
+ "resetUnavailable": "Nepodařilo se potvrdit odeslání e-mailu pro obnovení hesla. Zkontrolujte schránku a zkuste to později.",
"callbackError": "Přihlášení se nepodařilo dokončit. Zkuste to prosím znovu.",
"forgotPassword": "Zapomněli jste heslo?",
"resetPassword": "Obnovení hesla",
diff --git a/frontend/src/i18n/en/common.json b/frontend/src/i18n/en/common.json
index cb0981c..05a326c 100644
--- a/frontend/src/i18n/en/common.json
+++ b/frontend/src/i18n/en/common.json
@@ -33,6 +33,11 @@
"confirmationSent": "Check your email to confirm your account.",
"signingIn": "Finishing sign-in",
"verifying": "Verifying…",
+ "creatingAccount": "Creating account…",
+ "sendingResetLink": "Sending reset link…",
+ "captchaUnavailable": "The security check could not finish. Please try again. If it keeps failing, check whether your browser blocks Cloudflare Turnstile.",
+ "serviceUnavailable": "The account service is temporarily unavailable. Please try again later.",
+ "resetUnavailable": "We couldn’t confirm that the reset email was sent. Check your inbox, then try again later.",
"callbackError": "Sign-in could not be completed. Please try again.",
"forgotPassword": "Forgot password?",
"resetPassword": "Reset Password",
diff --git a/frontend/src/lib/observability.ts b/frontend/src/lib/observability.ts
index 7791521..43a03e9 100644
--- a/frontend/src/lib/observability.ts
+++ b/frontend/src/lib/observability.ts
@@ -24,12 +24,21 @@ const environment = import.meta.env.PUBLIC_SENTRY_ENVIRONMENT || import.meta.env
// chain off this promise so anything fired before the SDK arrives still lands. Pulling only the
// functions we use off the module by name (rather than the whole `Sentry` namespace) is what lets the
// bundler tree-shake Session Replay (rrweb) — the SDK's heaviest feature — out of the chunk entirely.
-type SentryApi = Pick;
+type SentryApi = Pick;
// Import eagerly (this module runs in ) rather than deferring, so init wins the race against the
// client:idle islands: their API calls are only traced, and early errors only captured, once init runs.
const sentryReady: Promise | null = sentryDsn
? import("@sentry/browser").then(
- ({ init, browserTracingIntegration, consoleLoggingIntegration, getCurrentScope, setUser, addBreadcrumb, logger }) => {
+ ({
+ init,
+ browserTracingIntegration,
+ consoleLoggingIntegration,
+ getCurrentScope,
+ setUser,
+ addBreadcrumb,
+ logger,
+ captureException,
+ }) => {
init({
dsn: sentryDsn,
environment,
@@ -80,7 +89,7 @@ const sentryReady: Promise | null = sentryDsn
"url.query": window.location.search,
"http.referer": document.referrer,
});
- return { setUser, addBreadcrumb, logger };
+ return { setUser, addBreadcrumb, logger, captureException };
},
)
: null;
diff --git a/frontend/src/lib/supabase.ts b/frontend/src/lib/supabase.ts
index 336ac4f..b8e607d 100644
--- a/frontend/src/lib/supabase.ts
+++ b/frontend/src/lib/supabase.ts
@@ -17,6 +17,20 @@ export const SUPABASE_URL = import.meta.env.PUBLIC_SUPABASE_URL || "";
export const SUPABASE_PUBLISHABLE_KEY = import.meta.env.PUBLIC_SUPABASE_PUBLISHABLE_KEY || "";
let _clientPromise: Promise | undefined;
+const AUTH_REQUEST_TIMEOUT_MS = 30000;
+
+function fetchWithAuthTimeout(input: RequestInfo | URL, init?: RequestInit): Promise {
+ const url = input instanceof Request ? input.url : input.toString();
+ if (!url.startsWith(`${SUPABASE_URL.replace(/\/$/, "")}/auth/v1/`)) return fetch(input, init);
+
+ // Abort the transport so an expired sign-in cannot later persist a session.
+ const timeoutSignal = AbortSignal.timeout(AUTH_REQUEST_TIMEOUT_MS);
+ const callerSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
+ return fetch(input, {
+ ...init,
+ signal: callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal,
+ });
+}
/**
* Returns a shared browser-side Supabase client, or null if env vars are missing.
@@ -29,7 +43,9 @@ export function getSupabaseClient(): Promise {
_clientPromise = Promise.resolve(null);
return _clientPromise;
}
- _clientPromise = import("@supabase/supabase-js").then(({ createClient }) => createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY));
+ _clientPromise = import("@supabase/supabase-js").then(({ createClient }) =>
+ createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, { global: { fetch: fetchWithAuthTimeout } }),
+ );
return _clientPromise;
}
diff --git a/frontend/src/lib/turnstile.ts b/frontend/src/lib/turnstile.ts
index b48f3ee..a986b6c 100644
--- a/frontend/src/lib/turnstile.ts
+++ b/frontend/src/lib/turnstile.ts
@@ -47,29 +47,36 @@ export function turnstileEnabled(): boolean {
const TURNSTILE_SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js";
-// Load Cloudflare's Turnstile API on first need rather than on every page. Idempotent: bails if the
-// API is already present or a matching tag is already in the document (a page that needs the widget
-// at load, like hire-me, injects its own).
-function loadTurnstileScript(): void {
- if (!SITE_KEY || window.turnstile) return;
- if (document.querySelector(`script[src^="${TURNSTILE_SCRIPT_SRC}"]`)) return;
+// Reuse the script when another form on this page already loaded Turnstile.
+function loadTurnstileScript(): HTMLScriptElement {
+ const existing = document.querySelector(`script[src^="${TURNSTILE_SCRIPT_SRC}"]`);
+ if (existing) return existing;
const script = document.createElement("script");
- script.src = TURNSTILE_SCRIPT_SRC;
+ script.src = `${TURNSTILE_SCRIPT_SRC}?render=explicit`;
script.async = true;
script.defer = true;
document.head.appendChild(script);
+ return script;
}
-export function whenTurnstileReady(): Promise {
+export function whenTurnstileReady(): Promise {
+ if (window.turnstile) return Promise.resolve(true);
+ if (!SITE_KEY) return Promise.resolve(false);
return new Promise((resolve) => {
- if (window.turnstile) return resolve();
- loadTurnstileScript();
- const iv = setInterval(() => {
- if (window.turnstile) {
- clearInterval(iv);
- resolve();
- }
+ const script = loadTurnstileScript();
+ const finish = (ready: boolean) => {
+ clearInterval(interval);
+ clearTimeout(timeout);
+ script.removeEventListener("error", onError);
+ if (!ready) script.remove();
+ resolve(ready);
+ };
+ const onError = () => finish(false);
+ const interval = setInterval(() => {
+ if (window.turnstile) finish(true);
}, 100);
+ const timeout = setTimeout(() => finish(false), 10000);
+ script.addEventListener("error", onError, { once: true });
});
}
@@ -116,6 +123,7 @@ export function createTurnstile(selector: string): TurnstileWidget {
// because `getResponse` returning "" doesn't tell us whether the challenge
// is still running or has silently been invalidated.
let currentToken = "";
+ let challengeFailed = false;
let pendingResolvers: Array<(token: string) => void> = [];
const notifyToken = (token: string) => {
@@ -133,6 +141,7 @@ export function createTurnstile(selector: string): TurnstileWidget {
// stuck on "Verifying…" for 15s every time the site key is
// misconfigured.
const failPending = () => {
+ challengeFailed = true;
const resolvers = pendingResolvers;
pendingResolvers = [];
resolvers.forEach((r) => r(""));
@@ -150,6 +159,7 @@ export function createTurnstile(selector: string): TurnstileWidget {
widgetId = null;
}
currentToken = "";
+ challengeFailed = false;
widgetId = window.turnstile.render(selector, {
sitekey: SITE_KEY,
theme: "auto",
@@ -158,6 +168,7 @@ export function createTurnstile(selector: string): TurnstileWidget {
callback: (token: string) => notifyToken(token),
"expired-callback": () => {
currentToken = "";
+ failPending();
},
"error-callback": (errorCode?: string) => {
currentToken = "";
@@ -177,13 +188,13 @@ export function createTurnstile(selector: string): TurnstileWidget {
},
execute() {
if (widgetId === null || !window.turnstile) return;
- // Each execute() starts a fresh challenge, so invalidate any
- // previously cached token so waitForToken actually waits for the
- // new one rather than resolving instantly with the stale value.
currentToken = "";
+ challengeFailed = false;
try {
window.turnstile.execute(widgetId);
- } catch {}
+ } catch {
+ failPending();
+ }
},
getToken() {
if (currentToken) return currentToken;
@@ -192,27 +203,33 @@ export function createTurnstile(selector: string): TurnstileWidget {
},
waitForToken(timeoutMs = 10000) {
if (currentToken) return Promise.resolve(currentToken);
+ if (challengeFailed) return Promise.resolve("");
if (widgetId === null || !window.turnstile) return Promise.resolve("");
return new Promise((resolve) => {
let settled = false;
const done = (token: string) => {
if (settled) return;
settled = true;
+ clearTimeout(timeout);
+ pendingResolvers = pendingResolvers.filter((pending) => pending !== done);
resolve(token);
};
pendingResolvers.push(done);
- setTimeout(() => done(""), timeoutMs);
+ const timeout = setTimeout(() => done(""), timeoutMs);
});
},
reset() {
+ failPending();
if (widgetId !== null && window.turnstile) {
try {
window.turnstile.reset(widgetId);
} catch {}
}
currentToken = "";
+ challengeFailed = false;
},
remove() {
+ failPending();
if (widgetId !== null && window.turnstile) {
try {
window.turnstile.remove(widgetId);
@@ -220,7 +237,7 @@ export function createTurnstile(selector: string): TurnstileWidget {
widgetId = null;
}
currentToken = "";
- pendingResolvers = [];
+ challengeFailed = false;
},
};
}
diff --git a/frontend/tests/auth-dialog.spec.ts b/frontend/tests/auth-dialog.spec.ts
new file mode 100644
index 0000000..843c35b
--- /dev/null
+++ b/frontend/tests/auth-dialog.spec.ts
@@ -0,0 +1,359 @@
+import { test, expect, type Page, type Route } from "@playwright/test";
+
+import { mockTurnstile, turnstileScript } from "./helpers/turnstile";
+const recoveryEndpoint = /\/auth\/v1\/recover(?:\?|$)/;
+const resetUnavailable = "We couldn’t confirm that the reset email was sent. Check your inbox, then try again later.";
+
+function signInResponse(email: string) {
+ const accessToken = [
+ Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"),
+ Buffer.from(JSON.stringify({ sub: email, exp: Math.floor(Date.now() / 1000) + 3600 })).toString("base64url"),
+ "signature",
+ ].join(".");
+ return {
+ access_token: accessToken,
+ refresh_token: "test-refresh-token",
+ expires_in: 3600,
+ token_type: "bearer",
+ user: { id: email, email, user_metadata: {} },
+ };
+}
+
+async function installRequestTimeoutClock(page: Page) {
+ await page.clock.install();
+ await page.addInitScript(() => {
+ // Playwright's clock controls JavaScript timers, but not native AbortSignal timeouts.
+ AbortSignal.timeout = (milliseconds) => {
+ const controller = new AbortController();
+ setTimeout(() => controller.abort(new DOMException("The request timed out", "TimeoutError")), milliseconds);
+ return controller.signal;
+ };
+ });
+}
+
+async function openPasswordReset(page: Page, path = "/") {
+ await page.goto(path);
+ await page.locator("#auth-sign-in-desktop").click();
+ await page.locator("#auth-dialog-forgot").click();
+ await page.locator("#auth-email").fill("reset@example.com");
+}
+
+test("a recovery gateway timeout restores the form and a retry sends a fresh CAPTCHA token", async ({ page }) => {
+ await mockTurnstile(page);
+ const requests: Array<{ email: string; gotrue_meta_security: { captcha_token: string } }> = [];
+ let releaseResponse!: () => void;
+ const responseReady = new Promise((resolve) => (releaseResponse = resolve));
+ await page.route(recoveryEndpoint, async (route) => {
+ requests.push(route.request().postDataJSON());
+ if (requests.length === 1) {
+ await responseReady;
+ await route.fulfill({ status: 504, contentType: "text/plain", body: "Gateway timeout" });
+ } else {
+ await route.fulfill({ json: {} });
+ }
+ });
+
+ await openPasswordReset(page, "/about");
+ const submit = page.locator("#auth-dialog-submit");
+ await submit.click();
+ await expect(submit).toHaveText("Sending reset link…");
+ await expect(submit).toBeDisabled();
+ await expect(page.locator("#auth-dialog-switch")).toBeDisabled();
+ releaseResponse();
+
+ await expect(page.getByRole("alert")).toHaveText(resetUnavailable);
+ await expect(submit).toHaveText("Send Reset Link");
+ await expect(submit).toBeEnabled();
+ await expect(page.locator("#auth-turnstile-container")).toBeEmpty();
+ expect(await page.evaluate(() => localStorage.getItem("turnstileFail:auth-fail"))).toBeNull();
+
+ await submit.click();
+ await expect(page.locator("#auth-dialog")).toBeHidden();
+ await expect(page.getByText("a password reset link is on its way")).toBeVisible();
+ expect(requests.map((request) => request.gotrue_meta_security.captcha_token)).toEqual(["token-1", "token-2"]);
+ expect(requests.map((request) => request.email)).toEqual(["reset@example.com", "reset@example.com"]);
+});
+
+test("recovery service errors are localized", async ({ page }) => {
+ await mockTurnstile(page);
+ await page.route(recoveryEndpoint, (route) => route.fulfill({ status: 504, body: "Gateway timeout" }));
+ await openPasswordReset(page, "/cs/about");
+ await page.locator("#auth-dialog-submit").click();
+ await expect(page.getByRole("alert")).toHaveText(
+ "Nepodařilo se potvrdit odeslání e-mailu pro obnovení hesla. Zkontrolujte schránku a zkuste to později.",
+ );
+ await expect(page.locator("#auth-dialog-submit")).toHaveText("Poslat odkaz");
+});
+
+test("a failed CAPTCHA cannot submit a recovery request", async ({ page }) => {
+ await mockTurnstile(page, "error");
+ const pageErrors: string[] = [];
+ const recoveryRequests: string[] = [];
+ page.on("pageerror", (error) => pageErrors.push(error.message));
+ await page.route(recoveryEndpoint, (route) => {
+ recoveryRequests.push(route.request().url());
+ return route.fulfill({ json: {} });
+ });
+ await openPasswordReset(page);
+ await page.locator("#auth-dialog-submit").click();
+ await expect(page.getByRole("alert")).toContainText("The security check could not finish.");
+ await expect(page.locator("#auth-dialog-submit")).toBeEnabled();
+ expect(recoveryRequests).toEqual([]);
+ expect(pageErrors).toEqual([]);
+});
+
+test("a blocked CAPTCHA script can be loaded again on retry", async ({ page }) => {
+ await page.route(turnstileScript, (route) => route.abort());
+ await page.route(recoveryEndpoint, (route) => route.fulfill({ json: {} }));
+ await openPasswordReset(page);
+ await page.locator("#auth-dialog-submit").click();
+ await expect(page.getByRole("alert")).toContainText("The security check could not finish.");
+ await expect(page.locator("#auth-dialog-submit")).toBeEnabled();
+ await page.unroute(turnstileScript);
+ await mockTurnstile(page);
+ await page.locator("#auth-dialog-submit").click();
+ await expect(page.locator("#auth-dialog")).toBeHidden();
+});
+
+test("a CAPTCHA script that never initializes cannot leave the form busy", async ({ page }) => {
+ await page.clock.install();
+ await page.route(turnstileScript, (route) => route.fulfill({ contentType: "application/javascript", body: "" }));
+ await openPasswordReset(page);
+ await page.locator("#auth-dialog-submit").click();
+ await expect(page.locator('script[src*="challenges.cloudflare.com/turnstile"]')).toHaveCount(1);
+ await page.clock.fastForward(10001);
+ await expect(page.getByRole("alert")).toContainText("The security check could not finish.");
+ await expect(page.locator("#auth-dialog-submit")).toBeEnabled();
+});
+
+test("a CAPTCHA that never responds cannot submit without a token", async ({ page }) => {
+ await page.clock.install();
+ await mockTurnstile(page, "pending");
+ const recoveryRequests: string[] = [];
+ await page.route(recoveryEndpoint, (route) => {
+ recoveryRequests.push(route.request().url());
+ return route.fulfill({ json: {} });
+ });
+ await openPasswordReset(page);
+ await page.locator("#auth-dialog-submit").click();
+ await expect(page.locator("#auth-turnstile-container")).toHaveText("Challenge in progress");
+ await page.clock.fastForward(15001);
+ await expect(page.getByRole("alert")).toContainText("The security check could not finish.");
+ await expect(page.locator("#auth-dialog-submit")).toBeEnabled();
+ expect(recoveryRequests).toEqual([]);
+});
+
+test("a network failure restores the form without an unhandled exception", async ({ page }) => {
+ await mockTurnstile(page);
+ const pageErrors: string[] = [];
+ page.on("pageerror", (error) => pageErrors.push(error.message));
+ await page.route(recoveryEndpoint, (route) => route.abort());
+ await openPasswordReset(page);
+ await page.locator("#auth-dialog-submit").click();
+ await expect(page.getByRole("alert")).toContainText("Could not reach the server.");
+ await expect(page.locator("#auth-dialog-submit")).toBeEnabled();
+ expect(pageErrors).toEqual([]);
+});
+
+test("an unexpected auth exception is reported and restores the form", async ({ page }) => {
+ await mockTurnstile(page);
+ const pageErrors: string[] = [];
+ const reportedErrors: Array<{ name: string; message: string; context: unknown }> = [];
+ page.on("pageerror", (error) => pageErrors.push(error.message));
+ await page.exposeFunction("reportAuthError", (error: (typeof reportedErrors)[number]) => reportedErrors.push(error));
+ await openPasswordReset(page);
+ await page.evaluate(async () => {
+ window.observability!.captureException = (error, context) => {
+ const exception = error as Error;
+ (window as any).reportAuthError({ name: exception.name, message: exception.message, context });
+ };
+ const supabase = await (window as any).__supabaseReady;
+ supabase.auth.resetPasswordForEmail = () => Promise.reject(new TypeError("Unexpected auth failure"));
+ });
+ await page.locator("#auth-dialog-submit").click();
+ await expect(page.getByRole("alert")).toHaveText("The account service is temporarily unavailable. Please try again later.");
+ await expect(page.locator("#auth-dialog-submit")).toBeEnabled();
+ await expect
+ .poll(() => reportedErrors)
+ .toEqual([{ name: "TypeError", message: "Unexpected auth failure", context: { operation: "auth-dialog-submit" } }]);
+ expect(pageErrors).toEqual([]);
+});
+
+for (const firstResult of ["success", "failure"] as const) {
+ test(`reopening the dialog keeps sign-ins serialized until ${firstResult}`, async ({ page }) => {
+ await mockTurnstile(page);
+ const emails: string[] = [];
+ let releaseFirstResponse!: () => void;
+ const firstResponseReady = new Promise((resolve) => (releaseFirstResponse = resolve));
+ await page.route(/\/auth\/v1\/token\?/, async (route) => {
+ const { email } = route.request().postDataJSON();
+ emails.push(email);
+ if (emails.length === 1) {
+ await firstResponseReady;
+ if (firstResult === "failure") {
+ await route.fulfill({ status: 503, body: "Service unavailable" });
+ return;
+ }
+ }
+ await route.fulfill({ json: signInResponse(email) });
+ });
+
+ await page.goto("/");
+ await page.locator("#auth-sign-in-desktop").click();
+ await page.locator("#auth-email").fill("first@example.com");
+ await page.locator("#auth-password").fill("password-123");
+ await page.locator("#auth-dialog-submit").click();
+ await expect.poll(() => emails).toEqual(["first@example.com"]);
+ await page.locator("#auth-dialog-close").click();
+ await page.locator("#auth-sign-in-desktop").click();
+
+ for (const id of [
+ "auth-dialog-submit",
+ "auth-email",
+ "auth-password",
+ "auth-dialog-switch",
+ "auth-dialog-forgot",
+ "auth-dialog-google",
+ ]) {
+ await expect(page.locator(`#${id}`)).toBeDisabled();
+ }
+ await page.locator("#auth-password").dispatchEvent("keydown", { key: "Enter" });
+ expect(emails).toEqual(["first@example.com"]);
+
+ releaseFirstResponse();
+ await expect(page.locator("#auth-dialog-submit")).toBeEnabled();
+ await expect(page.locator("#auth-dialog-submit")).toHaveText("Sign In");
+ await expect(page.locator("#auth-dialog")).toBeVisible();
+ await expect(page.getByRole("alert")).toBeHidden();
+ await page.locator("#auth-email").fill("second@example.com");
+ await page.locator("#auth-dialog-submit").click();
+ await expect(page.locator("#auth-dialog")).toBeHidden();
+ await expect.poll(() => page.evaluate(async () => (await (window as any).__getUser())?.email)).toBe("second@example.com");
+ expect(emails).toEqual(["first@example.com", "second@example.com"]);
+ });
+}
+
+for (const mode of ["signin", "signup", "reset"] as const) {
+ test(`a stalled ${mode} request is aborted before a reopened dialog accepts another sign-in`, async ({ page }) => {
+ await installRequestTimeoutClock(page);
+ await mockTurnstile(page);
+ const authEndpoint = /\/auth\/v1\/(token|signup|recover)(?:\?|$)/;
+ let stalledRequest: Route | undefined;
+ await page.route(authEndpoint, async (route) => {
+ if (!stalledRequest) {
+ stalledRequest = route;
+ return;
+ }
+ await route.fulfill({ json: signInResponse(route.request().postDataJSON().email) });
+ });
+
+ await page.goto("/");
+ await page.locator("#auth-sign-in-desktop").click();
+ if (mode === "signup") await page.locator("#auth-dialog-switch").click();
+ if (mode === "reset") await page.locator("#auth-dialog-forgot").click();
+ await page.locator("#auth-email").fill("first@example.com");
+ if (mode !== "reset") await page.locator("#auth-password").fill("password-123");
+ await page.locator("#auth-dialog-submit").click();
+ await expect.poll(() => !!stalledRequest).toBe(true);
+ await page.locator("#auth-dialog-close").click();
+ await page.locator("#auth-sign-in-desktop").click();
+ await expect(page.locator("#auth-dialog-google")).toBeDisabled();
+
+ const aborted = page.waitForEvent("requestfailed", (request) => authEndpoint.test(request.url()));
+ await page.clock.fastForward(30001);
+ expect((await aborted).failure()?.errorText).toContain("ERR_ABORTED");
+ await expect(page.locator("#auth-dialog-submit")).toBeEnabled();
+ await expect(page.locator("#auth-dialog-google")).toBeEnabled();
+ await expect(page.getByRole("alert")).toBeHidden();
+
+ await page.locator("#auth-email").fill("second@example.com");
+ await page.locator("#auth-password").fill("password-456");
+ await page.locator("#auth-dialog-submit").click();
+ await expect(page.locator("#auth-dialog")).toBeHidden();
+ await stalledRequest!.fulfill({ json: signInResponse("first@example.com") });
+ await expect.poll(() => page.evaluate(async () => (await (window as any).__getUser())?.email)).toBe("second@example.com");
+ });
+}
+
+for (const control of ["auth-email", "auth-password", "auth-dialog-submit", "auth-dialog-close"]) {
+ test(`a failed sign-in preserves keyboard focus on ${control}`, async ({ page }) => {
+ await mockTurnstile(page);
+ let pendingRequest: Route | undefined;
+ await page.route(/\/auth\/v1\/token\?/, (route) => {
+ pendingRequest = route;
+ });
+ await page.goto("/");
+ await page.locator("#auth-sign-in-desktop").click();
+ await page.locator("#auth-email").fill("reset@example.com");
+ await page.locator("#auth-password").fill("wrong-password");
+ const submitControl = control === "auth-dialog-close" ? "auth-password" : control;
+ await page.locator(`#${submitControl}`).press("Enter");
+ await expect.poll(() => !!pendingRequest).toBe(true);
+ await expect(page.locator("body")).toBeFocused();
+ if (control === "auth-dialog-close") await page.locator("#auth-dialog-close").focus();
+
+ await pendingRequest!.fulfill({ status: 400, json: { code: "invalid_credentials", message: "Invalid login credentials" } });
+ await expect(page.getByRole("alert")).toHaveText("Invalid login credentials");
+ await expect(page.locator(`#${control}`)).toBeFocused();
+ });
+}
+
+test("closing a pending CAPTCHA releases the submission guard", async ({ page }) => {
+ await mockTurnstile(page, "pending");
+ await openPasswordReset(page);
+ await page.locator("#auth-dialog-submit").click();
+ await expect(page.locator("#auth-turnstile-container")).toHaveText("Challenge in progress");
+ await page.locator("#auth-dialog-close").click();
+ await page.locator("#auth-sign-in-desktop").click();
+ await expect(page.locator("#auth-dialog-submit")).toBeEnabled();
+ await expect(page.locator("#auth-dialog-submit")).toHaveText("Sign In");
+});
+
+test("invalid sign-in credentials still count as a failed auth attempt", async ({ page }) => {
+ await mockTurnstile(page);
+ await page.route(/\/auth\/v1\/token\?/, (route) =>
+ route.fulfill({ status: 400, json: { code: "invalid_credentials", message: "Invalid login credentials" } }),
+ );
+ await page.goto("/");
+ await page.locator("#auth-sign-in-desktop").click();
+ await page.locator("#auth-email").fill("reset@example.com");
+ await page.locator("#auth-password").fill("wrong-password");
+ await page.locator("#auth-dialog-submit").click();
+ await expect(page.getByRole("alert")).toHaveText("Invalid login credentials");
+ await expect(page.locator("#auth-dialog-submit")).toBeEnabled();
+ expect(await page.evaluate(() => JSON.parse(localStorage.getItem("turnstileFail:auth-fail")!).count)).toBe(1);
+});
+
+test("an invalid email never starts CAPTCHA verification", async ({ page }) => {
+ await mockTurnstile(page);
+ const preload = page.waitForResponse(turnstileScript);
+ await openPasswordReset(page);
+ await preload;
+ await page.locator("#auth-email").fill("not-an-email");
+ await page.locator("#auth-dialog-submit").click();
+ await expect(page.locator("#auth-email")).toBeFocused();
+ await expect(page.locator("#auth-dialog-submit")).toBeEnabled();
+ await expect(page.locator("#auth-turnstile-container")).toBeEmpty();
+});
+
+test("a response from a closed dialog cannot close a newly opened dialog", async ({ page }) => {
+ await mockTurnstile(page);
+ let releaseResponse!: () => void;
+ const responseReady = new Promise((resolve) => (releaseResponse = resolve));
+ await page.route(recoveryEndpoint, async (route) => {
+ await responseReady;
+ await route.fulfill({ json: {} });
+ });
+ await openPasswordReset(page);
+ await page.locator("#auth-dialog-submit").click();
+ await expect(page.locator("#auth-dialog-submit")).toHaveText("Sending reset link…");
+ await page.locator("#auth-dialog-close").click();
+ await page.locator("#auth-sign-in-desktop").click();
+ await expect(page.locator("#auth-dialog-title")).toHaveText("Sign In");
+ const completed = page.waitForResponse(recoveryEndpoint);
+ releaseResponse();
+ await completed;
+ await expect(page.locator("#auth-dialog")).toBeVisible();
+ await expect(page.locator("#auth-dialog-submit")).toBeEnabled();
+ await expect(page.locator("#auth-dialog-submit")).toHaveText("Sign In");
+});
diff --git a/frontend/tests/e2e/password-reset-flow.spec.ts b/frontend/tests/e2e/password-reset-flow.spec.ts
index 97191fb..92529c2 100644
--- a/frontend/tests/e2e/password-reset-flow.spec.ts
+++ b/frontend/tests/e2e/password-reset-flow.spec.ts
@@ -1,4 +1,5 @@
import { test, expect, type APIRequestContext } from "@playwright/test";
+import { mockTurnstile } from "../helpers/turnstile";
/**
* E2E test for the forgot-password flow against local Supabase:
@@ -48,6 +49,10 @@ async function waitForResetLink(request: APIRequestContext, to: string): Promise
}
test.describe("Password Reset Flow", () => {
+ test.beforeEach(async ({ page }) => {
+ await mockTurnstile(page);
+ });
+
test.beforeAll(async ({ request }) => {
for (const user of [resetUser, usedLinkUser]) {
const response = await request.post(`${SUPABASE_URL}/auth/v1/admin/users`, {
@@ -74,7 +79,6 @@ test.describe("Password Reset Flow", () => {
await dialog.locator("#auth-email").fill(resetUser.email);
await dialog.getByRole("button", { name: "Send Reset Link" }).click();
- // Submitting waits on a Turnstile challenge first, which the dialog allows up to 15s.
await expect(dialog).toBeHidden({ timeout: 20000 });
await expect(page.getByText("a password reset link is on its way")).toBeVisible();
diff --git a/frontend/tests/helpers/turnstile.ts b/frontend/tests/helpers/turnstile.ts
new file mode 100644
index 0000000..2487e64
--- /dev/null
+++ b/frontend/tests/helpers/turnstile.ts
@@ -0,0 +1,34 @@
+import type { Page } from "@playwright/test";
+
+export const turnstileScript = "https://challenges.cloudflare.com/turnstile/v0/api.js*";
+
+export async function mockTurnstile(page: Page, outcome: "success" | "error" | "pending" = "success") {
+ await page.route(turnstileScript, (route) =>
+ route.fulfill({
+ contentType: "application/javascript",
+ body: `
+ const widgets = new Map();
+ let sequence = 0;
+ window.turnstile = {
+ render(selector, options) {
+ const id = String(widgets.size);
+ widgets.set(id, { selector, options });
+ return id;
+ },
+ execute(id) {
+ const { selector, options } = widgets.get(id);
+ document.querySelector(selector).textContent = "Challenge in progress";
+ if (${JSON.stringify(outcome)} === "success") options.callback("token-" + ++sequence);
+ if (${JSON.stringify(outcome)} === "error") options["error-callback"]("200500");
+ },
+ getResponse() { return ""; },
+ reset() {},
+ remove(id) {
+ document.querySelector(widgets.get(id).selector).textContent = "";
+ widgets.delete(id);
+ }
+ };
+ `,
+ }),
+ );
+}