From 8366e91b952f3c645e106f80bd38bb12626bbb6c Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:40:54 +0800 Subject: [PATCH] fix(auth): restore email verification login flow from main The frontend ApiClient had a non-existent `/auth/login` endpoint. Restored the two-step `sendCode` + `verifyCode` flow matching the backend, including OTP input UI and CLI browser login callback support. Also restored `IF NOT EXISTS` in migration 012 to prevent failures on databases where the column already exists. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/app/(auth)/login/page.tsx | 179 +++++++++++++++++++---- apps/web/features/auth/store.ts | 11 +- apps/web/shared/api/client.ts | 13 +- server/migrations/012_inbox_actor.up.sql | 4 +- 4 files changed, 174 insertions(+), 33 deletions(-) diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 4fab6b0c..0674c037 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { Suspense, useState } from "react"; +import { Suspense, useState, useEffect, useCallback } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; @@ -16,20 +16,34 @@ import { import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; function LoginPageContent() { const router = useRouter(); - const login = useAuthStore((s) => s.login); - const isLoading = useAuthStore((s) => s.isLoading); + const sendCode = useAuthStore((s) => s.sendCode); + const verifyCode = useAuthStore((s) => s.verifyCode); const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace); const searchParams = useSearchParams(); + + const [step, setStep] = useState<"email" | "code">("email"); const [email, setEmail] = useState(""); - const [name, setName] = useState(""); + const [code, setCode] = useState(""); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); + const [cooldown, setCooldown] = useState(0); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + useEffect(() => { + if (cooldown <= 0) return; + const timer = setTimeout(() => setCooldown((c) => c - 1), 1000); + return () => clearTimeout(timer); + }, [cooldown]); + + const handleSendCode = async (e?: React.FormEvent) => { + e?.preventDefault(); if (!email) { setError("Email is required"); return; @@ -37,16 +51,141 @@ function LoginPageContent() { setError(""); setSubmitting(true); try { - await login(email, name || undefined); - const wsList = await api.listWorkspaces(); - await hydrateWorkspace(wsList); - router.push(searchParams.get("next") || "/issues"); - } catch { - setError("Login failed. Make sure the server is running."); + await sendCode(email); + setStep("code"); + setCode(""); + setCooldown(10); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to send code. Make sure the server is running." + ); + } finally { setSubmitting(false); } }; + const handleVerifyCode = useCallback( + async (value: string) => { + if (value.length !== 6) return; + setError(""); + setSubmitting(true); + try { + const cliCallback = searchParams.get("cli_callback"); + if (cliCallback) { + try { + const cbUrl = new URL(cliCallback); + if (cbUrl.protocol !== "http:") { + setError("Invalid callback URL"); + setSubmitting(false); + return; + } + if (cbUrl.hostname !== "localhost" && cbUrl.hostname !== "127.0.0.1") { + setError("Invalid callback URL"); + setSubmitting(false); + return; + } + } catch { + setError("Invalid callback URL"); + setSubmitting(false); + return; + } + const { token } = await api.verifyCode(email, value); + const cliState = searchParams.get("cli_state") || ""; + const separator = cliCallback.includes("?") ? "&" : "?"; + window.location.href = `${cliCallback}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(cliState)}`; + return; + } + + await verifyCode(email, value); + const wsList = await api.listWorkspaces(); + await hydrateWorkspace(wsList); + router.push(searchParams.get("next") || "/issues"); + } catch (err) { + setError( + err instanceof Error ? err.message : "Invalid or expired code" + ); + setCode(""); + setSubmitting(false); + } + }, + [email, verifyCode, hydrateWorkspace, router, searchParams] + ); + + const handleResend = async () => { + if (cooldown > 0) return; + setError(""); + try { + await sendCode(email); + setCooldown(10); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to resend code" + ); + } + }; + + if (step === "code") { + return ( +
+ + + Check your email + + We sent a verification code to{" "} + {email} + + + + { + setCode(value); + if (value.length === 6) handleVerifyCode(value); + }} + disabled={submitting} + > + + + + + + + + + + {error && ( +

{error}

+ )} +
+ +
+
+ + + +
+
+ ); + } + return (
@@ -55,17 +194,7 @@ function LoginPageContent() { AI-native task management -
-
- - setName(e.target.value)} - /> -
+
- {submitting ? "Signing in..." : "Sign in"} + {submitting ? "Sending code..." : "Continue"} diff --git a/apps/web/features/auth/store.ts b/apps/web/features/auth/store.ts index 2cbf2e95..9957305e 100644 --- a/apps/web/features/auth/store.ts +++ b/apps/web/features/auth/store.ts @@ -9,7 +9,8 @@ interface AuthState { isLoading: boolean; initialize: () => Promise; - login: (email: string, name?: string) => Promise; + sendCode: (email: string) => Promise; + verifyCode: (email: string, code: string) => Promise; logout: () => void; setUser: (user: User) => void; } @@ -39,8 +40,12 @@ export const useAuthStore = create((set) => ({ } }, - login: async (email: string, name?: string) => { - const { token, user } = await api.login(email, name); + sendCode: async (email: string) => { + await api.sendCode(email); + }, + + verifyCode: async (email: string, code: string) => { + const { token, user } = await api.verifyCode(email, code); localStorage.setItem("multica_token", token); api.setToken(token); set({ user }); diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 259e57ae..bef724c8 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -112,10 +112,17 @@ export class ApiClient { } // Auth - async login(email: string, name?: string): Promise { - return this.fetch("/auth/login", { + async sendCode(email: string): Promise { + await this.fetch("/auth/send-code", { method: "POST", - body: JSON.stringify({ email, name }), + body: JSON.stringify({ email }), + }); + } + + async verifyCode(email: string, code: string): Promise { + return this.fetch("/auth/verify-code", { + method: "POST", + body: JSON.stringify({ email, code }), }); } diff --git a/server/migrations/012_inbox_actor.up.sql b/server/migrations/012_inbox_actor.up.sql index 81afe758..5784870b 100644 --- a/server/migrations/012_inbox_actor.up.sql +++ b/server/migrations/012_inbox_actor.up.sql @@ -1,2 +1,2 @@ -ALTER TABLE inbox_item ADD COLUMN actor_type TEXT; -ALTER TABLE inbox_item ADD COLUMN actor_id UUID; +ALTER TABLE inbox_item ADD COLUMN IF NOT EXISTS actor_type TEXT; +ALTER TABLE inbox_item ADD COLUMN IF NOT EXISTS actor_id UUID;