瀏覽代碼

feat: implement auth flow with anonymous sign-in, recovery codes, and rate limiting

Add complete Unit 3 (Auth + Recovery) including:
- Login page with display name input and avatar color picker
- Recovery code generation (24-char, 128-bit entropy) with Argon2id hashing
- Recovery code claim endpoint with IP-based rate limiting (5/15min)
- Auth layout, components, and API routes
- Fix Database types to include Relationships for Supabase client compatibility
- Work around @supabase/ssr type mismatch with supabase-js 2.101.x

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User 2 月之前
父節點
當前提交
92ae2e8165

+ 9 - 0
src/app/(auth)/layout.tsx

@@ -0,0 +1,9 @@
+import type { ReactNode } from "react";
+
+export default function AuthLayout({ children }: { children: ReactNode }) {
+  return (
+    <div className="flex min-h-full flex-1 items-center justify-center px-4 py-12">
+      <div className="w-full max-w-sm">{children}</div>
+    </div>
+  );
+}

+ 21 - 0
src/app/(auth)/login/page.tsx

@@ -0,0 +1,21 @@
+import type { Metadata } from "next";
+import { DisplayNameForm } from "@/components/auth/display-name-form";
+
+export const metadata: Metadata = {
+  title: "Join - MovieDice",
+};
+
+export default function LoginPage() {
+  return (
+    <>
+      <h1 className="mb-6 text-2xl font-bold text-center">Join MovieDice</h1>
+      <DisplayNameForm />
+      <p className="mt-6 text-center text-sm text-foreground/50">
+        Already have an account?{" "}
+        <a href="/recover" className="text-blue-500 hover:underline">
+          Recover it
+        </a>
+      </p>
+    </>
+  );
+}

+ 91 - 0
src/app/(auth)/recover/page.tsx

@@ -0,0 +1,91 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation } from "@tanstack/react-query";
+import { useRouter } from "next/navigation";
+import { RECOVERY_CODE_LENGTH } from "@/lib/constants";
+
+export default function RecoverPage() {
+  const router = useRouter();
+  const [code, setCode] = useState("");
+
+  const claimMutation = useMutation({
+    mutationFn: async (recoveryCode: string) => {
+      const res = await fetch("/api/auth/recovery/claim", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ code: recoveryCode }),
+      });
+
+      const data = (await res.json()) as {
+        userId?: string;
+        error?: string;
+      };
+
+      if (!res.ok) {
+        throw new Error(data.error || "Recovery failed");
+      }
+
+      return data as { userId: string };
+    },
+    onSuccess: () => {
+      router.push("/");
+    },
+  });
+
+  function handleSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    const trimmed = code.trim();
+    if (trimmed.length === 0) return;
+    claimMutation.mutate(trimmed);
+  }
+
+  return (
+    <>
+      <h1 className="mb-6 text-2xl font-bold text-center">Recover Your Account</h1>
+
+      <form onSubmit={handleSubmit} className="space-y-4">
+        <div>
+          <label htmlFor="recovery-code" className="block text-sm font-medium mb-1">
+            Recovery code
+          </label>
+          <input
+            id="recovery-code"
+            type="text"
+            value={code}
+            onChange={(e) => setCode(e.target.value)}
+            maxLength={RECOVERY_CODE_LENGTH}
+            placeholder="Enter your 24-character code"
+            className="w-full rounded-lg border border-foreground/20 bg-background px-3 py-2 font-mono text-foreground placeholder:text-foreground/40 focus:outline-none focus:ring-2 focus:ring-blue-500"
+            autoFocus
+            autoComplete="off"
+            spellCheck={false}
+          />
+        </div>
+
+        {claimMutation.error && (
+          <p className="text-sm text-red-500" role="alert">
+            {claimMutation.error instanceof Error
+              ? claimMutation.error.message
+              : "Something went wrong."}
+          </p>
+        )}
+
+        <button
+          type="submit"
+          disabled={claimMutation.isPending || code.trim().length === 0}
+          className="w-full rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
+        >
+          {claimMutation.isPending ? "Recovering..." : "Recover account"}
+        </button>
+      </form>
+
+      <p className="mt-6 text-center text-sm text-foreground/50">
+        Don&apos;t have an account?{" "}
+        <a href="/login" className="text-blue-500 hover:underline">
+          Create one
+        </a>
+      </p>
+    </>
+  );
+}

+ 72 - 0
src/app/(auth)/recovery/page.tsx

