/** * Email/password auth form body, without any surrounding chrome. The dialog * wraps it in a `DialogFrame`; the hosted-terminal sign-in gate wraps it in its * own panel. All the non-React logic lives in `auth-model`. */ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { apiClient, type AuthUser } from "../../../api-client"; import { matchesKeyChord, parseKeyChord } from "../../../app/keybindings"; import { Button, Spinner, TextField } from "../../../components"; import { t, tf } from "../../../i18n"; import { useAppLanguage } from "../../../i18n/react"; import { colors } from "../../../theme/colors"; import { Box, Text, TextAttributes, type InputRenderable } from "../../../ui"; import { useDialogKeyboard } from "../../../ui/dialog"; import { isPlainKey } from "../../../utils/keyboard"; import { advanceAccountField, classifyAccountError, performEmailAuth, validateAccountEmail, validateAccountPassword, type AccountMode, type AccountSubmitError, } from "./auth-model"; export const AUTH_FIELD_WIDTH = 42; type AuthField = "email" | "password"; type ResetState = "idle" | "sending" | "sent"; /** * The form's secondary actions. A field always has the keyboard here, so each * takes a Control chord that no field uses for editing, shown on its button. * Control on every host, like the other in-form chords (Ctrl+S save). */ const SWITCH_MODE_KEY = "Ctrl+L"; const RESET_PASSWORD_KEY = "Ctrl+R"; const REVEAL_PASSWORD_KEY = "Ctrl+O"; const SWITCH_MODE_CHORD = parseKeyChord(SWITCH_MODE_KEY)!; const RESET_PASSWORD_CHORD = parseKeyChord(RESET_PASSWORD_KEY)!; const REVEAL_PASSWORD_CHORD = parseKeyChord(REVEAL_PASSWORD_KEY)!; export interface AuthFormProps { initialMode: AccountMode; /** Called once the session exists. */ onSignedIn: (user: AuthUser) => void; /** Omitted where the form cannot be abandoned, e.g. the sign-in gate. */ onEscape?: () => void; /** * Groups this form's keys with the surface that owns it. Left unset inside a * dialog, where `useDialogKeyboard` falls back to the dialog's own scope. */ shortcutScope?: string; /** Fires when the user flips between sign-up and log in, so the host can retitle. */ onModeChange?: (mode: AccountMode) => void; /** Extra actions below the buttons, e.g. the gate's QR alternative. */ footer?: ReactNode; } export function AuthForm({ initialMode, onSignedIn, onEscape, shortcutScope, onModeChange, footer, }: AuthFormProps) { useAppLanguage(); const [mode, setMode] = useState(initialMode); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [activeField, setActiveField] = useState("email"); const [showPassword, setShowPassword] = useState(false); const [submitting, setSubmitting] = useState(false); const [validationError, setValidationError] = useState(null); const [submitError, setSubmitError] = useState(null); const [resetState, setResetState] = useState("idle"); const attemptRef = useRef(0); const emailInputRef = useRef(null); const passwordInputRef = useRef(null); /** * Moves the keyboard to a field. Focusing directly as well as through the * `focused` prop brings it back when a clicked button holds the focus and * the field is already the active one. */ const focusField = useCallback((field: AuthField) => { setActiveField(field); if (!submitting) (field === "email" ? emailInputRef : passwordInputRef).current?.focus?.(); }, [submitting]); const clearErrors = useCallback(() => { setValidationError(null); setSubmitError(null); }, []); useEffect(() => () => { // Unmounting abandons any in-flight attempt so a late response can't set state. attemptRef.current += 1; }, []); const switchMode = useCallback((nextMode: AccountMode) => { attemptRef.current += 1; setMode(nextMode); setSubmitting(false); setPassword(""); setResetState("idle"); clearErrors(); setActiveField(email.trim() ? "password" : "email"); onModeChange?.(nextMode); }, [clearErrors, email, onModeChange]); const requestReset = useCallback(() => { if (submitting || resetState === "sending") return; const trimmedEmail = email.trim(); const emailError = validateAccountEmail(trimmedEmail); if (emailError) { setActiveField("email"); setValidationError(emailError); return; } setResetState("sending"); clearErrors(); void apiClient.requestPasswordReset(trimmedEmail) .then(() => setResetState("sent")) .catch(() => { setResetState("idle"); setSubmitError({ message: t("Could not send the reset email."), kind: "retry" }); }); }, [clearErrors, email, resetState, submitting]); const submit = useCallback(() => { if (submitting) return; const trimmedEmail = email.trim(); const emailError = validateAccountEmail(trimmedEmail); if (emailError) { setActiveField("email"); setValidationError(emailError); return; } const passwordError = validateAccountPassword(password, mode); if (passwordError) { setActiveField("password"); setValidationError(passwordError); return; } const attemptId = attemptRef.current + 1; attemptRef.current = attemptId; setSubmitting(true); clearErrors(); void (async () => { try { const user = await performEmailAuth(mode, trimmedEmail, password); if (attemptRef.current !== attemptId) return; onSignedIn(user); } catch (error) { if (attemptRef.current !== attemptId) return; setSubmitting(false); setSubmitError(classifyAccountError(error, mode)); } })(); }, [clearErrors, email, mode, onSignedIn, password, submitting]); const submitField = useCallback(() => { // The email already has an account: Enter carries it over to log in. if (submitError?.kind === "switch-to-login" && !submitting) { switchMode("login"); return; } const advance = advanceAccountField({ mode, email: email.trim(), password, fieldIdx: activeField === "email" ? 0 : 1, }); if (advance.action === "invalid") { setValidationError(advance.message); return; } if (advance.action === "next-field") { setValidationError(null); setActiveField("password"); return; } submit(); }, [activeField, email, mode, password, submit, submitError, submitting, switchMode]); useDialogKeyboard((event) => { if (event.name === "escape") { if (!onEscape) return; event.stopPropagation?.(); onEscape(); return; } const consume = () => { event.preventDefault?.(); event.stopPropagation?.(); }; if (matchesKeyChord(SWITCH_MODE_CHORD, event)) { consume(); if (!submitting) switchMode(mode === "login" ? "signup" : "login"); return; } if (matchesKeyChord(RESET_PASSWORD_CHORD, event) && mode === "login") { consume(); requestReset(); return; } if (matchesKeyChord(REVEAL_PASSWORD_CHORD, event)) { consume(); setShowPassword((current) => !current); return; } const shiftTab = event.name === "tab" && event.shift && !event.ctrl && !event.alt && !event.meta && !event.super; const forward = isPlainKey(event, "tab") || (!event.targetEditable && isPlainKey(event, "down", "j")); const backward = shiftTab || (!event.targetEditable && isPlainKey(event, "up", "k")); if (forward || backward) { consume(); focusField(forward ? "password" : "email"); } }, { allowEditable: true, scope: shortcutScope, phase: shortcutScope ? "before" : undefined, }); const switchToLogin = submitError?.kind === "switch-to-login"; const error = validationError ?? submitError?.message ?? null; return ( setActiveField("email")} onChange={(value) => { setEmail(value); setResetState("idle"); clearErrors(); }} onSubmit={submitField} /> {t("Password")}