Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
183 changes: 107 additions & 76 deletions frontend/src/components/AuthDialog.astro
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ const t = lang === "cs" ? csCommon : enCommon;
<input
type="email"
id="auth-email"
autocomplete="email"
aria-describedby="auth-dialog-error"
placeholder={t.auth.emailPlaceholder}
required
class="w-full px-4 py-3 rounded-xl bg-surface-container border border-outline-variant/20 text-on-surface font-body text-sm placeholder:text-on-surface-variant/50 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
Expand Down Expand Up @@ -89,7 +91,7 @@ const t = lang === "cs" ? csCommon : enCommon;

<div id="auth-turnstile-container" class="flex justify-center empty:hidden"></div>

<p id="auth-dialog-error" class="hidden text-error text-sm font-body"></p>
<p id="auth-dialog-error" role="alert" class="hidden text-error text-sm font-body"></p>

<button
type="button"
Expand Down Expand Up @@ -139,6 +141,13 @@ const t = lang === "cs" ? csCommon : enCommon;
authSendResetLinkLabel: t.auth.sendResetLink,
authRememberedPasswordLabel: t.auth.rememberedPassword,
authResetLinkSentLabel: t.auth.resetLinkSent,
authSigningInLabel: t.auth.signingIn,
authCreatingAccountLabel: t.auth.creatingAccount,
authSendingResetLinkLabel: t.auth.sendingResetLink,
authCaptchaUnavailableLabel: t.auth.captchaUnavailable,
authServiceUnavailableLabel: t.auth.serviceUnavailable,
authResetUnavailableLabel: t.auth.resetUnavailable,
authNetworkErrorLabel: t.errors.networkError,
}}
>
window.__authDialogLabels = {
Expand All @@ -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,
};
</script>

Expand All @@ -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.
Expand All @@ -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);
Expand All @@ -206,28 +211,23 @@ 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<boolean> {
if (turnstileRendered) return true;
if (!(await whenTurnstileReady()) || revision !== authDialogRevision) return false;
if (!turnstileRendered) renderAuthTurnstile();
return true;
}

async function openAuthDialog(mode: AuthDialogMode = "signin") {
const supabase = await getSupabaseClient();
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(() => {});
}
}

Expand Down Expand Up @@ -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<string> {
async function requestFreshCaptchaToken(revision: number): Promise<string> {
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<AuthDialogMode, string> = {
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);
Expand All @@ -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();
}
Expand Down Expand Up @@ -444,6 +469,12 @@ const t = lang === "cs" ? csCommon : enCommon;
// Clicks on the backdrop and on the dialog's own padding both target the <dialog>, 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();
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/i18n/cs/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/i18n/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 12 additions & 3 deletions frontend/src/lib/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("@sentry/browser"), "setUser" | "addBreadcrumb" | "logger">;
type SentryApi = Pick<typeof import("@sentry/browser"), "setUser" | "addBreadcrumb" | "logger" | "captureException">;
// Import eagerly (this module runs in <head>) 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<SentryApi> | 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,
Expand Down Expand Up @@ -80,7 +89,7 @@ const sentryReady: Promise<SentryApi> | null = sentryDsn
"url.query": window.location.search,
"http.referer": document.referrer,
});
return { setUser, addBreadcrumb, logger };
return { setUser, addBreadcrumb, logger, captureException };
},
)
: null;
Expand Down
Loading
Loading