- 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
363 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|