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
4 changes: 4 additions & 0 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ In **Authentication → URL Configuration**:
- `https://kalandra.tech/**`
- `http://localhost:4321/**` (for local development)

#### Configure Auth Emails

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.

#### Create Storage Bucket

1. In Supabase dashboard: **Storage → New bucket**
Expand Down
2 changes: 2 additions & 0 deletions docs/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ Supabase Auth with email/password + Google OAuth. Client-side auth state is mana

The sign-in dialog (`AuthDialog.astro`) is included in the layout — pages don't need to add it.

**Password reset.** The dialog's "Forgot password?" mode emails a Supabase recovery link. Like OAuth, the link lands on `/auth/callback` — the only page that accepts session tokens in the URL — which forwards to `/reset-password`. That page offers the new-password form only to sessions opened from an emailed link (`amr` of `otp` / `magiclink` / `recovery`), the same sessions Supabase lets set a password without the current one; a password or Google session changes its password on the profile page instead.

## Blog

Posts are first-class Astro pages, not a CMS — git is the source of truth.
Expand Down
131 changes: 97 additions & 34 deletions frontend/src/components/AuthDialog.astro
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
---
/**
* Sign-in / sign-up dialog with Turnstile protection and Google OAuth.
* Sign-in / sign-up / password-reset dialog with Turnstile protection and Google OAuth.
*
* Self-contained component: renders the <dialog> markup and owns all the
* dialog-specific JS (mode switching, Turnstile lifecycle, submit handlers).
* Exposes `window.__openAuthDialog()` so the header sign-in buttons (in
* Exposes `window.__openAuthDialog(mode?)` so the header sign-in buttons (in
* Layout.astro) can trigger it without knowing about internals.
*
* Supabase client is a shared singleton from lib/supabase.ts so both this
* component and Layout.astro use the same instance.
*/
import { Icon } from "astro-icon/components";
import IconGoogle from "./icons/IconGoogle.astro";
import type { Locale } from "../i18n/utils";
import { localePath, type Locale } from "../i18n/utils";
import enCommon from "../i18n/en/common.json";
import csCommon from "../i18n/cs/common.json";

Expand All @@ -26,6 +26,8 @@ const t = lang === "cs" ? csCommon : enCommon;

<dialog
id="auth-dialog"
data-reset-password-path={localePath(lang, "reset-password")}
data-home-path={localePath(lang, "home")}
class="m-auto bg-surface-container-low rounded-2xl border border-outline-variant/10 p-8 max-w-sm w-full backdrop:bg-black/50"
>
<button
Expand All @@ -42,6 +44,9 @@ const t = lang === "cs" ? csCommon : enCommon;
<h2 id="auth-dialog-title" class="font-headline text-xl font-bold text-on-surface">
{t.auth.signIn}
</h2>
<p id="auth-dialog-description" class="hidden mt-2 text-sm font-body text-on-surface-variant">
{t.auth.resetPasswordDescription}
</p>
</div>

<!-- Email / password form -->
Expand All @@ -58,7 +63,7 @@ const t = lang === "cs" ? csCommon : enCommon;
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"
/>
</div>
<div>
<div id="auth-password-field">
<label for="auth-password" class="block text-sm font-label font-semibold text-on-surface mb-1.5">
{t.auth.password}
</label>
Expand All @@ -70,6 +75,15 @@ const t = lang === "cs" ? csCommon : enCommon;
minlength="6"
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"
/>
<div class="mt-1.5 text-right">
<button
type="button"
id="auth-dialog-forgot"
class="text-xs font-label font-semibold text-primary hover:underline cursor-pointer"
>
{t.auth.forgotPassword}
</button>
</div>
</div>
</div>

Expand All @@ -92,22 +106,24 @@ const t = lang === "cs" ? csCommon : enCommon;
</button>
</p>

