Files
kunstenkamp/apps/web/src/routes/login.tsx
zias dfc8ace186 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
2026-03-11 09:43:32 +01:00

363 lines
10 KiB
TypeScript

import { useMutation, useQuery } 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";
import { REGISTRATION_OPENS_AT } from "@/lib/opening";
import { useRegistrationOpen } from "@/lib/useRegistrationOpen";
import { orpc } from "@/utils/orpc";
export const Route = createFileRoute("/login")({
validateSearch: (search: Record<string, unknown>) => {
const signup =
search.signup === "1" || search.signup === "true" ? "1" : undefined;
const email = typeof search.email === "string" ? search.email : undefined;
// Only return defined keys so redirects without search still typecheck
return {
...(signup !== undefined && { signup }),
...(email !== undefined && { email }),
} as { signup?: "1"; email?: string };
},
component: LoginPage,
});
function LoginPage() {
const navigate = useNavigate();
const search = Route.useSearch();
const { isOpen } = useRegistrationOpen();
const [isSignup, setIsSignup] = useState(() => search.signup === "1");
const [email, setEmail] = useState(() => search.email ?? "");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const sessionQuery = useQuery({
queryKey: ["session"],
queryFn: () => authClient.getSession(),
});
const loginMutation = useMutation({
mutationFn: async ({
email,
password,
}: {
email: string;
password: string;
}) => {
const result = await authClient.signIn.email({
email,
password,
});
if (result.error) {
throw new Error(result.error.message);
}
return result.data;
},
onSuccess: () => {
toast.success("Succesvol ingelogd!");
authClient.getSession().then((session) => {
const user = session.data?.user as { role?: string } | undefined;
if (user?.role === "admin") {
navigate({ to: "/admin" });
} else {
navigate({ to: "/account" });
}
});
},
onError: (error) => {
toast.error(`Login mislukt: ${error.message}`);
},
});
const signupMutation = useMutation({
mutationFn: async ({
email,
password,
name,
}: {
email: string;
password: string;
name: string;
}) => {
const result = await authClient.signUp.email({
email,
password,
name,
});
if (result.error) {
throw new Error(result.error.message);
}
return result.data;
},
onSuccess: () => {
navigate({ to: "/account", search: { welkom: "1" } });
},
onError: (error) => {
toast.error(`Registratie mislukt: ${error.message}`);
},
});
const requestAdminMutation = useMutation({
...orpc.requestAdminAccess.mutationOptions(),
onSuccess: () => {
toast.success("Admin toegang aangevraagd!");
},
onError: (error) => {
toast.error(`Aanvraag mislukt: ${error.message}`);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isSignup) {
signupMutation.mutate({ email, password, name });
} else {
loginMutation.mutate({ email, password });
}
};
const handleRequestAdmin = () => {
requestAdminMutation.mutate(undefined);
};
// If already logged in, redirect to appropriate page
if (sessionQuery.data?.data?.user) {
const user = sessionQuery.data.data.user as { role?: string };
if (user.role === "admin") {
navigate({ to: "/admin" });
} else {
navigate({ to: "/account" });
}
return null;
}
const isLoggedIn = !!sessionQuery.data?.data?.user;
const user = sessionQuery.data?.data?.user as
| { role?: string; name?: string }
| undefined;
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">
{isLoggedIn
? `Welkom, ${user?.name}`
: isSignup
? "Account Aanmaken"
: "Inloggen"}
</CardTitle>
<CardDescription className="text-white/60">
{isLoggedIn
? "Je bent al ingelogd"
: isSignup
? "Maak een gratis account aan voor je Drinkkaart en inschrijving"
: "Log in om je account te bekijken"}
</CardDescription>
</CardHeader>
<CardContent>
{isLoggedIn ? (
<div className="space-y-4">
{/* Primary CTA: go to account */}
<Button
onClick={() => navigate({ to: "/account" })}
className="w-full bg-white text-[#214e51] hover:bg-white/90"
>
Ga naar mijn account
</Button>
{/* Secondary: request admin access */}
<div className="rounded-lg border border-white/10 bg-white/5 p-4">
<p className="mb-3 text-center text-sm text-white/60">
Admin-toegang aanvragen?
</p>
<Button
onClick={handleRequestAdmin}
disabled={requestAdminMutation.isPending}
variant="outline"
className="w-full border-white/20 bg-transparent text-white hover:bg-white/10"
>
{requestAdminMutation.isPending
? "Bezig..."
: "Vraag Admin Toegang Aan"}
</Button>
</div>
<Button
onClick={() =>
authClient.signOut().then(() => navigate({ to: "/" }))
}
variant="outline"
className="w-full border-white/20 bg-transparent text-white/60 hover:bg-white/10 hover:text-white"
>
Uitloggen
</Button>
<Link
to="/"
className="block text-center text-sm text-white/40 hover:text-white"
>
Terug naar website
</Link>
</div>
) : isSignup && !isOpen ? (
/* Signup is closed until registration opens */
<div className="space-y-4">
<div className="rounded-lg border border-white/10 bg-white/5 p-5 text-center">
<p className="mb-1 font-['Intro',sans-serif] text-white">
Registratie nog niet open
</p>
<p className="text-sm text-white/60">
Accounts aanmaken kan vanaf{" "}
<span className="text-teal-300">
{REGISTRATION_OPENS_AT.toLocaleDateString("nl-BE", {
weekday: "long",
day: "numeric",
month: "long",
})}{" "}
om{" "}
{REGISTRATION_OPENS_AT.toLocaleTimeString("nl-BE", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
.
</p>
</div>
<div className="flex flex-col gap-2 text-center">
<button
type="button"
onClick={() => setIsSignup(false)}
className="text-sm text-white/60 hover:text-white"
>
Al een account? Log in
</button>
<Link to="/" className="text-sm text-white/60 hover:text-white">
Terug naar website
</Link>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{isSignup && (
<div>
<label
htmlFor="name"
className="mb-2 block text-sm text-white/60"
>
Naam
</label>
<Input
id="name"
type="text"
placeholder="Jouw naam"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
/>
</div>
)}
<div>
<label
htmlFor="email"
className="mb-2 block text-sm text-white/60"
>
Email
</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>
<div>
<label
htmlFor="password"
className="mb-2 block text-sm text-white/60"
>
Wachtwoord
</label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
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}
className="w-full bg-white text-[#214e51] hover:bg-white/90"
>
{loginMutation.isPending || signupMutation.isPending
? "Bezig..."
: isSignup
? "Account Aanmaken"
: "Inloggen"}
</Button>
<div className="flex flex-col gap-2 text-center">
{/* Only show signup toggle when registration is open */}
{isOpen && (
<button
type="button"
onClick={() => setIsSignup(!isSignup)}
className="text-sm text-white/60 hover:text-white"
>
{isSignup
? "Al een account? Log in"
: "Nog geen account? Registreer"}
</button>
)}
{!isOpen && isSignup === false && (
<button
type="button"
onClick={() => setIsSignup(true)}
className="cursor-not-allowed text-sm text-white/30"
disabled
title={`Registratie opent op ${REGISTRATION_OPENS_AT.toLocaleString("nl-BE", { day: "numeric", month: "long", hour: "2-digit", minute: "2-digit" })}`}
>
Nog geen account? (opent{" "}
{REGISTRATION_OPENS_AT.toLocaleString("nl-BE", {
day: "numeric",
month: "long",
})}
)
</button>
)}
<Link to="/" className="text-sm text-white/60 hover:text-white">
Terug naar website
</Link>
</div>
</form>
)}
</CardContent>
</Card>
</div>
);
}