TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import { Suspense, useEffect, useState } from "react";4import Link from "next/link";5import { useRouter, useSearchParams } from "next/navigation";6import { api } from "@/lib/api";7import { useSession } from "@/lib/store";8import { Button, Input, Skeleton } from "@/components/ui";910function safeNext(raw: string | null): string {11 if (!raw || !raw.startsWith("/") || raw.startsWith("//")) return "/";12 return raw;13}1415function LoginForm() {16 const router = useRouter();17 const params = useSearchParams();18 const next = safeNext(params.get("next"));19 const expired = params.get("reason") === "expired";20 const refresh = useSession((s) => s.refresh);21 const status = useSession((s) => s.status);22 const [username, setUsername] = useState("");23 const [password, setPassword] = useState("");24 const [error, setError] = useState<string | null>(null);25 const [busy, setBusy] = useState(false);2627 useEffect(() => {28 if (status === "authenticated" && !busy) router.replace(next);29 }, [status, busy, next, router]);3031 const submit = async (e: React.FormEvent) => {32 e.preventDefault();33 if (busy) return;34 setBusy(true);35 setError(null);36 try {37 await api("/api/auth/login", { json: { username: username.trim().toLowerCase(), password } });38 await refresh();39 router.push(next);40 router.refresh();41 } catch (err) {42 setError(err instanceof Error ? err.message : "Invalid username or password.");43 setBusy(false);44 }45 };4647 return (48 <div className="surface rounded-xl p-6 sm:p-8">49 <div className="mb-6">50 <div className="eyebrow mb-2">Sign in</div>51 <h1 className="text-2xl font-semibold tracking-tight">Welcome back.</h1>52 <p className="mt-1.5 text-sm text-fg-3">Your credits, streak and progress are exactly where you left them.</p>53 </div>5455 {expired ? (56 <p className="mb-4 rounded-md border border-line-2 bg-surface-2 px-4 py-3 text-sm text-fg-2" role="status">57 Your session expired. Sign in to continue.58 </p>59 ) : null}6061 <form onSubmit={submit} className="space-y-4" noValidate>62 <Input label="Username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoCapitalize="none" autoCorrect="off" spellCheck={false} placeholder="your username" required />63 <Input label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" placeholder="your password" required />64 {error ? (65 <p className="rounded-md border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger" role="alert">66 {error}67 </p>68 ) : null}69 <Button type="submit" size="lg" className="w-full" loading={busy} disabled={!username || !password}>70 Sign in71 </Button>72 </form>7374 <div className="mt-6 flex flex-col items-center gap-2 text-sm text-fg-3">75 <Link href="/recover" className="tap inline-flex h-9 min-h-0 items-center font-medium text-fg-2 underline-offset-4 hover:underline">76 Forgot your password? Use your recovery code77 </Link>78 <p>79 New to Spinza?{" "}80 <Link href={next !== "/" ? `/register?next=${encodeURIComponent(next)}` : "/register"} className="font-medium text-accent-2 underline-offset-4 hover:underline">81 Create a free account82 </Link>83 </p>84 </div>85 </div>86 );87}8889export default function LoginPage() {90 return (91 <Suspense92 fallback={93 <div className="surface rounded-xl p-6 sm:p-8" aria-busy>94 <Skeleton className="h-7 w-40" />95 <Skeleton className="mt-6 h-12 w-full" />96 <Skeleton className="mt-4 h-12 w-full" />97 <Skeleton className="mt-4 h-12 w-full" />98 </div>99 }100 >101 <LoginForm />102 </Suspense>103 );104}105