SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
5.4 KB · 109 lines tsx
Raw Blame History
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