import type { IntegrationSetupState } from "@rakazo/contracts"; import { Redirect, useLocalSearchParams, useRouter } from "expo-router"; import { useEffect, useState } from "react"; import { AccessibilityInfo, Keyboard, KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, Text, TextInput, TouchableWithoutFeedback, View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { apiBaseWarning, currentApiBase, defaultApiBase, displayApiHost, loadSessionToken, normalizeApiBase, type PasswordResetCapabilities, passwordResetCapabilities, probeApiBase, requestPasswordReset, resetApiBase, rpc, saveApiBase, signIn, signUp, usesCustomApiBase, } from "../lib/api"; import { type AuthMode, initialAuthMode } from "../lib/auth-routing"; import { useI18n } from "../lib/i18n"; import { useMobileTokens } from "../lib/native"; export default function SignIn() { const { t } = useI18n(); const tokens = useMobileTokens(); const router = useRouter(); const { mode: requestedMode } = useLocalSearchParams<{ mode?: string | string[] }>(); const [mode, setMode] = useState(() => initialAuthMode(requestedMode)); const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [pending, setPending] = useState(false); const [ready, setReady] = useState(false); const [hasSession, setHasSession] = useState(false); const [apiBase, setApiBase] = useState(() => currentApiBase()); const [serverOpen, setServerOpen] = useState(false); const [reset, setReset] = useState(null); const [resetSent, setResetSent] = useState(false); useEffect(() => { void loadSessionToken().then((token) => { setHasSession(Boolean(token)); setReady(true); }); }, []); useEffect(() => { let active = true; setReset(null); void passwordResetCapabilities() .then((capabilities) => { if (active) setReset(capabilities); }) .catch(() => undefined); return () => { active = false; }; }, [apiBase]); useEffect(() => { if (resetSent) AccessibilityInfo.announceForAccessibility(t("Check your email")); }, [resetSent, t]); if (!ready) { return ( {t("Loading…")} ); } if (hasSession) return ; async function submit() { if (pending) return; setPending(true); setError(null); try { if (mode === "forgot") { if (!reset?.passwordReset || !reset.resetUrl) { throw new Error(t("Password recovery is not configured for this server")); } await requestPasswordReset(email.trim(), reset.resetUrl); setResetSent(true); return; } if (mode === "up") { const trimmedEmail = email.trim(); const result = await signUp( trimmedEmail, password, name.trim() || trimmedEmail.split("@")[0] || "User", ); if (result.verificationRequired) { setResetSent(true); return; } } else { await signIn(email.trim(), password); } const setup = mode === "up" ? await rpc("integrationSetup/get").catch(() => null) : null; router.replace(setup?.needsSetup ? "/integration-setup" : "/"); } catch (err) { setError(err instanceof Error ? err.message : t("Could not continue")); } finally { setPending(false); } } const custom = usesCustomApiBase(apiBase); return ( {resetSent ? t("Check your email") : mode === "in" ? t("Sign in to Rakazo") : mode === "up" ? t("Sign up for Rakazo") : t("Reset your password")} {resetSent ? ( { setMode("in"); setResetSent(false); }} > {t("Back to sign in")} ) : ( <> {mode === "up" ? ( ) : null} {mode !== "forgot" ? ( void submit()} style={{ marginTop: 12, backgroundColor: tokens.muted, borderRadius: 13, padding: 16, color: tokens.foreground, }} /> ) : null} {error ? ( {error} ) : null} void submit()} disabled={pending} style={{ marginTop: 16, backgroundColor: tokens.primary, borderRadius: 13, padding: 18, alignItems: "center", }} > {pending ? t("Working…") : mode === "in" ? t("Sign in") : mode === "up" ? t("Sign up") : t("Send reset link")} {mode === "in" && reset?.passwordReset && reset.resetUrl ? ( { setMode("forgot"); setError(null); }} style={{ alignSelf: "center", marginTop: 16 }} > {t("Forgot password?")} ) : null} {mode === "in" ? t("Don’t have an account?") : mode === "up" ? t("Already have an account?") : ""} { setMode((current) => (current === "in" ? "up" : "in")); setError(null); }} style={{ marginLeft: 5 }} > {mode === "in" ? t("Sign up") : mode === "up" ? t("Sign in") : t("Back to sign in")} )} setServerOpen(true)} style={{ alignItems: "center", paddingHorizontal: 24, paddingBottom: 12, paddingTop: 8, }} > {custom ? ( <> {t("Custom server")} {displayApiHost(apiBase)} ) : ( {t("Use a custom server")} )} setServerOpen(false)} onSaved={(url) => { setApiBase(url); setServerOpen(false); }} /> ); } function ServerSheet({ visible, current, onClose, onSaved, }: { visible: boolean; current: string; onClose: () => void; onSaved: (url: string) => void; }) { const { t } = useI18n(); const tokens = useMobileTokens(); const [draft, setDraft] = useState(current); const [error, setError] = useState(null); const [pending, setPending] = useState(false); useEffect(() => { if (!visible) return; setDraft(current); setError(null); setPending(false); }, [visible, current]); const parsedDraft = normalizeApiBase(draft); const warning = parsedDraft.ok ? apiBaseWarning(parsedDraft.url) : null; async function save() { setPending(true); setError(null); try { const probed = await probeApiBase(draft); if (!probed.ok) { setError(probed.error); return; } const saved = await saveApiBase(probed.url); if (!saved.ok) { setError(saved.error); return; } onSaved(saved.url); } finally { setPending(false); } } async function restoreDefault() { setPending(true); setError(null); try { const saved = await resetApiBase(); if (!saved.ok) { setError(saved.error); return; } onSaved(saved.url); } finally { setPending(false); } } return ( {t("Cancel")} {t("Server")} void save()} disabled={pending} hitSlop={8}> {pending ? t("Checking…") : t("Save")} {t("Enter your Rakazo server address.")} void save()} placeholder={defaultApiBase()} placeholderTextColor={tokens.mutedForeground} value={draft} onChangeText={(value) => { setDraft(value); setError(null); }} style={{ marginTop: 20, backgroundColor: tokens.muted, borderRadius: 13, padding: 16, color: tokens.foreground, fontSize: 16, }} /> {warning ? ( {warning} ) : null} {error ? {error} : null} {usesCustomApiBase(current) || draft.trim() !== current ? ( void restoreDefault()} disabled={pending} style={{ marginTop: 28, alignItems: "center" }} > {t("Use default server")} ) : null} ); }