TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import { useState } from "react";4import Link from "next/link";5import { useRouter } from "next/navigation";6import { RECOVERY_RE } from "@spinza/shared";7import { api } from "@/lib/api";8import { useSession } from "@/lib/store";9import { Button, Input } from "@/components/ui";10import { RecoveryCodePanel } from "@/components/shell/recovery-code";1112/** Normalise user input to SPZ-XXXX-XXXX-XXXX while typing. */13function formatRecoveryCode(raw: string): string {14 const clean = raw.toUpperCase().replace(/[^A-Z0-9]/g, "");15 const body = clean.startsWith("SPZ") ? clean.slice(3) : clean;16 const groups = body.slice(0, 12).match(/.{1,4}/g) ?? [];17 return ["SPZ", ...groups].join("-");18}1920interface RecoverResponse {21 user: { id: string; username: string };22 recoveryCode: string;23 notice: string;24}2526export default function RecoverPage() {27 const router = useRouter();28 const refresh = useSession((s) => s.refresh);29 const [username, setUsername] = useState("");30 const [code, setCode] = useState("SPZ-");31 const [password, setPassword] = useState("");32 const [confirm, setConfirm] = useState("");33 const [error, setError] = useState<string | null>(null);34 const [busy, setBusy] = useState(false);35 const [result, setResult] = useState<RecoverResponse | null>(null);36 const [continuing, setContinuing] = useState(false);3738 const codeOk = RECOVERY_RE.test(code);39 const pwError = password && password.length < 8 ? "At least 8 characters." : null;40 const confirmError = confirm && confirm !== password ? "Passwords do not match." : null;41 const canSubmit = username.trim().length >= 3 && codeOk && password.length >= 8 && confirm === password && !busy;4243 const submit = async (e: React.FormEvent) => {44 e.preventDefault();45 if (!canSubmit) return;46 setBusy(true);47 setError(null);48 try {49 const res = await api<RecoverResponse>("/api/auth/recover", { json: { username: username.trim().toLowerCase(), recoveryCode: code, newPassword: password } });50 setResult(res);51 } catch (err) {52 setError(err instanceof Error ? err.message : "Invalid username or recovery code.");53 } finally {54 setBusy(false);55 }56 };5758 const finish = async () => {59 setContinuing(true);60 await refresh();61 router.push("/");62 router.refresh();63 };6465 if (result) {66 return (67 <div className="surface rounded-xl p-6 sm:p-8">68 <div className="mb-5 rounded-md border border-success/30 bg-success/10 px-4 py-3 text-sm text-success">Password updated. You are signed in as <span className="font-semibold">@{result.user.username}</span>. All other sessions were signed out.</div>69 <RecoveryCodePanel title="Your new recovery code" code={result.recoveryCode} notice={`${result.notice} The old code no longer works. Spinza does not collect your email address — if you lose your password and this code, your account cannot be recovered.`} onContinue={finish} loading={continuing} continueLabel="Continue to Spinza" />70 </div>71 );72 }7374 return (75 <div className="surface rounded-xl p-6 sm:p-8">76 <div className="mb-6">77 <div className="eyebrow mb-2">Account recovery</div>78 <h1 className="text-2xl font-semibold tracking-tight">Reset your password.</h1>79 <p className="mt-1.5 text-sm text-fg-3">Enter the recovery code you saved when you created your account. A new code will be issued afterwards.</p>80 </div>8182 <form onSubmit={submit} className="space-y-4" noValidate>83 <Input label="Username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoCapitalize="none" autoCorrect="off" spellCheck={false} placeholder="your username" required />84 <Input label="Recovery code" value={code} onChange={(e) => setCode(formatRecoveryCode(e.target.value))} onFocus={(e) => e.target.select()} autoComplete="one-time-code" autoCapitalize="characters" autoCorrect="off" spellCheck={false} placeholder="SPZ-XXXX-XXXX-XXXX" className="font-mono uppercase tracking-[0.08em]" error={code.length > 4 && code.length >= 18 && !codeOk ? "Format: SPZ-XXXX-XXXX-XXXX" : null} hint="Format: SPZ-XXXX-XXXX-XXXX" />85 <Input label="New password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" placeholder="At least 8 characters" error={pwError} />86 <Input label="Confirm new password" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" placeholder="Repeat your new password" error={confirmError} />87 {error ? (88 <p className="rounded-md border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger" role="alert">89 {error}90 </p>91 ) : null}92 <Button type="submit" size="lg" className="w-full" disabled={!canSubmit} loading={busy}>93 Reset password94 </Button>95 </form>9697 <div className="mt-6 space-y-3 text-center text-sm text-fg-3">98 <p>99 Remembered it?{" "}100 <Link href="/login" className="font-medium text-accent-2 underline-offset-4 hover:underline">101 Back to sign in102 </Link>103 </p>104 <p className="text-[12px] leading-relaxed text-fg-4">Spinza never stores an email address or phone number, so there is no reset link to send. Without the recovery code, the account cannot be recovered — you can always start fresh with a new username.</p>105 </div>106 </div>107 );108}109