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 ( +
{error}
+ )} +