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:
@@ -1,3 +1,3 @@
|
|||||||
import { createAuthClient } from "better-auth/react";
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
export const authClient = createAuthClient({});
|
export const authClient = createAuthClient();
|
||||||
|
|||||||
@@ -10,8 +10,10 @@
|
|||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as TermsRouteImport } from './routes/terms'
|
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 PrivacyRouteImport } from './routes/privacy'
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
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 DrinkkaartRouteImport } from './routes/drinkkaart'
|
||||||
import { Route as ContactRouteImport } from './routes/contact'
|
import { Route as ContactRouteImport } from './routes/contact'
|
||||||
import { Route as AccountRouteImport } from './routes/account'
|
import { Route as AccountRouteImport } from './routes/account'
|
||||||
@@ -29,6 +31,11 @@ const TermsRoute = TermsRouteImport.update({
|
|||||||
path: '/terms',
|
path: '/terms',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ResetPasswordRoute = ResetPasswordRouteImport.update({
|
||||||
|
id: '/reset-password',
|
||||||
|
path: '/reset-password',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const PrivacyRoute = PrivacyRouteImport.update({
|
const PrivacyRoute = PrivacyRouteImport.update({
|
||||||
id: '/privacy',
|
id: '/privacy',
|
||||||
path: '/privacy',
|
path: '/privacy',
|
||||||
@@ -39,6 +46,11 @@ const LoginRoute = LoginRouteImport.update({
|
|||||||
path: '/login',
|
path: '/login',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
|
||||||
|
id: '/forgot-password',
|
||||||
|
path: '/forgot-password',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const DrinkkaartRoute = DrinkkaartRouteImport.update({
|
const DrinkkaartRoute = DrinkkaartRouteImport.update({
|
||||||
id: '/drinkkaart',
|
id: '/drinkkaart',
|
||||||
path: '/drinkkaart',
|
path: '/drinkkaart',
|
||||||
@@ -100,8 +112,10 @@ export interface FileRoutesByFullPath {
|
|||||||
'/account': typeof AccountRoute
|
'/account': typeof AccountRoute
|
||||||
'/contact': typeof ContactRoute
|
'/contact': typeof ContactRoute
|
||||||
'/drinkkaart': typeof DrinkkaartRoute
|
'/drinkkaart': typeof DrinkkaartRoute
|
||||||
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/privacy': typeof PrivacyRoute
|
'/privacy': typeof PrivacyRoute
|
||||||
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/terms': typeof TermsRoute
|
'/terms': typeof TermsRoute
|
||||||
'/admin/drinkkaart': typeof AdminDrinkkaartRoute
|
'/admin/drinkkaart': typeof AdminDrinkkaartRoute
|
||||||
'/manage/$token': typeof ManageTokenRoute
|
'/manage/$token': typeof ManageTokenRoute
|
||||||
@@ -116,8 +130,10 @@ export interface FileRoutesByTo {
|
|||||||
'/account': typeof AccountRoute
|
'/account': typeof AccountRoute
|
||||||
'/contact': typeof ContactRoute
|
'/contact': typeof ContactRoute
|
||||||
'/drinkkaart': typeof DrinkkaartRoute
|
'/drinkkaart': typeof DrinkkaartRoute
|
||||||
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/privacy': typeof PrivacyRoute
|
'/privacy': typeof PrivacyRoute
|
||||||
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/terms': typeof TermsRoute
|
'/terms': typeof TermsRoute
|
||||||
'/admin/drinkkaart': typeof AdminDrinkkaartRoute
|
'/admin/drinkkaart': typeof AdminDrinkkaartRoute
|
||||||
'/manage/$token': typeof ManageTokenRoute
|
'/manage/$token': typeof ManageTokenRoute
|
||||||
@@ -133,8 +149,10 @@ export interface FileRoutesById {
|
|||||||
'/account': typeof AccountRoute
|
'/account': typeof AccountRoute
|
||||||
'/contact': typeof ContactRoute
|
'/contact': typeof ContactRoute
|
||||||
'/drinkkaart': typeof DrinkkaartRoute
|
'/drinkkaart': typeof DrinkkaartRoute
|
||||||
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/privacy': typeof PrivacyRoute
|
'/privacy': typeof PrivacyRoute
|
||||||
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/terms': typeof TermsRoute
|
'/terms': typeof TermsRoute
|
||||||
'/admin/drinkkaart': typeof AdminDrinkkaartRoute
|
'/admin/drinkkaart': typeof AdminDrinkkaartRoute
|
||||||
'/manage/$token': typeof ManageTokenRoute
|
'/manage/$token': typeof ManageTokenRoute
|
||||||
@@ -151,8 +169,10 @@ export interface FileRouteTypes {
|
|||||||
| '/account'
|
| '/account'
|
||||||
| '/contact'
|
| '/contact'
|
||||||
| '/drinkkaart'
|
| '/drinkkaart'
|
||||||
|
| '/forgot-password'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/privacy'
|
| '/privacy'
|
||||||
|
| '/reset-password'
|
||||||
| '/terms'
|
| '/terms'
|
||||||
| '/admin/drinkkaart'
|
| '/admin/drinkkaart'
|
||||||
| '/manage/$token'
|
| '/manage/$token'
|
||||||
@@ -167,8 +187,10 @@ export interface FileRouteTypes {
|
|||||||
| '/account'
|
| '/account'
|
||||||
| '/contact'
|
| '/contact'
|
||||||
| '/drinkkaart'
|
| '/drinkkaart'
|
||||||
|
| '/forgot-password'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/privacy'
|
| '/privacy'
|
||||||
|
| '/reset-password'
|
||||||
| '/terms'
|
| '/terms'
|
||||||
| '/admin/drinkkaart'
|
| '/admin/drinkkaart'
|
||||||
| '/manage/$token'
|
| '/manage/$token'
|
||||||
@@ -183,8 +205,10 @@ export interface FileRouteTypes {
|
|||||||
| '/account'
|
| '/account'
|
||||||
| '/contact'
|
| '/contact'
|
||||||
| '/drinkkaart'
|
| '/drinkkaart'
|
||||||
|
| '/forgot-password'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/privacy'
|
| '/privacy'
|
||||||
|
| '/reset-password'
|
||||||
| '/terms'
|
| '/terms'
|
||||||
| '/admin/drinkkaart'
|
| '/admin/drinkkaart'
|
||||||
| '/manage/$token'
|
| '/manage/$token'
|
||||||
@@ -200,8 +224,10 @@ export interface RootRouteChildren {
|
|||||||
AccountRoute: typeof AccountRoute
|
AccountRoute: typeof AccountRoute
|
||||||
ContactRoute: typeof ContactRoute
|
ContactRoute: typeof ContactRoute
|
||||||
DrinkkaartRoute: typeof DrinkkaartRoute
|
DrinkkaartRoute: typeof DrinkkaartRoute
|
||||||
|
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
PrivacyRoute: typeof PrivacyRoute
|
PrivacyRoute: typeof PrivacyRoute
|
||||||
|
ResetPasswordRoute: typeof ResetPasswordRoute
|
||||||
TermsRoute: typeof TermsRoute
|
TermsRoute: typeof TermsRoute
|
||||||
AdminDrinkkaartRoute: typeof AdminDrinkkaartRoute
|
AdminDrinkkaartRoute: typeof AdminDrinkkaartRoute
|
||||||
ManageTokenRoute: typeof ManageTokenRoute
|
ManageTokenRoute: typeof ManageTokenRoute
|
||||||
@@ -221,6 +247,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof TermsRouteImport
|
preLoaderRoute: typeof TermsRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/reset-password': {
|
||||||
|
id: '/reset-password'
|
||||||
|
path: '/reset-password'
|
||||||
|
fullPath: '/reset-password'
|
||||||
|
preLoaderRoute: typeof ResetPasswordRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/privacy': {
|
'/privacy': {
|
||||||
id: '/privacy'
|
id: '/privacy'
|
||||||
path: '/privacy'
|
path: '/privacy'
|
||||||
@@ -235,6 +268,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LoginRouteImport
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/forgot-password': {
|
||||||
|
id: '/forgot-password'
|
||||||
|
path: '/forgot-password'
|
||||||
|
fullPath: '/forgot-password'
|
||||||
|
preLoaderRoute: typeof ForgotPasswordRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/drinkkaart': {
|
'/drinkkaart': {
|
||||||
id: '/drinkkaart'
|
id: '/drinkkaart'
|
||||||
path: '/drinkkaart'
|
path: '/drinkkaart'
|
||||||
@@ -320,8 +360,10 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
AccountRoute: AccountRoute,
|
AccountRoute: AccountRoute,
|
||||||
ContactRoute: ContactRoute,
|
ContactRoute: ContactRoute,
|
||||||
DrinkkaartRoute: DrinkkaartRoute,
|
DrinkkaartRoute: DrinkkaartRoute,
|
||||||
|
ForgotPasswordRoute: ForgotPasswordRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
PrivacyRoute: PrivacyRoute,
|
PrivacyRoute: PrivacyRoute,
|
||||||
|
ResetPasswordRoute: ResetPasswordRoute,
|
||||||
TermsRoute: TermsRoute,
|
TermsRoute: TermsRoute,
|
||||||
AdminDrinkkaartRoute: AdminDrinkkaartRoute,
|
AdminDrinkkaartRoute: AdminDrinkkaartRoute,
|
||||||
ManageTokenRoute: ManageTokenRoute,
|
ManageTokenRoute: ManageTokenRoute,
|
||||||
|
|||||||
118
apps/web/src/routes/forgot-password.tsx
Normal file
118
apps/web/src/routes/forgot-password.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -299,6 +299,16 @@ function LoginPage() {
|
|||||||
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
|
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{!isSignup && (
|
||||||
|
<div className="text-right">
|
||||||
|
<Link
|
||||||
|
to="/forgot-password"
|
||||||
|
className="text-sm text-white/50 hover:text-white"
|
||||||
|
>
|
||||||
|
Wachtwoord vergeten?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loginMutation.isPending || signupMutation.isPending}
|
disabled={loginMutation.isPending || signupMutation.isPending}
|
||||||
|
|||||||
154
apps/web/src/routes/reset-password.tsx
Normal file
154
apps/web/src/routes/reset-password.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
bun.lock
4
bun.lock
@@ -111,10 +111,12 @@
|
|||||||
"@kk/env": "workspace:*",
|
"@kk/env": "workspace:*",
|
||||||
"better-auth": "catalog:",
|
"better-auth": "catalog:",
|
||||||
"dotenv": "catalog:",
|
"dotenv": "catalog:",
|
||||||
|
"nodemailer": "^8.0.2",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kk/config": "workspace:*",
|
"@kk/config": "workspace:*",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
"typescript": "catalog:",
|
"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=="],
|
"@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=="],
|
"@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=="],
|
"@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="],
|
||||||
|
|||||||
@@ -15,10 +15,12 @@
|
|||||||
"@kk/env": "workspace:*",
|
"@kk/env": "workspace:*",
|
||||||
"better-auth": "catalog:",
|
"better-auth": "catalog:",
|
||||||
"dotenv": "catalog:",
|
"dotenv": "catalog:",
|
||||||
|
"nodemailer": "^8.0.2",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kk/config": "workspace:*",
|
"@kk/config": "workspace:*",
|
||||||
|
"@types/nodemailer": "^7.0.11",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,96 @@ import { env } from "@kk/env/server";
|
|||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
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({
|
export const auth = betterAuth({
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
@@ -35,6 +125,9 @@ export const auth = betterAuth({
|
|||||||
return keyBuffer.equals(hashBuffer);
|
return keyBuffer.equals(hashBuffer);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
sendResetPassword: async ({ user, url }) => {
|
||||||
|
await sendPasswordResetEmail(user.email, url);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
additionalFields: {
|
additionalFields: {
|
||||||
|
|||||||
Reference in New Issue
Block a user