"use client"; import { useState, useEffect, useCallback, useRef, type ReactNode } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, } from "@multica/ui/components/ui/card"; import { Input } from "@multica/ui/components/ui/input"; import { Button } from "@multica/ui/components/ui/button"; import { Label } from "@multica/ui/components/ui/label"; import { InputOTP, InputOTPGroup, InputOTPSlot, } from "@multica/ui/components/ui/input-otp"; import { useAuthStore } from "@multica/core/auth"; import { workspaceKeys } from "@multica/core/workspace/queries"; import { api } from "@multica/core/api"; import type { User } from "@multica/core/types"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface GoogleAuthConfig { clientId: string; redirectUri: string; /** Opaque state passed through Google OAuth (e.g. "platform:desktop"). */ state?: string; } interface CliCallbackConfig { /** Validated localhost callback URL */ url: string; /** Opaque state to pass back to CLI */ state: string; } interface LoginPageProps { /** Logo element rendered above the title */ logo?: ReactNode; /** Called after successful login. The workspace list is seeded into React * Query before this fires, so the caller can compute a destination URL. */ onSuccess: () => void; /** Google OAuth config. Omit to disable Google login. */ google?: GoogleAuthConfig; /** CLI callback config for authorizing CLI tools. */ cliCallback?: CliCallbackConfig; /** Called after a token is obtained (e.g. to set cookies). */ onTokenObtained?: () => void; /** Override Google login handler (e.g. desktop opens browser externally). When provided, renders the Google button even if `google` config is omitted. */ onGoogleLogin?: () => void; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function redirectToCliCallback(url: string, token: string, state: string) { const separator = url.includes("?") ? "&" : "?"; window.location.href = `${url}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(state)}`; } /** * Validate that a CLI callback URL points to a safe host over HTTP. * Allows localhost and private/LAN IPs (RFC 1918) to support self-hosted setups * on local VMs while blocking arbitrary public hosts. */ export function validateCliCallback(cliCallback: string): boolean { try { const cbUrl = new URL(cliCallback); if (cbUrl.protocol !== "http:") return false; const h = cbUrl.hostname; if (h === "localhost" || h === "127.0.0.1") return true; // Allow RFC 1918 private IPs: 10.x.x.x, 172.16-31.x.x, 192.168.x.x if (/^10\./.test(h)) return true; if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true; if (/^192\.168\./.test(h)) return true; return false; } catch { return false; } } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function LoginPage({ logo, onSuccess, google, cliCallback, onTokenObtained, onGoogleLogin, }: LoginPageProps) { const qc = useQueryClient(); const [step, setStep] = useState<"email" | "code" | "cli_confirm">("email"); const [email, setEmail] = useState(""); const [code, setCode] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [cooldown, setCooldown] = useState(0); const [existingUser, setExistingUser] = useState(null); // Tracks how the existing session was detected so handleCliAuthorize // uses the matching token source (cookie → issueCliToken, localStorage → direct). const authSourceRef = useRef<"cookie" | "localStorage">("cookie"); // Check for existing session when CLI callback is present. // Prioritises cookie auth (= current browser session) to avoid authorising // the CLI with a stale or mismatched localStorage token. useEffect(() => { if (!cliCallback) return; // Ensure no stale bearer token interferes — we want to test the cookie first. api.setToken(null); api .getMe() .then((user) => { authSourceRef.current = "cookie"; setExistingUser(user); setStep("cli_confirm"); }) .catch(() => { // Cookie auth failed — fall back to localStorage token const token = localStorage.getItem("multica_token"); if (!token) return; api.setToken(token); api .getMe() .then((user) => { authSourceRef.current = "localStorage"; setExistingUser(user); setStep("cli_confirm"); }) .catch(() => { api.setToken(null); localStorage.removeItem("multica_token"); }); }); }, [cliCallback]); // Cooldown timer for resend useEffect(() => { if (cooldown <= 0) return; const timer = setTimeout(() => setCooldown((c) => c - 1), 1000); return () => clearTimeout(timer); }, [cooldown]); const handleSendCode = useCallback( async (e?: React.FormEvent) => { e?.preventDefault(); if (!email) { setError("Email is required"); return; } setLoading(true); setError(""); try { await useAuthStore.getState().sendCode(email); setStep("code"); setCode(""); setCooldown(60); } catch (err) { setError( err instanceof Error ? err.message : "Failed to send code. Make sure the server is running.", ); } finally { setLoading(false); } }, [email], ); const handleVerify = useCallback( async (value: string) => { if (value.length !== 6) return; setLoading(true); setError(""); try { if (cliCallback) { // CLI path: get token directly for the redirect URL const { token } = await api.verifyCode(email, value); localStorage.setItem("multica_token", token); api.setToken(token); onTokenObtained?.(); redirectToCliCallback(cliCallback.url, token, cliCallback.state); return; } // Normal path: seed the workspace list into the Query cache so the // caller's onSuccess can read it synchronously to compute a destination // URL (first workspace's slug, or /workspaces/new for zero-workspace // users). await useAuthStore.getState().verifyCode(email, value); const wsList = await api.listWorkspaces(); qc.setQueryData(workspaceKeys.list(), wsList); onTokenObtained?.(); onSuccess(); } catch (err) { setError( err instanceof Error ? err.message : "Invalid or expired code", ); setCode(""); setLoading(false); } }, [email, onSuccess, cliCallback, onTokenObtained, qc], ); const handleResend = async () => { if (cooldown > 0) return; setError(""); try { await useAuthStore.getState().sendCode(email); setCooldown(60); } catch (err) { setError( err instanceof Error ? err.message : "Failed to resend code", ); } }; const handleCliAuthorize = async () => { if (!cliCallback) return; setLoading(true); try { let token: string; if (authSourceRef.current === "localStorage") { // Session was detected via localStorage — reuse that token directly. const stored = localStorage.getItem("multica_token"); if (!stored) throw new Error("token missing"); token = stored; } else { // Session was detected via cookie — obtain a bearer token from the server. const res = await api.issueCliToken(); token = res.token; } onTokenObtained?.(); redirectToCliCallback(cliCallback.url, token, cliCallback.state); } catch { setError("Failed to authorize CLI. Please log in again."); setExistingUser(null); setStep("email"); setLoading(false); } }; const handleGoogleLogin = () => { if (onGoogleLogin) { onGoogleLogin(); return; } if (!google) return; const params = new URLSearchParams({ client_id: google.clientId, redirect_uri: google.redirectUri, response_type: "code", scope: "openid email profile", access_type: "offline", prompt: "select_account", }); if (google.state) params.set("state", google.state); window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`; }; // ------------------------------------------------------------------------- // CLI confirm step // ------------------------------------------------------------------------- if (step === "cli_confirm" && existingUser) { return (
{logo &&
{logo}
} Authorize CLI Allow the CLI to access Multica as{" "} {existingUser.email} ?
); } // ------------------------------------------------------------------------- // Code verification step // ------------------------------------------------------------------------- if (step === "code") { return (
{logo &&
{logo}
} Check your email We sent a verification code to{" "} {email}
{ setCode(value); if (value.length === 6) handleVerify(value); }} disabled={loading} > {error && (

{error}

)}
); } // ------------------------------------------------------------------------- // Email step // ------------------------------------------------------------------------- return (
{logo &&
{logo}
} Sign in to Multica Enter your email to get a login code
setEmail(e.target.value)} autoFocus required />
{error && (

{error}

)}
{(google || onGoogleLogin) && ( <>
or
)}
); }