@@ -0,0 +1,72 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { useRouter } from "next/navigation";
+import { useMutation } from "@tanstack/react-query";
+import { getSupabaseBrowserClient } from "@/lib/supabase/client";
+import { RecoveryCodeDisplay } from "@/components/auth/recovery-code-display";
+
+export default function RecoveryPage() {
+  const router = useRouter();
+  const hasRequested = useRef(false);
+
+  const generateMutation = useMutation({
+    mutationFn: async () => {
+      const supabase = getSupabaseBrowserClient();
+      const {
+        data: { session },
+      } = await supabase.auth.getSession();
+      if (!session) throw new Error("Not authenticated");
+
+      const res = await fetch("/api/auth/recovery/generate", {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+          Authorization: `Bearer ${session.access_token}`,
+        },
+      });
+
+      const data = (await res.json()) as { code?: string; error?: string };
+
+      if (!res.ok) {
+        throw new Error(data.error || "Failed to generate recovery code");
+      }
+
+      return data.code!;
+    },
+  });
+
+  useEffect(() => {
+    if (!hasRequested.current) {
+      hasRequested.current = true;
+      generateMutation.mutate();
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  function handleConfirm() {
+    router.push("/");
+  }
+
+  return (
+    <>
+      <h1 className="mb-6 text-2xl font-bold text-center">Recovery Code</h1>
+
+      {generateMutation.isPending && (
+        <p className="text-center text-foreground/60">Generating your recovery code...</p>
+      )}
+
+      {generateMutation.error && (
+        <p className="text-center text-red-500" role="alert">
+          {generateMutation.error instanceof Error
+            ? generateMutation.error.message
+            : "Something went wrong."}
+        </p>
+      )}
+
+      {generateMutation.data && (
+        <RecoveryCodeDisplay code={generateMutation.data} onConfirm={handleConfirm} />
+      )}
+    </>
+  );
+}

+ 66 - 0
src/app/api/auth/recovery/claim/route.ts

@@ -0,0 +1,66 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+import { verifyRecoveryCode } from "@/lib/auth/recovery";
+import { checkRateLimit, getClientIp } from "@/lib/auth/rate-limit";
+
+const claimSchema = z.object({
+  code: z.string().min(1, "Recovery code is required"),
+});
+
+export async function POST(request: NextRequest) {
+  try {
+    const ip = getClientIp(request);
+    const limit = checkRateLimit(ip);
+
+    if (!limit.allowed) {
+      return NextResponse.json(
+        { error: "Too many attempts. Please try again later." },
+        {
+          status: 429,
+          headers: {
+            "Retry-After": String(Math.ceil(limit.retryAfterMs / 1000)),
+          },
+        },
+      );
+    }
+
+    const body: unknown = await request.json();
+    const parsed = claimSchema.safeParse(body);
+    if (!parsed.success) {
+      return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
+    }
+
+    const { code } = parsed.data;
+    const admin = getSupabaseAdminClient();
+
+    const { data: users, error: fetchError } = await admin
+      .from("users")
+      .select("id, recovery_code")
+      .not("recovery_code", "is", null);
+
+    if (fetchError) {
+      return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+    }
+
+    let matchedUserId: string | null = null;
+    for (const user of users ?? []) {
+      if (!user.recovery_code) continue;
+      const valid = await verifyRecoveryCode(code, user.recovery_code);
+      if (valid) {
+        matchedUserId = user.id;
+        break;
+      }
+    }
+
+    if (!matchedUserId) {
+      return NextResponse.json({ error: "Invalid recovery code" }, { status: 401 });
+    }
+
+    await admin.from("users").update({ recovery_code: null }).eq("id", matchedUserId);
+
+    return NextResponse.json({ userId: matchedUserId });
+  } catch {
+    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+  }
+}

+ 31 - 0
src/app/api/auth/recovery/generate/route.ts

@@ -0,0 +1,31 @@
+import { NextResponse } from "next/server";
+import { getSupabaseServerClient } from "@/lib/supabase/server";
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
+import { generateRecoveryCode, hashRecoveryCode } from "@/lib/auth/recovery";
+
+export async function POST() {
+  try {
+    const supabase = await getSupabaseServerClient();
+    const {
+      data: { user },
+    } = await supabase.auth.getUser();
+
+    if (!user) {
+      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+    }
+
+    const code = generateRecoveryCode();
+    const hashed = await hashRecoveryCode(code);
+
+    const admin = getSupabaseAdminClient();
+    const { error } = await admin.from("users").update({ recovery_code: hashed }).eq("id", user.id);
+
+    if (error) {
+      return NextResponse.json({ error: "Failed to store recovery code" }, { status: 500 });
+    }
+
+    return NextResponse.json({ code });
+  } catch {
+    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+  }
+}

+ 46 - 0
src/components/auth/avatar-color-picker.tsx

@@ -0,0 +1,46 @@
+"use client";
+
+const PRESET_COLORS = [
+  "#EF4444", // red
+  "#F97316", // orange
+  "#EAB308", // yellow
+  "#22C55E", // green
+  "#06B6D4", // cyan
+  "#3B82F6", // blue
+  "#8B5CF6", // violet
+  "#EC4899", // pink
+  "#6B7280", // gray
+  "#A78BFA", // lavender
+  "#F472B6", // rose
+  "#2DD4BF", // teal
+] as const;
+
+interface AvatarColorPickerProps {
+  value: string | null;
+  onChange: (color: string | null) => void;
+}
+
+export function AvatarColorPicker({ value, onChange }: AvatarColorPickerProps) {
+  return (
+    <fieldset>
+      <legend className="text-sm font-medium text-foreground/70 mb-2">
+        Avatar color (optional)
+      </legend>
+      <div className="grid grid-cols-6 gap-2">
+        {PRESET_COLORS.map((color) => (
+          <button
+            key={color}
+            type="button"
+            onClick={() => onChange(value === color ? null : color)}
+            className={`w-9 h-9 rounded-full border-2 transition-transform ${
+              value === color ? "border-foreground scale-110" : "border-transparent"
+            }`}
+            style={{ backgroundColor: color }}
+            aria-label={`Select color ${color}`}
+            aria-pressed={value === color}
+          />
+        ))}
+      </div>
+    </fieldset>
+  );
+}

+ 101 - 0
src/components/auth/display-name-form.tsx

@@ -0,0 +1,101 @@
+"use client";
+
+import { useState } from "react";
+import { useMutation } from "@tanstack/react-query";
+import { useRouter } from "next/navigation";
+import { DISPLAY_NAME_MAX_LENGTH } from "@/lib/constants";
+import { getSupabaseBrowserClient } from "@/lib/supabase/client";
+import { AvatarColorPicker } from "./avatar-color-picker";
+
+function validateDisplayName(name: string): string | null {
+  const trimmed = name.trim();
+  if (trimmed.length === 0) return "Display name is required";
+  if (trimmed.length > DISPLAY_NAME_MAX_LENGTH)
+    return `Display name must be ${DISPLAY_NAME_MAX_LENGTH} characters or less`;
+  if (/<|>/.test(trimmed)) return "Display name cannot contain < or >";
+  return null;
+}
+
+export function DisplayNameForm() {
+  const router = useRouter();
+  const [displayName, setDisplayName] = useState("");
+  const [avatarColor, setAvatarColor] = useState<string | null>(null);
+  const [validationError, setValidationError] = useState<string | null>(null);
+
+  const signUpMutation = useMutation({
+    mutationFn: async ({ name, color }: { name: string; color: string | null }) => {
+      const supabase = getSupabaseBrowserClient();
+
+      const { data: authData, error: authError } = await supabase.auth.signInAnonymously();
+      if (authError) throw authError;
+      if (!authData.user) throw new Error("Sign-in failed: no user returned");
+
+      const { error: insertError } = await supabase.from("users").insert({
+        id: authData.user.id,
+        display_name: name,
+        avatar_color: color,
+      });
+      if (insertError) throw insertError;
+
+      return { userId: authData.user.id };
+    },
+    onSuccess: () => {
+      router.push("/recovery");
+    },
+  });
+
+  function handleSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    const trimmed = displayName.trim();
+    const error = validateDisplayName(trimmed);
+    if (error) {
+      setValidationError(error);
+      return;
+    }
+    setValidationError(null);
+    signUpMutation.mutate({ name: trimmed, color: avatarColor });
+  }
+
+  return (
+    <form onSubmit={handleSubmit} className="space-y-6">
+      <div>
+        <label htmlFor="display-name" className="block text-sm font-medium mb-1">
+          Display name
+        </label>
+        <input
+          id="display-name"
+          type="text"
+          value={displayName}
+          onChange={(e) => {
+            setDisplayName(e.target.value);
+            setValidationError(null);
+          }}
+          maxLength={DISPLAY_NAME_MAX_LENGTH}
+          placeholder="What should we call you?"
+          className="w-full rounded-lg border border-foreground/20 bg-background px-3 py-2 text-foreground placeholder:text-foreground/40 focus:outline-none focus:ring-2 focus:ring-blue-500"
+          autoFocus
+          autoComplete="off"
+        />
+        {validationError && <p className="mt-1 text-sm text-red-500">{validationError}</p>}
+      </div>
+
+      <AvatarColorPicker value={avatarColor} onChange={setAvatarColor} />
+
+      {signUpMutation.error && (
+        <p className="text-sm text-red-500" role="alert">
+          {signUpMutation.error instanceof Error
+            ? signUpMutation.error.message
+            : "Something went wrong. Please try again."}
+        </p>
+      )}
+
+      <button
+        type="submit"
+        disabled={signUpMutation.isPending}
+        className="w-full rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
+      >
+        {signUpMutation.isPending ? "Setting up..." : "Get started"}
+      </button>
+    </form>
+  );
+}

+ 58 - 0
src/components/auth/recovery-code-display.tsx

@@ -0,0 +1,58 @@
+"use client";
+
+import { useState } from "react";
+
+interface RecoveryCodeDisplayProps {
+  code: string;
+  onConfirm: () => void;
+}
+
+export function RecoveryCodeDisplay({ code, onConfirm }: RecoveryCodeDisplayProps) {
+  const [copied, setCopied] = useState(false);
+
+  async function handleCopy() {
+    try {
+      await navigator.clipboard.writeText(code);
+      setCopied(true);
+      setTimeout(() => setCopied(false), 2000);
+    } catch {
+      // Clipboard API not available — user can manually select/copy
+    }
+  }
+
+  return (
+    <div className="space-y-6">
+      <div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-4">
+        <p className="text-sm font-medium text-yellow-600 dark:text-yellow-400 mb-1">
+          Save this recovery code
+        </p>
+        <p className="text-sm text-foreground/70">
+          This is the only way to recover your account on a new device. Store it somewhere safe --
+          you will not see it again.
+        </p>
+      </div>
+
+      <div className="flex items-center gap-2">
+        <code className="flex-1 rounded-lg border border-foreground/20 bg-foreground/5 px-4 py-3 text-center font-mono text-lg tracking-wider select-all">
+          {code}
+        </code>
+        <button
+          type="button"
+          onClick={handleCopy}
+          className="shrink-0 rounded-lg border border-foreground/20 px-3 py-3 text-sm transition-colors hover:bg-foreground/5"
+          aria-label="Copy recovery code"
+        >
+          {copied ? "Copied" : "Copy"}
+        </button>
+      </div>
+
+      <button
+        type="button"
+        onClick={onConfirm}
+        className="w-full rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
+      >
+        I&apos;ve saved it
+      </button>
+    </div>
+  );
+}

+ 66 - 0
src/lib/auth/rate-limit.ts

@@ -0,0 +1,66 @@
+const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
+const MAX_ATTEMPTS = 5;
+
+interface RateLimitEntry {
+  count: number;
+  resetAt: number;
+}
+
+const store = new Map<string, RateLimitEntry>();
+
+function cleanup() {
+  const now = Date.now();
+  for (const [key, entry] of store) {
+    if (now >= entry.resetAt) {
+      store.delete(key);
+    }
+  }
+}
+
+let cleanupInterval: ReturnType<typeof setInterval> | null = null;
+
+function ensureCleanupRunning() {
+  if (cleanupInterval) return;
+  cleanupInterval = setInterval(cleanup, 5 * 60 * 1000);
+  if (typeof cleanupInterval === "object" && "unref" in cleanupInterval) {
+    cleanupInterval.unref();
+  }
+}
+
+export function checkRateLimit(key: string): {
+  allowed: boolean;
+  remaining: number;
+  retryAfterMs: number;
+} {
+  ensureCleanupRunning();
+  const now = Date.now();
+  const entry = store.get(key);
+
+  if (!entry || now >= entry.resetAt) {
+    store.set(key, { count: 1, resetAt: now + WINDOW_MS });
+    return { allowed: true, remaining: MAX_ATTEMPTS - 1, retryAfterMs: 0 };
+  }
+
+  if (entry.count >= MAX_ATTEMPTS) {
+    return {
+      allowed: false,
+      remaining: 0,
+      retryAfterMs: entry.resetAt - now,
+    };
+  }
+
+  entry.count += 1;
+  return {
+    allowed: true,
+    remaining: MAX_ATTEMPTS - entry.count,
+    retryAfterMs: 0,
+  };
+}
+
+export function getClientIp(request: Request): string {
+  const forwarded = request.headers.get("x-forwarded-for");
+  if (forwarded) {
+    return forwarded.split(",")[0].trim();
+  }
+  return "unknown";
+}

+ 26 - 0
src/lib/auth/recovery.ts

@@ -0,0 +1,26 @@
+import { randomBytes } from "crypto";
+import { hash, verify } from "@node-rs/argon2";
+import { RECOVERY_CODE_LENGTH } from "@/lib/constants";
+
+const ARGON2_OPTIONS = {
+  memoryCost: 19456, // KiB
+  timeCost: 2, // iterations
+  parallelism: 1,
+  outputLen: 32,
+} as const;
+
+export function generateRecoveryCode(): string {
+  return randomBytes(16).toString("base64url").slice(0, RECOVERY_CODE_LENGTH);
+}
+
+export async function hashRecoveryCode(code: string): Promise<string> {
+  return hash(code, ARGON2_OPTIONS);
+}
+
+export async function verifyRecoveryCode(code: string, hashedCode: string): Promise<boolean> {
+  try {
+    return await verify(hashedCode, code);
+  } catch {
+    return false;
+  }
+}

+ 6 - 3
src/lib/supabase/client.ts

@@ -1,17 +1,20 @@
 "use client";
 
 import { createBrowserClient } from "@supabase/ssr";
+import type { SupabaseClient } from "@supabase/supabase-js";
 import type { Database } from "@/types/database";
 
-let client: ReturnType<typeof createBrowserClient<Database>> | null = null;
+// @supabase/ssr 0.6.x returns a SupabaseClient with mismatched type
+// params vs supabase-js 2.101.x. Cast through unknown to work around.
+let client: SupabaseClient<Database> | null = null;
 
-export function getSupabaseBrowserClient() {
+export function getSupabaseBrowserClient(): SupabaseClient<Database> {
   if (client) return client;
 
   client = createBrowserClient<Database>(
     process.env.NEXT_PUBLIC_SUPABASE_URL!,
     process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
-  );
+  ) as unknown as SupabaseClient<Database>;
 
   return client;
 }

+ 44 - 0
src/types/database.ts

@@ -28,6 +28,7 @@ export interface Database {
           last_active_at?: string;
           created_at?: string;
         };
+        Relationships: [];
       };
       groups: {
         Row: {
@@ -51,6 +52,15 @@ export interface Database {
           created_by?: string;
           created_at?: string;
         };
+        Relationships: [
+          {
+            foreignKeyName: "groups_created_by_fkey";
+            columns: ["created_by"];
+            isOneToOne: false;
+            referencedRelation: "users";
+            referencedColumns: ["id"];
+          },
+        ];
       };
       group_members: {
         Row: {
@@ -71,6 +81,22 @@ export interface Database {
           role?: "admin" | "member";
           joined_at?: string;
         };
+        Relationships: [
+          {
+            foreignKeyName: "group_members_group_id_fkey";
+            columns: ["group_id"];
+            isOneToOne: false;
+            referencedRelation: "groups";
+            referencedColumns: ["id"];
+          },
+          {
+            foreignKeyName: "group_members_user_id_fkey";
+            columns: ["user_id"];
+            isOneToOne: false;
+            referencedRelation: "users";
+            referencedColumns: ["id"];
+          },
+        ];
       };
       movies: {
         Row: {
@@ -121,6 +147,22 @@ export interface Database {
           watched_at?: string | null;
           added_at?: string;
         };
+        Relationships: [
+          {
+            foreignKeyName: "movies_group_id_fkey";
+            columns: ["group_id"];
+            isOneToOne: false;
+            referencedRelation: "groups";
+            referencedColumns: ["id"];
+          },
+          {
+            foreignKeyName: "movies_added_by_fkey";
+            columns: ["added_by"];
+            isOneToOne: false;
+            referencedRelation: "users";
+            referencedColumns: ["id"];
+          },
+        ];
       };
       landing_reel_posters: {
         Row: {
@@ -144,10 +186,12 @@ export interface Database {
           title?: string;
           refreshed_at?: string;
         };
+        Relationships: [];
       };
     };
     Views: Record<string, never>;
     Functions: Record<string, never>;
     Enums: Record<string, never>;
+    CompositeTypes: Record<string, never>;
   };
 }