171 lines
5.0 KiB
TypeScript
171 lines
5.0 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { toast } from "sonner";
|
|
import { api, AdminError, type Me } from "@/lib/api";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
export default function LoginPage() {
|
|
const router = useRouter();
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [otp, setOtp] = useState("");
|
|
const [stage, setStage] = useState<"credentials" | "otp">("credentials");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Once authenticated, only admins may proceed. Non-admins are signed back
|
|
// out so an ordinary account can't sit in an authenticated-but-forbidden
|
|
// state against the admin origin.
|
|
async function gateAdmin(user: Me) {
|
|
if (user.role === "admin") {
|
|
router.replace("/users");
|
|
return;
|
|
}
|
|
await api.logout().catch(() => {});
|
|
setError("This account doesn't have admin access.");
|
|
setStage("credentials");
|
|
}
|
|
|
|
async function onCredentials(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setBusy(true);
|
|
try {
|
|
const res = await api.login(email.trim(), password);
|
|
if (res.kind === "otp_required") {
|
|
setStage("otp");
|
|
toast("We emailed you a verification code.");
|
|
} else {
|
|
await gateAdmin(res.user);
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof AdminError ? err.message : "Could not sign in.");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function onOtp(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setBusy(true);
|
|
try {
|
|
const user = await api.verifyLoginOtp(email.trim(), password, otp.trim());
|
|
await gateAdmin(user);
|
|
} catch (err) {
|
|
setError(err instanceof AdminError ? err.message : "Invalid code.");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<AuthShell title="Admin sign in">
|
|
{stage === "credentials" ? (
|
|
<form onSubmit={onCredentials} className="space-y-4">
|
|
<Field label="Email">
|
|
<Input
|
|
type="email"
|
|
autoComplete="email"
|
|
required
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field label="Password">
|
|
<Input
|
|
type="password"
|
|
autoComplete="current-password"
|
|
required
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
/>
|
|
</Field>
|
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
|
<Button type="submit" disabled={busy} className="w-full">
|
|
{busy ? "Signing in…" : "Sign in"}
|
|
</Button>
|
|
<p className="text-center text-sm text-muted-foreground">
|
|
<Link href="/forgot-password" className="hover:text-foreground">
|
|
Forgot your password?
|
|
</Link>
|
|
</p>
|
|
</form>
|
|
) : (
|
|
<form onSubmit={onOtp} className="space-y-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Enter the verification code we emailed to {email}.
|
|
</p>
|
|
<Field label="Verification code">
|
|
<Input
|
|
inputMode="numeric"
|
|
autoFocus
|
|
required
|
|
value={otp}
|
|
onChange={(e) => setOtp(e.target.value)}
|
|
/>
|
|
</Field>
|
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
|
<Button type="submit" disabled={busy} className="w-full">
|
|
{busy ? "Verifying…" : "Verify & sign in"}
|
|
</Button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setStage("credentials");
|
|
setOtp("");
|
|
setError(null);
|
|
}}
|
|
className="w-full text-center text-sm text-muted-foreground hover:text-foreground"
|
|
>
|
|
Back
|
|
</button>
|
|
</form>
|
|
)}
|
|
</AuthShell>
|
|
);
|
|
}
|
|
|
|
export function AuthShell({
|
|
title,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<main className="flex min-h-screen items-center justify-center px-4">
|
|
<div className="w-full max-w-sm">
|
|
<div className="mb-6 text-center">
|
|
<p className="text-xs uppercase tracking-[0.2em] text-primary">
|
|
Prabhupāda Says
|
|
</p>
|
|
<h1 className="mt-1 text-xl font-semibold text-foreground">{title}</h1>
|
|
</div>
|
|
<div className="rounded-2xl border border-border bg-card p-6 shadow-sm">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
export function Field({
|
|
label,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<label className="block space-y-1.5">
|
|
<span className="text-sm font-medium text-foreground">{label}</span>
|
|
{children}
|
|
</label>
|
|
);
|
|
}
|