feat: add password reset flow

- Add /forgot-password and /reset-password routes (Dutch UI)
- Wire sendResetPassword into emailAndPassword config (not a plugin)
- Send styled HTML email via nodemailer with 1-hour expiry
- Add 'Wachtwoord vergeten?' link on login page
- Update routeTree.gen.ts with new routes
This commit is contained in:
2026-03-11 09:43:32 +01:00
parent 439bbc5545
commit dfc8ace186
8 changed files with 424 additions and 1 deletions

View File

@@ -1,3 +1,3 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({});
export const authClient = createAuthClient();

View File

@@ -10,8 +10,10 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as TermsRouteImport } from './routes/terms'
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
import { Route as PrivacyRouteImport } from './routes/privacy'
import { Route as LoginRouteImport } from './routes/login'
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
import { Route as DrinkkaartRouteImport } from './routes/drinkkaart'
import { Route as ContactRouteImport } from './routes/contact'
import { Route as AccountRouteImport } from './routes/account'
@@ -29,6 +31,11 @@ const TermsRoute = TermsRouteImport.update({
path: '/terms',
getParentRoute: () => rootRouteImport,
} as any)
const ResetPasswordRoute = ResetPasswordRouteImport.update({
id: '/reset-password',
path: '/reset-password',
getParentRoute: () => rootRouteImport,
} as any)
const PrivacyRoute = PrivacyRouteImport.update({
id: '/privacy',
path: '/privacy',
@@ -39,6 +46,11 @@ const LoginRoute = LoginRouteImport.update({
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
id: '/forgot-password',
path: '/forgot-password',
getParentRoute: () => rootRouteImport,
} as any)
const DrinkkaartRoute = DrinkkaartRouteImport.update({
id: '/drinkkaart',
path: '/drinkkaart',
@@ -100,8 +112,10 @@ export interface FileRoutesByFullPath {
'/account': typeof AccountRoute
'/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute
'/forgot-password': typeof ForgotPasswordRoute
'/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/reset-password': typeof ResetPasswordRoute
'/terms': typeof TermsRoute
'/admin/drinkkaart': typeof AdminDrinkkaartRoute
'/manage/$token': typeof ManageTokenRoute
@@ -116,8 +130,10 @@ export interface FileRoutesByTo {
'/account': typeof AccountRoute
'/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute
'/forgot-password': typeof ForgotPasswordRoute
'/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/reset-password': typeof ResetPasswordRoute
'/terms': typeof TermsRoute
'/admin/drinkkaart': typeof AdminDrinkkaartRoute
'/manage/$token': typeof ManageTokenRoute
@@ -133,8 +149,10 @@ export interface FileRoutesById {
'/account': typeof AccountRoute
'/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute
'/forgot-password': typeof ForgotPasswordRoute
'/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/reset-password': typeof ResetPasswordRoute
'/terms': typeof TermsRoute
'/admin/drinkkaart': typeof AdminDrinkkaartRoute
'/manage/$token': typeof ManageTokenRoute
@@ -151,8 +169,10 @@ export interface FileRouteTypes {
| '/account'
| '/contact'
| '/drinkkaart'
| '/forgot-password'
| '/login'
| '/privacy'
| '/reset-password'
| '/terms'
| '/admin/drinkkaart'
| '/manage/$token'
@@ -167,8 +187,10 @@ export interface FileRouteTypes {
| '/account'
| '/contact'
| '/drinkkaart'
| '/forgot-password'
| '/login'
| '/privacy'
| '/reset-password'
| '/terms'
| '/admin/drinkkaart'
| '/manage/$token'
@@ -183,8 +205,10 @@ export interface FileRouteTypes {
| '/account'
| '/contact'
| '/drinkkaart'
| '/forgot-password'
| '/login'
| '/privacy'
| '/reset-password'
| '/terms'
| '/admin/drinkkaart'
| '/manage/$token'
@@ -200,8 +224,10 @@ export interface RootRouteChildren {
AccountRoute: typeof AccountRoute
ContactRoute: typeof ContactRoute
DrinkkaartRoute: typeof DrinkkaartRoute
ForgotPasswordRoute: typeof ForgotPasswordRoute
LoginRoute: typeof LoginRoute
PrivacyRoute: typeof PrivacyRoute
ResetPasswordRoute: typeof ResetPasswordRoute
TermsRoute: typeof TermsRoute
AdminDrinkkaartRoute: typeof AdminDrinkkaartRoute
ManageTokenRoute: typeof ManageTokenRoute
@@ -221,6 +247,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof TermsRouteImport
parentRoute: typeof rootRouteImport
}
'/reset-password': {
id: '/reset-password'
path: '/reset-password'
fullPath: '/reset-password'
preLoaderRoute: typeof ResetPasswordRouteImport
parentRoute: typeof rootRouteImport
}
'/privacy': {
id: '/privacy'
path: '/privacy'
@@ -235,6 +268,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/forgot-password': {
id: '/forgot-password'
path: '/forgot-password'
fullPath: '/forgot-password'
preLoaderRoute: typeof ForgotPasswordRouteImport
parentRoute: typeof rootRouteImport
}
'/drinkkaart': {
id: '/drinkkaart'
path: '/drinkkaart'
@@ -320,8 +360,10 @@ const rootRouteChildren: RootRouteChildren = {
AccountRoute: AccountRoute,
ContactRoute: ContactRoute,
DrinkkaartRoute: DrinkkaartRoute,
ForgotPasswordRoute: ForgotPasswordRoute,
LoginRoute: LoginRoute,
PrivacyRoute: PrivacyRoute,
ResetPasswordRoute: ResetPasswordRoute,
TermsRoute: TermsRoute,
AdminDrinkkaartRoute: AdminDrinkkaartRoute,
ManageTokenRoute: ManageTokenRoute,

View File

@@ -0,0 +1,118 @@
import { useMutation } from "@tanstack/react-query";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
export const Route = createFileRoute("/forgot-password")({
component: ForgotPasswordPage,
});
function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const mutation = useMutation({
mutationFn: async (email: string) => {
const result = await authClient.requestPasswordReset({
email,
redirectTo: "/reset-password",
});
if (result.error) {
throw new Error(result.error.message);
}
return result.data;
},
onSuccess: () => {
setSent(true);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate(email);
};
return (
<div className="flex min-h-[calc(100dvh-2.75rem)] items-center justify-center overflow-y-auto px-4 py-8 sm:min-h-[calc(100dvh-3.5rem)]">
<Card className="my-auto w-full max-w-md border-white/10 bg-white/5">
<CardHeader className="text-center">
<CardTitle className="font-['Intro',sans-serif] text-3xl text-white">
Wachtwoord vergeten
</CardTitle>
<CardDescription className="text-white/60">
{sent
? "Controleer je inbox"
: "Vul je e-mailadres in en we sturen je een link"}
</CardDescription>
</CardHeader>
<CardContent>
{sent ? (
<div className="space-y-4">
<div className="rounded-lg border border-white/10 bg-white/5 p-5 text-center">
<p className="text-sm text-white/80 leading-relaxed">
Als er een account bestaat voor{" "}
<span className="font-medium text-white">{email}</span>, is er
een e-mail verstuurd met een link om je wachtwoord opnieuw in
te stellen. De link is 1 uur geldig.
</p>
</div>
<Link
to="/login"
className="block text-center text-sm text-white/60 hover:text-white"
>
Terug naar inloggen
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="email"
className="mb-2 block text-sm text-white/60"
>
E-mailadres
</label>
<Input
id="email"
type="email"
placeholder="jouw@email.be"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
/>
</div>
{mutation.isError && (
<p className="text-red-400 text-sm">{mutation.error.message}</p>
)}
<Button
type="submit"
disabled={mutation.isPending}
className="w-full bg-white text-[#214e51] hover:bg-white/90"
>
{mutation.isPending ? "Bezig..." : "Stuur reset-link"}
</Button>
<div className="text-center">
<Link
to="/login"
className="text-sm text-white/60 hover:text-white"
>
Terug naar inloggen
</Link>
</div>
</form>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -299,6 +299,16 @@ function LoginPage() {
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
/>
</div>
{!isSignup && (
<div className="text-right">
<Link
to="/forgot-password"
className="text-sm text-white/50 hover:text-white"
>
Wachtwoord vergeten?
</Link>
</div>
)}
<Button
type="submit"
disabled={loginMutation.isPending || signupMutation.isPending}

View File

@@ -0,0 +1,154 @@
import { useMutation } from "@tanstack/react-query";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
export const Route = createFileRoute("/reset-password")({
validateSearch: (search: Record<string, unknown>) => {
const token = typeof search.token === "string" ? search.token : undefined;
return { ...(token !== undefined && { token }) } as { token?: string };
},
component: ResetPasswordPage,
});
function ResetPasswordPage() {
const navigate = useNavigate();
const search = Route.useSearch();
const token = search.token;
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const mutation = useMutation({
mutationFn: async ({
token,
newPassword,
}: {
token: string;
newPassword: string;
}) => {
const result = await authClient.resetPassword({ token, newPassword });
if (result.error) {
throw new Error(result.error.message);
}
return result.data;
},
onSuccess: () => {
toast.success("Wachtwoord succesvol gewijzigd!");
navigate({ to: "/login" });
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirm) {
toast.error("De wachtwoorden komen niet overeen.");
return;
}
if (!token) {
toast.error("Ongeldige reset-link. Vraag een nieuwe aan.");
return;
}
mutation.mutate({ token, newPassword: password });
};
if (!token) {
return (
<div className="flex min-h-[calc(100dvh-2.75rem)] items-center justify-center px-4 sm:min-h-[calc(100dvh-3.5rem)]">
<Card className="w-full max-w-md border-white/10 bg-white/5">
<CardHeader className="text-center">
<CardTitle className="font-['Intro',sans-serif] text-3xl text-white">
Ongeldige link
</CardTitle>
<CardDescription className="text-white/60">
Deze reset-link is ongeldig of verlopen.
</CardDescription>
</CardHeader>
<CardContent className="text-center">
<Link
to="/forgot-password"
className="text-sm text-white/60 hover:text-white"
>
Vraag een nieuwe reset-link aan
</Link>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-[calc(100dvh-2.75rem)] items-center justify-center overflow-y-auto px-4 py-8 sm:min-h-[calc(100dvh-3.5rem)]">
<Card className="my-auto w-full max-w-md border-white/10 bg-white/5">
<CardHeader className="text-center">
<CardTitle className="font-['Intro',sans-serif] text-3xl text-white">
Nieuw wachtwoord
</CardTitle>
<CardDescription className="text-white/60">
Kies een nieuw wachtwoord voor je account
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="password"
className="mb-2 block text-sm text-white/60"
>
Nieuw wachtwoord
</label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
/>
</div>
<div>
<label
htmlFor="confirm"
className="mb-2 block text-sm text-white/60"
>
Bevestig wachtwoord
</label>
<Input
id="confirm"
type="password"
placeholder="••••••••"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
minLength={8}
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
/>
</div>
{mutation.isError && (
<p className="text-red-400 text-sm">{mutation.error.message}</p>
)}
<Button
type="submit"
disabled={mutation.isPending}
className="w-full bg-white text-[#214e51] hover:bg-white/90"
>
{mutation.isPending ? "Bezig..." : "Stel wachtwoord in"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -111,10 +111,12 @@
"@kk/env": "workspace:*",
"better-auth": "catalog:",
"dotenv": "catalog:",
"nodemailer": "^8.0.2",
"zod": "catalog:",
},
"devDependencies": {
"@kk/config": "workspace:*",
"@types/nodemailer": "^7.0.11",
"typescript": "catalog:",
},
},
@@ -1985,6 +1987,8 @@
"@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@kk/auth/nodemailer": ["nodemailer@8.0.2", "", {}, "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw=="],
"@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="],

View File

@@ -15,10 +15,12 @@
"@kk/env": "workspace:*",
"better-auth": "catalog:",
"dotenv": "catalog:",
"nodemailer": "^8.0.2",
"zod": "catalog:"
},
"devDependencies": {
"@kk/config": "workspace:*",
"@types/nodemailer": "^7.0.11",
"typescript": "catalog:"
}
}

View File

@@ -5,6 +5,96 @@ import { env } from "@kk/env/server";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { tanstackStartCookies } from "better-auth/tanstack-start";
import nodemailer from "nodemailer";
const _smtpFrom = env.SMTP_FROM ?? "Kunstenkamp <info@kunstenkamp.be>";
let _transport: nodemailer.Transporter | null | undefined;
function getTransport(): nodemailer.Transporter | null {
if (_transport !== undefined) return _transport;
if (!env.SMTP_HOST || !env.SMTP_USER || !env.SMTP_PASS) {
_transport = null;
return null;
}
_transport = nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_PORT === 465,
auth: { user: env.SMTP_USER, pass: env.SMTP_PASS },
});
return _transport;
}
async function sendPasswordResetEmail(to: string, resetUrl: string) {
const transport = getTransport();
if (!transport) {
console.warn("SMTP not configured — skipping password reset email");
return;
}
const html = `<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wachtwoord opnieuw instellen</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<tr>
<td style="padding:40px 48px 32px;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Wachtwoord opnieuw instellen</h1>
</td>
</tr>
<tr>
<td style="padding:0 48px 32px;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
We hebben een aanvraag ontvangen om het wachtwoord van je Kunstenkamp-account opnieuw in te stellen.
</p>
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Klik op de knop hieronder om een nieuw wachtwoord in te stellen. Deze link is <strong style="color:#ffffff;">1 uur geldig</strong>.
</p>
<table cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius:2px;background:#ffffff;">
<a href="${resetUrl}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:#214e51;text-decoration:none;">
Stel wachtwoord in
</a>
</td>
</tr>
</table>
<p style="margin:16px 0 0;font-size:13px;color:rgba(255,255,255,0.4);">
Of kopieer deze link: <span style="color:rgba(255,255,255,0.6);">${resetUrl}</span>
</p>
<p style="margin:24px 0 0;font-size:14px;color:rgba(255,255,255,0.5);line-height:1.6;">
Heb je dit niet aangevraagd? Dan hoef je niets te doen — je wachtwoord blijft ongewijzigd.
</p>
</td>
</tr>
<tr>
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);line-height:1.6;">
Vragen? Mail ons op <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a><br/>
Kunstenkamp vzw — Een initiatief voor en door kunstenaars.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
await transport.sendMail({
from: _smtpFrom,
to,
subject: "Wachtwoord opnieuw instellen — Kunstenkamp",
html,
});
}
export const auth = betterAuth({
database: drizzleAdapter(db, {
@@ -35,6 +125,9 @@ export const auth = betterAuth({
return keyBuffer.equals(hashBuffer);
},
},
sendResetPassword: async ({ user, url }) => {
await sendPasswordResetEmail(user.email, url);
},
},
user: {
additionalFields: {