<!-- Divider -->
<div class="flex items-center gap-3">
<div class="flex-1 border-t border-outline-variant/20"></div>
<span class="text-xs font-label text-on-surface-variant">{t.auth.or}</span>
<div class="flex-1 border-t border-outline-variant/20"></div>
</div>
<div id="auth-dialog-alternatives" class="space-y-5">
<!-- Divider -->
<div class="flex items-center gap-3">
<div class="flex-1 border-t border-outline-variant/20"></div>
<span class="text-xs font-label text-on-surface-variant">{t.auth.or}</span>
<div class="flex-1 border-t border-outline-variant/20"></div>
</div>

<!-- Google OAuth -->
<button
type="button"
id="auth-dialog-google"
class="w-full flex items-center justify-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold font-label bg-surface-container-high text-on-surface hover:bg-surface-container-highest transition-colors cursor-pointer"
>
<IconGoogle class="w-5 h-5" />
{t.auth.continueWithGoogle}
</button>
<!-- Google OAuth -->
<button
type="button"
id="auth-dialog-google"
class="w-full flex items-center justify-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold font-label bg-surface-container-high text-on-surface hover:bg-surface-container-highest transition-colors cursor-pointer"
>
<IconGoogle class="w-5 h-5" />
{t.auth.continueWithGoogle}
</button>
</div>
</div>
</dialog>

Expand All @@ -119,6 +135,10 @@ const t = lang === "cs" ? csCommon : enCommon;
authHaveAccountLabel: t.auth.haveAccount,
authConfirmationSentLabel: t.auth.confirmationSent,
authVerifyingLabel: t.auth.verifying,
authResetPasswordLabel: t.auth.resetPassword,
authSendResetLinkLabel: t.auth.sendResetLink,
authRememberedPasswordLabel: t.auth.rememberedPassword,
authResetLinkSentLabel: t.auth.resetLinkSent,
}}
>
window.__authDialogLabels = {
Expand All @@ -128,10 +148,15 @@ const t = lang === "cs" ? csCommon : enCommon;
haveAccount: authHaveAccountLabel,
confirmationSent: authConfirmationSentLabel,
verifying: authVerifyingLabel,
resetPassword: authResetPasswordLabel,
sendResetLink: authSendResetLinkLabel,
rememberedPassword: authRememberedPasswordLabel,
resetLinkSent: authResetLinkSentLabel,
};
</script>

<script>
import type { AuthDialogMode } from "../lib/auth";
import { getSupabaseClient } from "../lib/supabase";
import {
createTurnstile,
Expand All @@ -142,7 +167,7 @@ const t = lang === "cs" ? csCommon : enCommon;
recordSuccess,
} from "../lib/turnstile";

let authDialogMode: "signin" | "signup" = "signin";
let authDialogMode: AuthDialogMode = "signin";

// 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 Down Expand Up @@ -193,10 +218,10 @@ const t = lang === "cs" ? csCommon : enCommon;
if (!turnstileRendered) renderAuthTurnstile();
}

