evOo-local/apps/mobile/app/sign-in.tsx
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

508 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<AuthMode>(() => initialAuthMode(requestedMode));
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(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<PasswordResetCapabilities | null>(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 (
<View
style={{
flex: 1,
backgroundColor: tokens.background,
justifyContent: "center",
padding: 24,
}}
>
<Text style={{ color: tokens.mutedForeground, textAlign: "center" }}>{t("Loading…")}</Text>
</View>
);
}
if (hasSession) return <Redirect href="/" />;
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<IntegrationSetupState>("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 (
<SafeAreaView style={{ flex: 1, backgroundColor: tokens.background }}>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
<View style={{ flex: 1 }}>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
paddingHorizontal: 24,
paddingVertical: 24,
}}
keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"}
keyboardShouldPersistTaps="handled"
>
<Text
accessibilityRole="header"
style={{
color: tokens.foreground,
fontSize: 32,
fontWeight: "500",
textAlign: "center",
}}
>
{resetSent
? t("Check your email")
: mode === "in"
? t("Sign in to Rakazo")
: mode === "up"
? t("Sign up for Rakazo")
: t("Reset your password")}
</Text>
{resetSent ? (
<View style={{ alignItems: "center", marginTop: 28 }}>
<Pressable
accessibilityRole="button"
onPress={() => {
setMode("in");
setResetSent(false);
}}
>
<Text style={{ color: tokens.foreground, fontSize: 15, fontWeight: "600" }}>
{t("Back to sign in")}
</Text>
</Pressable>
</View>
) : (
<>
{mode === "up" ? (
<TextInput
autoComplete="name"
placeholder={t("Name")}
placeholderTextColor={tokens.mutedForeground}
value={name}
onChangeText={setName}
style={{
marginTop: 28,
backgroundColor: tokens.muted,
borderRadius: 13,
padding: 16,
color: tokens.foreground,
}}
/>
) : null}
<TextInput
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
placeholder={t("Email")}
placeholderTextColor={tokens.mutedForeground}
value={email}
onChangeText={setEmail}
style={{
marginTop: mode === "up" ? 12 : 28,
backgroundColor: tokens.muted,
borderRadius: 13,
padding: 16,
color: tokens.foreground,
}}
/>
{mode !== "forgot" ? (
<TextInput
autoComplete={mode === "in" ? "current-password" : "new-password"}
placeholder={t("Password")}
placeholderTextColor={tokens.mutedForeground}
returnKeyType="go"
secureTextEntry
value={password}
onChangeText={setPassword}
onSubmitEditing={() => void submit()}
style={{
marginTop: 12,
backgroundColor: tokens.muted,
borderRadius: 13,
padding: 16,
color: tokens.foreground,
}}
/>
) : null}
{error ? (
<Text style={{ color: tokens.destructive, marginTop: 12 }}>{error}</Text>
) : null}
<Pressable
accessibilityRole="button"
onPress={() => void submit()}
disabled={pending}
style={{
marginTop: 16,
backgroundColor: tokens.primary,
borderRadius: 13,
padding: 18,
alignItems: "center",
}}
>
<Text style={{ color: tokens.primaryForeground, fontSize: 17 }}>
{pending
? t("Working…")
: mode === "in"
? t("Sign in")
: mode === "up"
? t("Sign up")
: t("Send reset link")}
</Text>
</Pressable>
{mode === "in" && reset?.passwordReset && reset.resetUrl ? (
<Pressable
accessibilityRole="button"
hitSlop={8}
onPress={() => {
setMode("forgot");
setError(null);
}}
style={{ alignSelf: "center", marginTop: 16 }}
>
<Text style={{ color: tokens.foreground, fontSize: 14, fontWeight: "600" }}>
{t("Forgot password?")}
</Text>
</Pressable>
) : null}
<View
style={{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
marginTop: 24,
}}
>
<Text style={{ color: tokens.mutedForeground, fontSize: 15 }}>
{mode === "in"
? t("Don’t have an account?")
: mode === "up"
? t("Already have an account?")
: ""}
</Text>
<Pressable
accessibilityRole="button"
hitSlop={8}
onPress={() => {
setMode((current) => (current === "in" ? "up" : "in"));
setError(null);
}}
style={{ marginLeft: 5 }}
>
<Text style={{ color: tokens.foreground, fontSize: 15, fontWeight: "600" }}>
{mode === "in"
? t("Sign up")
: mode === "up"
? t("Sign in")
: t("Back to sign in")}
</Text>
</Pressable>
</View>
</>
)}
</ScrollView>
<Pressable
accessibilityRole="button"
accessibilityLabel={
custom
? t("Custom server {host}", { host: displayApiHost(apiBase) })
: t("Use a custom server")
}
hitSlop={12}
onPress={() => setServerOpen(true)}
style={{
alignItems: "center",
paddingHorizontal: 24,
paddingBottom: 12,
paddingTop: 8,
}}
>
{custom ? (
<>
<Text style={{ color: tokens.mutedForeground, fontSize: 12 }}>
{t("Custom server")}
</Text>
<Text style={{ color: tokens.mutedForeground, fontSize: 13, marginTop: 2 }}>
{displayApiHost(apiBase)}
</Text>
</>
) : (
<Text style={{ color: tokens.mutedForeground, fontSize: 13 }}>
{t("Use a custom server")}
</Text>
)}
</Pressable>
</View>
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
<ServerSheet
visible={serverOpen}
current={apiBase}
onClose={() => setServerOpen(false)}
onSaved={(url) => {
setApiBase(url);
setServerOpen(false);
}}
/>
</SafeAreaView>
);
}
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<string | null>(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 (
<Modal
visible={visible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: tokens.background }}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<SafeAreaView style={{ flex: 1, paddingHorizontal: 24, paddingTop: 12 }}>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Pressable onPress={onClose} hitSlop={8}>
<Text style={{ color: tokens.mutedForeground, fontSize: 17 }}>{t("Cancel")}</Text>
</Pressable>
<Text style={{ color: tokens.foreground, fontSize: 17, fontWeight: "600" }}>
{t("Server")}
</Text>
<Pressable onPress={() => void save()} disabled={pending} hitSlop={8}>
<Text style={{ color: tokens.foreground, fontSize: 17, fontWeight: "600" }}>
{pending ? t("Checking…") : t("Save")}
</Text>
</Pressable>
</View>
<Text
style={{ color: tokens.mutedForeground, marginTop: 28, fontSize: 15, lineHeight: 22 }}
>
{t("Enter your Rakazo server address.")}
</Text>
<TextInput
autoCapitalize="none"
autoCorrect={false}
autoComplete="off"
keyboardType="url"
textContentType="URL"
returnKeyType="go"
onSubmitEditing={() => 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 ? (
<Text style={{ color: tokens.mutedForeground, marginTop: 12, fontSize: 13 }}>
{warning}
</Text>
) : null}
{error ? <Text style={{ color: tokens.destructive, marginTop: 12 }}>{error}</Text> : null}
{usesCustomApiBase(current) || draft.trim() !== current ? (
<Pressable
onPress={() => void restoreDefault()}
disabled={pending}
style={{ marginTop: 28, alignItems: "center" }}
>
<Text style={{ color: tokens.mutedForeground, fontSize: 15 }}>
{t("Use default server")}
</Text>
</Pressable>
) : null}
</SafeAreaView>
</KeyboardAvoidingView>
</Modal>
);
}