import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useSearchParams } from "@/lib/router"; import { authApi } from "../api/auth"; import { healthApi } from "../api/health"; import { CloudSignIn } from "@/components/CloudSignIn"; import { clearCloudSignInAttempt } from "@/lib/cloud-sign-in"; import { tenantSignInReturnPath } from "@/lib/cloudLinks"; import { queryKeys } from "../lib/queryKeys"; import { getRememberedInvitePath } from "../lib/invite-memory"; import { Button } from "@/components/ui/button"; import { AsciiArtAnimation } from "@/components/AsciiArtAnimation"; import { PaperclipLoading } from "@/components/AnimatedPaperclipIcon"; import { ThemeToggle } from "@/components/ThemeToggle"; import { PaperclipLockup } from "../components/PaperclipLockup"; type AuthMode = "sign_in" | "sign_up"; export function AuthPage() { const queryClient = useQueryClient(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [mode, setMode] = useState("sign_in"); const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const errorId = "auth-error"; const nextPath = useMemo( () => tenantSignInReturnPath(searchParams.get("next") || getRememberedInvitePath() || "/"), [searchParams], ); const healthQuery = useQuery({ queryKey: queryKeys.health, queryFn: () => healthApi.get(), retry: false, }); const { data: session, isLoading: isSessionLoading, error: sessionError } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), retry: false, }); useEffect(() => { if (session) { clearCloudSignInAttempt(); navigate(nextPath, { replace: true }); } }, [session, navigate, nextPath]); const mutation = useMutation({ mutationFn: async () => { if (mode === "sign_in") { await authApi.signInEmail({ email: email.trim(), password }); return; } await authApi.signUpEmail({ name: name.trim(), email: email.trim(), password, }); }, onSuccess: async () => { setError(null); await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); await queryClient.invalidateQueries({ queryKey: queryKeys.health }); // Reset rather than invalidate: the `["companies"]` entry is shared app-wide and // is not account-scoped, so invalidating leaves the previous account's list // readable (and any fetch for that session in flight) until the refetch lands. // Sign-in can change accounts, so drop the list outright. await queryClient.resetQueries({ queryKey: queryKeys.companies.all }); navigate(nextPath, { replace: true }); }, onError: (err) => { setError(err instanceof Error ? err.message : "Authentication failed"); }, }); const canSubmit = email.trim().length > 0 && password.trim().length > 0 && (mode === "sign_in" || (name.trim().length > 0 && password.trim().length >= 8)); if (healthQuery.isLoading || isSessionLoading || session) { return (
); } // A health/session failure must not be mistaken for a self-hosted instance. if (healthQuery.error || sessionError) { return

Unable to check sign-in. Refresh and try again.

; } if (healthQuery.data?.cloud) { return ; } return (
{/* Left half — form */}

{mode === "sign_in" ? "Sign in to Paperclip" : "Create your Paperclip account"}

{mode === "sign_in" ? "Use your email and password to access this instance." : "Create an account for this instance. Email confirmation is not required in v1."}

{ event.preventDefault(); if (mutation.isPending) return; if (!canSubmit) { setError("Please fill in all required fields."); return; } mutation.mutate(); }} > {mode === "sign_up" && (
setName(event.target.value)} autoComplete="name" required aria-required="true" aria-invalid={error ? true : undefined} aria-describedby={error ? errorId : undefined} autoFocus />
)}
setEmail(event.target.value)} autoComplete="username" required aria-required="true" aria-invalid={error ? true : undefined} aria-describedby={error ? errorId : undefined} autoFocus={mode === "sign_in"} />
setPassword(event.target.value)} autoComplete={mode === "sign_in" ? "current-password" : "new-password"} required aria-required="true" aria-invalid={error ? true : undefined} aria-describedby={error ? errorId : undefined} />
{error && ( )}
{mode === "sign_in" ? "Need an account?" : "Already have an account?"}{" "}
{/* Right half — ASCII art animation (hidden on mobile) */}
); }