async function openAuthDialog() {
async function openAuthDialog(mode: AuthDialogMode = "signin") {
const supabase = await getSupabaseClient();
if (!supabase) return;
authDialogMode = "signin";
authDialogMode = mode;
updateAuthDialogMode();
(document.getElementById("auth-dialog") as HTMLDialogElement)?.showModal();
if (turnstileEnabled()) {
Expand All @@ -219,17 +244,37 @@ const t = lang === "cs" ? csCommon : enCommon;
errorEl.className = "hidden text-error text-sm font-body";
}

if (authDialogMode === "signin") {
if (title) title.textContent = labels.signIn;
if (submit) submit.textContent = labels.signIn;
if (toggle) toggle.childNodes[0].textContent = labels.noAccount + " ";
if (switchBtn) switchBtn.textContent = labels.signUp;
} else {
if (title) title.textContent = labels.signUp;
if (submit) submit.textContent = labels.signUp;
if (toggle) toggle.childNodes[0].textContent = labels.haveAccount + " ";
if (switchBtn) switchBtn.textContent = labels.signIn;
}
const modeLabels: Record<AuthDialogMode, { title: string; submit: string; togglePrompt: string; toggleAction: string }> = {
signin: { title: labels.signIn, submit: labels.signIn, togglePrompt: labels.noAccount, toggleAction: labels.signUp },
signup: { title: labels.signUp, submit: labels.signUp, togglePrompt: labels.haveAccount, toggleAction: labels.signIn },
reset: {
title: labels.resetPassword,
submit: labels.sendResetLink,
togglePrompt: labels.rememberedPassword,
toggleAction: labels.signIn,
},
};
const current = modeLabels[authDialogMode];
if (title) title.textContent = current.title;
if (submit) submit.textContent = current.submit;
if (toggle) toggle.childNodes[0].textContent = current.togglePrompt + " ";
if (switchBtn) switchBtn.textContent = current.toggleAction;

const isReset = authDialogMode === "reset";
document.getElementById("auth-dialog-description")?.classList.toggle("hidden", !isReset);
document.getElementById("auth-password-field")?.classList.toggle("hidden", isReset);
document.getElementById("auth-dialog-forgot")?.classList.toggle("hidden", authDialogMode !== "signin");
document.getElementById("auth-dialog-alternatives")?.classList.toggle("hidden", isReset);
}

// Reset links go through /auth/callback, the only page allowed to receive session tokens in the URL.
function passwordResetRedirectUrl(): string {
const { resetPasswordPath, homePath } = (document.getElementById("auth-dialog") as HTMLDialogElement).dataset;
const here = new URL(window.location.href);
// Asking for a fresh link from the reset page itself keeps that page's original destination.
const returnTo = here.pathname === resetPasswordPath ? (here.searchParams.get("next") ?? homePath!) : here.pathname + here.search;
const next = `${resetPasswordPath}?next=${encodeURIComponent(returnTo)}`;
return `${window.location.origin}/auth/callback?next=${encodeURIComponent(next)}`;
}

function setSubmitBusy(busy: boolean) {
Expand Down Expand Up @@ -313,7 +358,20 @@ const t = lang === "cs" ? csCommon : enCommon;
}
};

