diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts index 8baa342..f1012dd 100644 --- a/apps/web/src/lib/auth-client.ts +++ b/apps/web/src/lib/auth-client.ts @@ -1,3 +1,3 @@ import { createAuthClient } from "better-auth/react"; -export const authClient = createAuthClient({}); +export const authClient = createAuthClient(); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 84f9305..fb95264 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -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, diff --git a/apps/web/src/routes/forgot-password.tsx b/apps/web/src/routes/forgot-password.tsx new file mode 100644 index 0000000..9bfc714 --- /dev/null +++ b/apps/web/src/routes/forgot-password.tsx @@ -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 ( +
+ + + + Wachtwoord vergeten + + + {sent + ? "Controleer je inbox" + : "Vul je e-mailadres in en we sturen je een link"} + + + + {sent ? ( +
+
+

+ Als er een account bestaat voor{" "} + {email}, is er + een e-mail verstuurd met een link om je wachtwoord opnieuw in + te stellen. De link is 1 uur geldig. +

+
+ + ← Terug naar inloggen + +
+ ) : ( +
+
+ + setEmail(e.target.value)} + required + className="border-white/20 bg-white/10 text-white placeholder:text-white/40" + /> +
+ {mutation.isError && ( +

{mutation.error.message}

+ )} + +
+ + ← Terug naar inloggen + +
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index b37b2fd..508cf4d 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -299,6 +299,16 @@ function LoginPage() { className="border-white/20 bg-white/10 text-white placeholder:text-white/40" /> + {!isSignup && ( +
+ + Wachtwoord vergeten? + +
+ )} + + + + + ); +} diff --git a/bun.lock b/bun.lock index df32358..c3d43b6 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/auth/package.json b/packages/auth/package.json index 47c19eb..c289ac3 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -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:" } } diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index f67299d..9d8ed4b 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -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 "; + +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 = ` + + + + + Wachtwoord opnieuw instellen + + + + + + +
+ + + + + + + + + + +
+

Kunstenkamp

+

Wachtwoord opnieuw instellen

+
+

+ We hebben een aanvraag ontvangen om het wachtwoord van je Kunstenkamp-account opnieuw in te stellen. +

+

+ Klik op de knop hieronder om een nieuw wachtwoord in te stellen. Deze link is 1 uur geldig. +

+ + + + +
+ + Stel wachtwoord in + +
+

+ Of kopieer deze link: ${resetUrl} +

+

+ Heb je dit niet aangevraagd? Dan hoef je niets te doen — je wachtwoord blijft ongewijzigd. +

+
+

+ Vragen? Mail ons op info@kunstenkamp.be
+ Kunstenkamp vzw — Een initiatief voor en door kunstenaars. +

+
+
+ +`; + 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: {