"use client"; import { ArrowLeftIcon, CheckIcon, CopyIcon, DownloadIcon, ShieldCheckIcon, SmartphoneIcon, KeyRoundIcon } from "lucide-react"; import { AnimatePresence, motion, useAnimationControls, useReducedMotion } from "motion/react"; import { type ReactNode, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { OTPField, OTPFieldInput, OTPFieldSeparator } from "@/components/ui/otp-field"; import { cn } from "@/lib/utils"; export interface TwoStepSetupProps { account?: string; issuer?: string; secret?: string; backupCodes?: string[]; verify?: (code: string) => Promise; onCancel?: () => void; onDone?: () => void; className?: string; } type Step = 0 | 1 | 2 | 3; const ease = [0.23, 1, 0.32, 1] as const; const press = "transition-[box-shadow,scale,background-color] duration-150 active:scale-[0.97]"; const STEPS = ["Scan", "Verify", "Save codes"]; function useMeasuredHeight() { const ref = useRef(null); const [height, setHeight] = useState("auto"); useLayoutEffect(() => { const el = ref.current; if (!el) return; const ro = new ResizeObserver(() => setHeight(el.offsetHeight)); ro.observe(el); return () => ro.disconnect(); }, []); return [ref, height] as const; } function useCopy() { const [copied, setCopied] = useState(null); useEffect(() => { if (!copied) return; const t = setTimeout(() => setCopied(null), 1600); return () => clearTimeout(t); }, [copied]); const copy = async (key: string, text: string) => { try { await navigator.clipboard.writeText(text); } catch {} setCopied(key); }; return { copied, copy }; } function SwapIcon({ on, children, done }: { on: boolean; children: ReactNode; done: ReactNode }) { return ( {on ? done : children} ); } function QrMock({ seed, size = 25 }: { seed: string; size?: number }) { const cells = useMemo(() => { let h = 2166136261; for (const ch of seed) h = Math.imul(h ^ ch.charCodeAt(0), 16777619); const rand = () => { h ^= h << 13; h ^= h >>> 17; h ^= h << 5; return ((h >>> 0) % 1000) / 1000; }; const out: [number, number][] = []; const inFinder = (x: number, y: number) => (x < 8 && y < 8) || (x >= size - 8 && y < 8) || (x < 8 && y >= size - 8); const mid = Math.floor(size / 2); const inLogo = (x: number, y: number) => Math.abs(x - mid) <= 3 && Math.abs(y - mid) <= 3; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { if (inFinder(x, y) || inLogo(x, y)) continue; if (y === 6 || x === 6) { if ((x + y) % 2 === 0) out.push([x, y]); continue; } if (rand() > 0.52) out.push([x, y]); } } return out; }, [seed, size]); const finder = (x: number, y: number) => ( ); const mid = Math.floor(size / 2); return ( {cells.map(([x, y]) => ( ))} {finder(0, 0)} {finder(size - 7, 0)} {finder(0, size - 7)} ); } function Rail({ step }: { step: Step }) { const reduce = useReducedMotion(); return (
    {STEPS.map((label, i) => { const state = step > i ? "done" : step === i ? "active" : "todo"; return (
  1. {state === "done" && } {label}
  2. ); })}
); } export function TwoStepSetup({ account = "jo@quillstack.io", issuer = "Quillstack", secret = "JBSWY3DPEHPK3PXP", backupCodes = ["k7q2-9xmd", "p4tn-v8wc", "3hzr-m2ke", "w9fb-q6ys", "a5lc-t7np", "m8dj-4rvx", "z2ke-h9gq", "c6yw-n3fb", "r4mx-8tlz", "e7pv-2jdc"], verify = async () => true, onCancel, onDone, className, }: TwoStepSetupProps) { const reduce = useReducedMotion(); const uid = useId(); const [contentRef, height] = useMeasuredHeight(); const [step, setStep] = useState(0); const [dir, setDir] = useState(1); const [code, setCode] = useState(""); const [status, setStatus] = useState<"idle" | "checking" | "error" | "ok">("idle"); const [saved, setSaved] = useState(false); const [downloaded, setDownloaded] = useState(false); const { copied, copy } = useCopy(); const shake = useAnimationControls(); const otpRef = useRef(null); const go = (next: Step) => { setDir(next > step ? 1 : -1); if (next === 1) { setCode(""); setStatus("idle"); } setStep(next); }; useEffect(() => { if (step !== 1) return; const t = setTimeout(() => otpRef.current?.querySelector("input")?.focus(), 260); return () => clearTimeout(t); }, [step]); useEffect(() => { if (status !== "ok") return; const t = setTimeout(() => go(2), 650); return () => clearTimeout(t); }, [status]); const check = async (value: string) => { setStatus("checking"); const ok = await verify(value); if (ok) { setStatus("ok"); return; } setStatus("error"); setCode(""); if (!reduce) shake.start({ x: [0, -7, 7, -4, 4, 0], transition: { duration: 0.34, ease: "easeOut" } }); }; // Inputs are disabled while checking, so focus can only return once they're enabled again. useEffect(() => { if (status !== "error") return; const t = setTimeout(() => otpRef.current?.querySelector("input")?.focus(), 0); return () => clearTimeout(t); }, [status]); const chunks = secret.match(/.{1,4}/g) ?? [secret]; const download = () => { const text = `${issuer} backup codes for ${account}\nEach code works once.\n\n${backupCodes.join("\n")}\n`; const url = URL.createObjectURL(new Blob([text], { type: "text/plain" })); const a = document.createElement("a"); a.href = url; a.download = `${issuer.toLowerCase()}-backup-codes.txt`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); setDownloaded(true); }; const variants = { enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: d * 28 }), center: { opacity: 1, x: 0 }, exit: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: d * -28 }), }; return (
{step < 3 && (

Turn on two-step verification

Add a code from your phone to every new sign-in on {account}.

)}
{step === 0 && (
    {["Open your authenticator app.", `Scan the code to add ${issuer}.`].map((text, i) => (
  1. {i + 1} {text}
  2. ))}

Can't scan? Enter this setup key instead.

{chunks.map((c, i) => ( 0 && "ms-1.5")} key={i}> {c} ))}
)} {step === 1 && (

It changes every 30 seconds, so use the one on screen now.

{ setCode(v); if (status === "error") setStatus("idle"); }} onValueComplete={check} size="lg" value={code} > {Array.from({ length: 6 }, (_, i) => ( {i === 3 && } ))}

{status === "checking" && "Checking…"} {status === "error" && "That code didn't match. Try the newest one from your app."} {status === "ok" && ( <> Code accepted )}

)} {step === 2 && (

Save your backup codes

If you lose your phone, each code gets you in once. We won't show them again.

    {backupCodes.map((c, i) => ( {i + 1} {c} ))}
)} {step === 3 && (

Two-step verification is on

Next time you sign in on a new device, we'll ask for a code from your app.

Authenticator
Added just now
Backup codes
{backupCodes.length} unused
)}
{step < 3 && ( )}
); }