if (authDialogMode === "signup") {
if (authDialogMode === "reset") {
const { error: resetError } = await supabase.auth.resetPasswordForEmail(email, {
...authOptions,
redirectTo: passwordResetRedirectUrl(),
});
if (resetError) {
finishFailure(resetError.message);
} 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") {
const { data, error: signUpError } = await supabase.auth.signUp({
email,
password,
Expand Down Expand Up @@ -374,6 +432,11 @@ const t = lang === "cs" ? csCommon : enCommon;
authDialogMode = authDialogMode === "signin" ? "signup" : "signin";
updateAuthDialogMode();
});
document.getElementById("auth-dialog-forgot")?.addEventListener("click", () => {
authDialogMode = "reset";
updateAuthDialogMode();
document.getElementById("auth-email")?.focus();
});
document.getElementById("auth-dialog-close")?.addEventListener("click", () => {
(document.getElementById("auth-dialog") as HTMLDialogElement)?.close();
});
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/i18n/cs/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@
"confirmationSent": "Zkontrolujte svůj e-mail a potvrďte svůj účet.",
"signingIn": "Dokončuji přihlášení",
"verifying": "Ověřování…",
"callbackError": "Přihlášení se nepodařilo dokončit. Zkuste to prosím znovu."
"callbackError": "Přihlášení se nepodařilo dokončit. Zkuste to prosím znovu.",
"forgotPassword": "Zapomněli jste heslo?",
"resetPassword": "Obnovení hesla",
"resetPasswordDescription": "Zadejte e-mail, se kterým jste se registrovali, a pošleme vám odkaz pro nastavení nového hesla.",
"sendResetLink": "Poslat odkaz",
"rememberedPassword": "Vzpomněli jste si?",
"resetLinkSent": "Pokud k tomuto e-mailu existuje účet, posíláme na něj odkaz pro obnovení hesla. Zkontrolujte svou schránku."
},
"theme": {
"label": "Motiv",
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/i18n/cs/reset-password.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"meta": {
"title": "Obnovení hesla — kalandra.tech",
"description": "Nastavte si nové heslo ke svému účtu na kalandra.tech."
},
"hero": {
"title": "Obnovení hesla",
"subtitle": "Nastavte si nové heslo ke svému účtu."
},
"form": {
"account": "Účet",
"newPassword": "Nové heslo",
"newPasswordPlaceholder": "Min. 6 znaků",
"confirmPassword": "Potvrzení hesla",
"confirmPasswordPlaceholder": "Zopakujte nové heslo",
"save": "Nastavit nové heslo",
"saving": "Ukládání…",
"success": "Heslo bylo změněno a jste přihlášeni.",
"tooShort": "Heslo musí mít alespoň 6 znaků.",
"mismatch": "Hesla se neshodují."
},
"invalidLink": {
"title": "Odkaz je neplatný nebo vypršel",
"description": "Odkaz pro obnovení hesla funguje jen jednou a platí hodinu. Vyžádejte si nový a pošleme vám ho e-mailem.",
"requestNewLink": "Poslat nový odkaz"
}
}
8 changes: 7 additions & 1 deletion frontend/src/i18n/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@
"confirmationSent": "Check your email to confirm your account.",
"signingIn": "Finishing sign-in",
"verifying": "Verifying…",
"callbackError": "Sign-in could not be completed. Please try again."
"callbackError": "Sign-in could not be completed. Please try again.",
"forgotPassword": "Forgot password?",
"resetPassword": "Reset Password",
"resetPasswordDescription": "Enter the email you signed up with and we'll send you a link to choose a new password.",
"sendResetLink": "Send Reset Link",
"rememberedPassword": "Remembered it?",
"resetLinkSent": "If an account exists for that email, a password reset link is on its way. Check your inbox."
},
"theme": {
"label": "Theme",
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/i18n/en/reset-password.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"meta": {
"title": "Reset Password — kalandra.tech",
"description": "Choose a new password for your kalandra.tech account."
},
"hero": {
"title": "Reset Password",
"subtitle": "Choose a new password for your account."
},
"form": {
"account": "Account",
"newPassword": "New Password",
"newPasswordPlaceholder": "Min. 6 characters",
"confirmPassword": "Confirm Password",
"confirmPasswordPlaceholder": "Repeat new password",
"save": "Set New Password",
"saving": "Saving…",
"success": "Your password has been changed and you're signed in.",
"tooShort": "Password must be at least 6 characters.",
"mismatch": "Passwords do not match."
},
"invalidLink": {
"title": "This link is invalid or has expired",
"description": "Password reset links work only once and expire after an hour. Request a new one and we'll email it to you.",
"requestNewLink": "Send a New Link"
}
}
1 change: 1 addition & 0 deletions frontend/src/layouts/Layout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface Props {
| "admin/job-offers"
| "admin/test-errors"
| "profile"
| "reset-password"
| "privacy"
| "terms"
| "oauth-consent";
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,16 @@ export async function getCurrentUser(): Promise<any | null> {
return ((await (window as any).__getUser?.()) as any | null) ?? null;
}

export type AuthDialogMode = "signin" | "signup" | "reset";

export function openAuthDialog(): void {
(window as any).__openAuthDialog?.();
}

export function openPasswordResetDialog(): void {
(window as any).__openAuthDialog?.("reset" satisfies AuthDialogMode);
}

export function userHasAdminRole(user: any): boolean {
return Array.isArray(user?.app_metadata?.roles) && user.app_metadata.roles.includes("admin");
}
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/lib/redirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Parsed rather than prefix-checked: browsers resolve `/\evil.com` to https://evil.com.
export function sameOriginPath(target: string | null, fallback: string): string {
if (!target) return fallback;
try {
const url = new URL(target, window.location.origin);
return url.origin === window.location.origin ? url.pathname + url.search : fallback;
} catch {
return fallback;
}
}
Loading
Loading