acc93de708
Uses better-auth's built-in twoFactor plugin rather than hand-rolling TOTP — adds the two_factors table and users.twoFactorEnabled, wires the server/client plugins (allowPasswordless: true so OAuth-only accounts aren't locked out of managing 2FA), and adds a custom rate limit for the verify endpoints (5/min — far stricter than the default 100/10s, since a 6-digit code has a much smaller keyspace than a password). New Settings → Security section walks through enable (password confirm -> QR + backup codes -> confirm a live code before it's actually turned on, per the plugin's default flow) and disable. New /verify-2fa page handles the post-password mid-login step, with a backup-code fallback. Verified live end-to-end: enable, confirm, forced re-auth on next sign-in, TOTP accepted, backup code accepted (single-use), disable. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
252 lines
9.1 KiB
TypeScript
252 lines
9.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import QRCode from "qrcode";
|
|
import { toast } from "sonner";
|
|
import { useTranslations } from "next-intl";
|
|
import { ShieldCheck, ShieldOff, Copy, Check } from "lucide-react";
|
|
import { authClient } from "@/lib/auth/client";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
|
|
type Step = "closed" | "password" | "setup" | "disable-password";
|
|
|
|
export function TwoFactorSettings({ initialEnabled, hasPassword }: { initialEnabled: boolean; hasPassword: boolean }) {
|
|
const t = useTranslations("settingsForm");
|
|
const [enabled, setEnabled] = useState(initialEnabled);
|
|
const [step, setStep] = useState<Step>("closed");
|
|
const [password, setPassword] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
|
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
|
const [confirmCode, setConfirmCode] = useState("");
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
function closeAndReset() {
|
|
setStep("closed");
|
|
setPassword("");
|
|
setError(null);
|
|
setQrDataUrl(null);
|
|
setBackupCodes([]);
|
|
setConfirmCode("");
|
|
setCopied(false);
|
|
}
|
|
|
|
async function startEnable() {
|
|
setError(null);
|
|
setBusy(true);
|
|
try {
|
|
const { data, error } = await authClient.twoFactor.enable(
|
|
hasPassword ? { password } : {}
|
|
);
|
|
if (error || !data) {
|
|
setError(error?.message ?? t("twoFactorEnableFailed"));
|
|
return;
|
|
}
|
|
const dataUrl = await QRCode.toDataURL(data.totpURI, { margin: 1, width: 200 });
|
|
setQrDataUrl(dataUrl);
|
|
setBackupCodes(data.backupCodes);
|
|
setStep("setup");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function confirmEnable() {
|
|
setError(null);
|
|
setBusy(true);
|
|
try {
|
|
const { error } = await authClient.twoFactor.verifyTotp({ code: confirmCode.trim() });
|
|
if (error) {
|
|
setError(error.message ?? t("twoFactorInvalidCode"));
|
|
return;
|
|
}
|
|
setEnabled(true);
|
|
toast.success(t("twoFactorEnabled"));
|
|
closeAndReset();
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function handleDisable() {
|
|
setError(null);
|
|
setBusy(true);
|
|
try {
|
|
const { error } = await authClient.twoFactor.disable(hasPassword ? { password } : {});
|
|
if (error) {
|
|
setError(error.message ?? t("twoFactorDisableFailed"));
|
|
return;
|
|
}
|
|
setEnabled(false);
|
|
toast.success(t("twoFactorDisabled"));
|
|
closeAndReset();
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function copyBackupCodes() {
|
|
try {
|
|
await navigator.clipboard.writeText(backupCodes.join("\n"));
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
} catch {
|
|
toast.error(t("twoFactorCopyFailed"));
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className="rounded-xl border p-6 space-y-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<h2 className="font-semibold text-lg">{t("twoFactorTitle")}</h2>
|
|
<p className="text-sm text-muted-foreground mt-1 max-w-prose">{t("twoFactorDescription")}</p>
|
|
</div>
|
|
{enabled ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setStep("disable-password")}
|
|
className="gap-1.5 shrink-0"
|
|
>
|
|
<ShieldOff className="h-4 w-4" />
|
|
{t("twoFactorDisableButton")}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => (hasPassword ? setStep("password") : void startEnable())}
|
|
className="gap-1.5 shrink-0"
|
|
>
|
|
<ShieldCheck className="h-4 w-4" />
|
|
{t("twoFactorEnableButton")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Password confirmation before enabling */}
|
|
<Dialog open={step === "password"} onOpenChange={(open) => !open && closeAndReset()}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("twoFactorConfirmPasswordTitle")}</DialogTitle>
|
|
<DialogDescription>{t("twoFactorConfirmPasswordDescription")}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="two-factor-password">{t("currentPassword")}</Label>
|
|
<Input
|
|
id="two-factor-password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
autoComplete="current-password"
|
|
autoFocus
|
|
/>
|
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={closeAndReset} disabled={busy}>{t("twoFactorCancel")}</Button>
|
|
<Button onClick={() => void startEnable()} disabled={busy || !password}>
|
|
{busy ? t("saving") : t("twoFactorContinue")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* QR + backup codes + confirmation code */}
|
|
<Dialog open={step === "setup"} onOpenChange={(open) => !open && closeAndReset()}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("twoFactorSetupTitle")}</DialogTitle>
|
|
<DialogDescription>{t("twoFactorSetupDescription")}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
{qrDataUrl && (
|
|
<div className="flex justify-center">
|
|
{/* eslint-disable-next-line @next/next/no-img-element -- a data: URL generated client-side, not an optimizable remote image */}
|
|
<img src={qrDataUrl} alt={t("twoFactorQrAlt")} className="h-40 w-40" />
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-1.5">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("twoFactorBackupCodesLabel")}</p>
|
|
<div className="rounded-lg border bg-muted/40 p-3 grid grid-cols-2 gap-1 font-mono text-xs">
|
|
{backupCodes.map((c) => <span key={c}>{c}</span>)}
|
|
</div>
|
|
<Button type="button" variant="outline" size="sm" className="w-full" onClick={() => void copyBackupCodes()}>
|
|
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
|
{copied ? t("twoFactorCodesCopied") : t("twoFactorCopyCodes")}
|
|
</Button>
|
|
<p className="text-xs text-muted-foreground">{t("twoFactorBackupCodesHint")}</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="two-factor-confirm-code">{t("twoFactorCodeLabel")}</Label>
|
|
<Input
|
|
id="two-factor-confirm-code"
|
|
value={confirmCode}
|
|
onChange={(e) => setConfirmCode(e.target.value)}
|
|
inputMode="numeric"
|
|
placeholder="000000"
|
|
autoComplete="one-time-code"
|
|
/>
|
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={closeAndReset} disabled={busy}>{t("twoFactorCancel")}</Button>
|
|
<Button onClick={() => void confirmEnable()} disabled={busy || !confirmCode.trim()}>
|
|
{busy ? t("saving") : t("twoFactorVerifyAndEnable")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Password confirmation before disabling */}
|
|
<Dialog open={step === "disable-password"} onOpenChange={(open) => !open && closeAndReset()}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("twoFactorDisableTitle")}</DialogTitle>
|
|
<DialogDescription>{t("twoFactorDisableDescription")}</DialogDescription>
|
|
</DialogHeader>
|
|
{hasPassword && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="two-factor-disable-password">{t("currentPassword")}</Label>
|
|
<Input
|
|
id="two-factor-disable-password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
autoComplete="current-password"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
)}
|
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={closeAndReset} disabled={busy}>{t("twoFactorCancel")}</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => void handleDisable()}
|
|
disabled={busy || (hasPassword && !password)}
|
|
>
|
|
{busy ? t("saving") : t("twoFactorDisableButton")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</section>
|
|
);
|
|
}
|