import { Trans, useLingui } from "@lingui/react/macro"; import { readBoundedJsonResponse, signupRequiresEmailVerification } from "@rakazo/core"; import { Button, Input, Label } from "@rakazo/ui-web"; import { Eye, EyeOff } from "lucide-react"; import { useEffect, useState } from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { authClient } from "../lib/auth"; import { clearSpaceSelection } from "../lib/rpc"; type AuthMode = "in" | "up" | "forgot"; type PasswordResetCapabilities = { passwordReset: boolean; resetUrl: string | null }; const fieldClass = "mt-2 h-12 rounded-xl px-4 text-base md:text-base"; const submitClass = "mt-3 h-12 w-full rounded-xl text-base"; const AUTH_CAPABILITIES_TIMEOUT_MS = 8_000; const MAX_AUTH_CAPABILITIES_RESPONSE_BYTES = 64 * 1024; export function AuthPage({ mode }: { mode: AuthMode }) { const { t } = useLingui(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [name, setName] = useState(""); const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(null); const [pending, setPending] = useState(false); const [resetSent, setResetSent] = useState(false); // Signup triggers a session refresh that remounts the anonymous auth page. const sent = resetSent || searchParams.get("verify") === "email"; const [reset, setReset] = useState(null); const passwordFieldId = mode === "in" ? "current-password" : "new-password"; const title = sent ? ( Check your email ) : mode === "in" ? ( Sign in to EvOo ) : mode === "up" ? ( Create your EvOo ) : ( Reset your password ); useEffect(() => { if (mode === "up") return; let active = true; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), AUTH_CAPABILITIES_TIMEOUT_MS); void fetch("/api/auth/capabilities", { signal: controller.signal }) .then(async (response) => { if (!response.ok) throw new Error("Could not load authentication capabilities"); return readBoundedJsonResponse( response, MAX_AUTH_CAPABILITIES_RESPONSE_BYTES, controller.signal, ); }) .then((capabilities) => { if (active) setReset(capabilities); }) .catch(() => undefined) .finally(() => clearTimeout(timer)); return () => { active = false; clearTimeout(timer); controller.abort(); }; }, [mode]); async function submit(e: React.FormEvent) { e.preventDefault(); setPending(true); setError(null); try { if (mode === "forgot") { if (!reset?.passwordReset || !reset.resetUrl) { setError(t`Password recovery is not configured for this server`); return; } const result = await authClient.requestPasswordReset({ email: email.trim(), redirectTo: reset.resetUrl, }); if (result.error) { setError(result.error.message ?? t`Could not send reset email`); return; } setResetSent(true); return; } const cleanEmail = email.trim().toLowerCase(); const cleanPassword = password.trim(); const result = mode === "up" ? await authClient.signUp.email({ email: cleanEmail, password: cleanPassword, name: name || cleanEmail.split("@")[0] || "User", }) : await authClient.signIn.email({ email: cleanEmail, password: cleanPassword }); if (result.error) { setError(result.error.message ?? t`Could not continue`); return; } if (mode === "up" && signupRequiresEmailVerification(result.data)) { setSearchParams({ verify: "email" }); return; } clearSpaceSelection(); navigate( mode === "up" ? "/onboarding" : searchParams.get("next") === "/integrations/setup" ? "/integrations/setup" : "/app", ); } catch { setError(t`Could not reach the server`); } finally { setPending(false); } } return ( {sent ? (
Back to sign in
) : ( <> {mode === "up" ? (
setName(e.target.value)} placeholder={t`Your name`} className={fieldClass} />
) : null}
setEmail(e.target.value)} placeholder={t`Your email address`} type="email" required className={fieldClass} />
{mode !== "forgot" ? (
setPassword(e.target.value)} placeholder={t`Password`} type={showPassword ? "text" : "password"} required minLength={8} className={`${fieldClass} pr-12`} />
{mode === "in" && reset?.passwordReset ? (
Forgot password?
) : null}
) : null} {error ? (

{error}

) : null}

{mode === "in" ? ( <> Don’t have an account?{" "} Sign up ) : mode === "up" ? ( <> Already have an account?{" "} Sign in ) : ( Back to sign in )}

)}
); } export function PasswordResetPage() { const { t } = useLingui(); const [params] = useSearchParams(); const [password, setPassword] = useState(""); const [confirmation, setConfirmation] = useState(""); const [pending, setPending] = useState(false); const [complete, setComplete] = useState(false); const [error, setError] = useState( params.get("error") || !params.get("token") ? t`This reset link is invalid or expired` : null, ); async function submit(event: React.FormEvent) { event.preventDefault(); const token = params.get("token"); if (!token) return; if (password !== confirmation) { setError(t`Passwords do not match`); return; } setPending(true); setError(null); try { const result = await authClient.resetPassword({ newPassword: password, token }); if (result.error) { setError(result.error.message ?? t`Could not reset password`); return; } setComplete(true); } catch { setError(t`Could not reach the server`); } finally { setPending(false); } } return ( Choose a new password}> {complete ? (

Password updated

Sign in
) : ( <> {error ? (

{error}

) : null} Back to sign in )}
); } function AuthFrame({ title, onSubmit, children, }: { title: React.ReactNode; onSubmit: (event: React.FormEvent) => void; children: React.ReactNode; }) { return (

{title}

{children}
); } function PasswordField({ id, label, value, onChange, className = "", }: { id: string; label: string; value: string; onChange: (value: string) => void; className?: string; }) { return (
onChange(event.target.value)} className={fieldClass} />
); }