3e15d9f937
Hesap güvenliği için authenticator app (Google Authenticator, 1Password,
Authy etc.) based TOTP. SMS yok — sadece app-based per user request.
Enroll flow (/settings/security)
- startMfaEnrollAction → account.createMFAAuthenticator('totp'),
returns otpauth URI + plain secret as backup.
- MfaPanel client island: starts the flow, shows the QR (rendered via
api.qrserver.com for zero deps) plus the secret as text. Picks the
6-digit code → verifyMfaEnrollAction calls
updateMFAAuthenticator(totp, otp) + updateMFA(true) +
createMFARecoveryCodes(). The recovery codes are surfaced once on
success with a 'save these now' warning.
- disableMfaAction + regenerateRecoveryCodesAction give the same
panel a disable + 'yeni yedek kodlar' option once MFA is active.
- settings-nav now has 'Güvenlik' between 'Görünüm' and 'Hesap
Aktivitesi'.
Sign-in flow
- signInAction:
1. createEmailPasswordSession (sets cookie as before)
2. users.get(userId).mfa? If yes:
a. otp empty → return { mfaRequired: true, error }
b. otp present → createMfaChallenge({factor: totp}) +
updateMfaChallenge(challengeId, otp). Failure tears the
partial session down and bounces back with mfaRequired.
- AuthState gained an mfaRequired field. The login form watches it
and reveals an autofocused 6-digit OTP input on the next render.
User types the code, submits the form again, the same action
finishes the challenge and redirects.
Existing accounts without MFA are unaffected — they never hit the
challenge branch.
210 lines
7.2 KiB
TypeScript
210 lines
7.2 KiB
TypeScript
"use client";
|
||
|
||
import { useActionState, useEffect, useState, useTransition } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { Check, KeyRound, Loader2, ShieldCheck, ShieldOff } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
disableMfaAction,
|
||
initialMfaActionState,
|
||
regenerateRecoveryCodesAction,
|
||
startMfaEnrollAction,
|
||
verifyMfaEnrollAction,
|
||
} from "@/lib/appwrite/mfa-actions";
|
||
|
||
type EnrollStage =
|
||
| { kind: "idle" }
|
||
| { kind: "loading" }
|
||
| { kind: "verify"; uri: string; secret: string }
|
||
| { kind: "done"; recoveryCodes: string[] };
|
||
|
||
export function MfaPanel({ initiallyEnabled }: { initiallyEnabled: boolean }) {
|
||
const router = useRouter();
|
||
const [enabled, setEnabled] = useState(initiallyEnabled);
|
||
const [stage, setStage] = useState<EnrollStage>({ kind: "idle" });
|
||
const [verifyState, verifyAction, verifying] = useActionState(
|
||
verifyMfaEnrollAction,
|
||
initialMfaActionState,
|
||
);
|
||
const [busy, startTransition] = useTransition();
|
||
|
||
useEffect(() => {
|
||
if (verifyState.ok && verifyState.recoveryCodes) {
|
||
setEnabled(true);
|
||
setStage({ kind: "done", recoveryCodes: verifyState.recoveryCodes });
|
||
toast.success("2FA etkinleştirildi.");
|
||
router.refresh();
|
||
} else if (verifyState.error) {
|
||
toast.error(verifyState.error);
|
||
}
|
||
}, [verifyState, router]);
|
||
|
||
function beginEnroll() {
|
||
setStage({ kind: "loading" });
|
||
startTransition(async () => {
|
||
const res = await startMfaEnrollAction();
|
||
if (res.ok && res.uri && res.secret) {
|
||
setStage({ kind: "verify", uri: res.uri, secret: res.secret });
|
||
} else {
|
||
toast.error(res.error ?? "Başlatılamadı.");
|
||
setStage({ kind: "idle" });
|
||
}
|
||
});
|
||
}
|
||
|
||
function onDisable() {
|
||
if (
|
||
!window.confirm(
|
||
"2FA devre dışı bırakılsın mı? Hesabınız sadece şifre ile korunacak.",
|
||
)
|
||
)
|
||
return;
|
||
startTransition(async () => {
|
||
const res = await disableMfaAction();
|
||
if (res.ok) {
|
||
setEnabled(false);
|
||
setStage({ kind: "idle" });
|
||
toast.success("2FA devre dışı bırakıldı.");
|
||
router.refresh();
|
||
} else {
|
||
toast.error(res.error ?? "Devre dışı bırakılamadı.");
|
||
}
|
||
});
|
||
}
|
||
|
||
function onRegenerateCodes() {
|
||
startTransition(async () => {
|
||
const res = await regenerateRecoveryCodesAction();
|
||
if (res.ok && res.recoveryCodes) {
|
||
setStage({ kind: "done", recoveryCodes: res.recoveryCodes });
|
||
toast.success("Yeni yedek kodlar oluşturuldu — eskileri geçersiz.");
|
||
} else {
|
||
toast.error(res.error ?? "Üretilemedi.");
|
||
}
|
||
});
|
||
}
|
||
|
||
if (enabled && stage.kind !== "done") {
|
||
return (
|
||
<div className="grid gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<Badge className="bg-emerald-600 text-white">
|
||
<ShieldCheck className="size-3.5" />
|
||
Aktif
|
||
</Badge>
|
||
<span className="text-muted-foreground text-sm">
|
||
Authenticator uygulaması ile giriş yapıyorsunuz.
|
||
</span>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button variant="outline" onClick={onRegenerateCodes} disabled={busy}>
|
||
<KeyRound className="size-4" />
|
||
Yedek kodları yenile
|
||
</Button>
|
||
<Button variant="destructive" onClick={onDisable} disabled={busy}>
|
||
{busy ? <Loader2 className="size-4 animate-spin" /> : <ShieldOff className="size-4" />}
|
||
Devre dışı bırak
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (stage.kind === "done") {
|
||
return (
|
||
<div className="grid gap-3">
|
||
<div className="bg-emerald-50 dark:bg-emerald-950 rounded-md border border-emerald-200 dark:border-emerald-900 p-4">
|
||
<p className="flex items-center gap-2 font-medium text-emerald-700 dark:text-emerald-300">
|
||
<Check className="size-4" />
|
||
Yedek kodlarınız
|
||
</p>
|
||
<p className="text-muted-foreground mt-1 text-xs">
|
||
Telefonunuza erişiminizi kaybederseniz bu kodlardan biriyle giriş
|
||
yapabilirsiniz. Her kod tek seferlik. <strong>Şimdi güvenli bir yere kaydedin</strong> —
|
||
bu sayfadan çıktığınızda tekrar gösterilmez.
|
||
</p>
|
||
<pre className="bg-background mt-3 grid grid-cols-2 gap-2 rounded-md border p-3 text-sm font-mono">
|
||
{stage.recoveryCodes.map((c) => (
|
||
<span key={c}>{c}</span>
|
||
))}
|
||
</pre>
|
||
</div>
|
||
<Button variant="outline" onClick={() => setStage({ kind: "idle" })}>
|
||
Tamamladım
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (stage.kind === "verify") {
|
||
const otpauthQrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(stage.uri)}`;
|
||
return (
|
||
<form action={verifyAction} className="grid gap-4">
|
||
<div className="grid gap-4 sm:grid-cols-[200px_1fr] sm:items-start">
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={otpauthQrUrl}
|
||
alt="QR kodu"
|
||
className="size-[200px] rounded-md border bg-white p-2"
|
||
width={200}
|
||
height={200}
|
||
/>
|
||
<div className="grid gap-2 text-sm">
|
||
<p>Authenticator uygulamanızı açın, QR kodu tarayın.</p>
|
||
<p className="text-muted-foreground text-xs">
|
||
Tarayamıyorsanız bu kodu manuel girin:
|
||
</p>
|
||
<code className="bg-muted rounded-md p-2 font-mono text-xs">{stage.secret}</code>
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="otp">Uygulamadaki 6 haneli kod</Label>
|
||
<Input
|
||
id="otp"
|
||
name="otp"
|
||
inputMode="numeric"
|
||
pattern="[0-9]*"
|
||
maxLength={6}
|
||
placeholder="000000"
|
||
required
|
||
autoComplete="one-time-code"
|
||
className="font-mono text-lg tracking-widest"
|
||
/>
|
||
{verifyState.error && (
|
||
<p className="text-destructive text-xs">{verifyState.error}</p>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button type="button" variant="outline" onClick={() => setStage({ kind: "idle" })}>
|
||
Vazgeç
|
||
</Button>
|
||
<Button type="submit" disabled={verifying}>
|
||
{verifying ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
|
||
Doğrula ve etkinleştir
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="grid gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant="outline">Pasif</Badge>
|
||
<span className="text-muted-foreground text-sm">
|
||
Hesabınız yalnızca şifre ile korunuyor.
|
||
</span>
|
||
</div>
|
||
<Button onClick={beginEnroll} disabled={busy || stage.kind === "loading"}>
|
||
{(busy || stage.kind === "loading") ? <Loader2 className="size-4 animate-spin" /> : <ShieldCheck className="size-4" />}
|
||
İki adımlı doğrulamayı aç
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|