0b2d65a4e9
Mobile app detects SSO per server, supports OAuth sign-in, and preserves saved sessions when adding accounts. Tab screens get proper chrome layout and tab-bar clearance with scrollable page headers. Co-authored-by: Cursor <cursoragent@cursor.com>
233 lines
6.5 KiB
TypeScript
233 lines
6.5 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import { useEffect, useState } from "react";
|
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
|
|
|
import { Input } from "@/components/ui/Input";
|
|
import { fonts, spacing } from "@/constants/theme";
|
|
import { useAccounts } from "@/contexts/AccountsContext";
|
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
|
import { DEFAULT_API_URL } from "@/lib/config";
|
|
import {
|
|
formatServerHost,
|
|
isServerConfigValid,
|
|
resolveServerMode,
|
|
resolveServerUrl,
|
|
SERVER_MODE_OPTIONS,
|
|
type ServerMode,
|
|
} from "@/lib/server-mode";
|
|
|
|
type AuthServerPickerProps = {
|
|
onReadyChange?: (ready: boolean) => void;
|
|
/** When true, picker sits inside the auth card with no outer margin. */
|
|
embedded?: boolean;
|
|
};
|
|
|
|
function modeSummary(mode: ServerMode, selfHostedUrl: string) {
|
|
if (mode === "official") return "Official";
|
|
const host = formatServerHost(selfHostedUrl);
|
|
return host || "Self-hosted";
|
|
}
|
|
|
|
export function AuthServerPicker({ onReadyChange, embedded = false }: AuthServerPickerProps) {
|
|
const { colors } = useAppTheme();
|
|
const { apiUrl, setInstanceUrl } = useAccounts();
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [mode, setMode] = useState<ServerMode>(() => resolveServerMode(apiUrl));
|
|
const [selfHostedUrl, setSelfHostedUrl] = useState(() =>
|
|
resolveServerMode(apiUrl) === "self-hosted" ? apiUrl : "",
|
|
);
|
|
const [urlError, setUrlError] = useState<string | null>(null);
|
|
|
|
const ready = isServerConfigValid(mode, selfHostedUrl);
|
|
|
|
useEffect(() => {
|
|
onReadyChange?.(ready);
|
|
}, [ready, onReadyChange]);
|
|
|
|
useEffect(() => {
|
|
const nextMode = resolveServerMode(apiUrl);
|
|
setMode(nextMode);
|
|
if (nextMode === "self-hosted") {
|
|
setSelfHostedUrl(apiUrl);
|
|
}
|
|
}, [apiUrl]);
|
|
|
|
async function applyMode(nextMode: ServerMode) {
|
|
setMode(nextMode);
|
|
setUrlError(null);
|
|
|
|
if (nextMode === "official") {
|
|
try {
|
|
await setInstanceUrl(DEFAULT_API_URL);
|
|
setExpanded(false);
|
|
} catch (err) {
|
|
setUrlError(err instanceof Error ? err.message : "Could not set server");
|
|
}
|
|
return;
|
|
}
|
|
|
|
setExpanded(true);
|
|
const resolved = resolveServerUrl("self-hosted", selfHostedUrl);
|
|
if (!resolved) return;
|
|
|
|
try {
|
|
await setInstanceUrl(resolved);
|
|
} catch (err) {
|
|
setUrlError(err instanceof Error ? err.message : "Could not set server");
|
|
}
|
|
}
|
|
|
|
async function commitSelfHostedUrl() {
|
|
const resolved = resolveServerUrl("self-hosted", selfHostedUrl);
|
|
if (!resolved) {
|
|
setUrlError("Enter a valid server URL (e.g. beenvoice.app or localhost:3000)");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const saved = await setInstanceUrl(resolved);
|
|
setSelfHostedUrl(saved);
|
|
setUrlError(null);
|
|
setExpanded(false);
|
|
} catch (err) {
|
|
setUrlError(err instanceof Error ? err.message : "Could not save server URL");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<View style={[styles.wrapper, embedded && styles.wrapperEmbedded]}>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityState={{ expanded }}
|
|
onPress={() => setExpanded((open) => !open)}
|
|
hitSlop={8}
|
|
style={({ pressed }) => [styles.trigger, pressed && styles.pressed]}
|
|
>
|
|
<Text style={[styles.triggerText, { color: colors.mutedForeground }]}>
|
|
Server ·{" "}
|
|
<Text style={[styles.summary, { color: colors.foreground }]}>
|
|
{modeSummary(mode, selfHostedUrl)}
|
|
</Text>
|
|
</Text>
|
|
<Ionicons
|
|
name={expanded ? "chevron-up" : "chevron-down"}
|
|
size={16}
|
|
color={colors.mutedForeground}
|
|
/>
|
|
</Pressable>
|
|
|
|
{expanded ? (
|
|
<View
|
|
style={[
|
|
styles.panel,
|
|
{ backgroundColor: colors.cardGlass, borderColor: colors.borderGlass },
|
|
]}
|
|
>
|
|
{SERVER_MODE_OPTIONS.map((option) => {
|
|
const selected = option.value === mode;
|
|
return (
|
|
<Pressable
|
|
key={option.value}
|
|
accessibilityRole="button"
|
|
accessibilityState={{ selected }}
|
|
onPress={() => void applyMode(option.value)}
|
|
style={({ pressed }) => [
|
|
styles.option,
|
|
{
|
|
borderColor: colors.border,
|
|
backgroundColor: selected ? colors.muted : "transparent",
|
|
},
|
|
pressed && styles.pressed,
|
|
]}
|
|
>
|
|
<Text style={[styles.optionLabel, { color: colors.foreground }]}>
|
|
{option.label}
|
|
</Text>
|
|
{selected ? (
|
|
<Ionicons name="checkmark" size={18} color={colors.primary} />
|
|
) : null}
|
|
</Pressable>
|
|
);
|
|
})}
|
|
|
|
{mode === "self-hosted" ? (
|
|
<>
|
|
<Input
|
|
label="Server URL"
|
|
value={selfHostedUrl}
|
|
onChangeText={(value) => {
|
|
setSelfHostedUrl(value);
|
|
setUrlError(null);
|
|
}}
|
|
onBlur={() => void commitSelfHostedUrl()}
|
|
onSubmitEditing={() => void commitSelfHostedUrl()}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
keyboardType="url"
|
|
placeholder="beenvoice.app or localhost:3000"
|
|
required
|
|
error={urlError ?? undefined}
|
|
/>
|
|
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
|
|
Use your Mac's LAN IP on a physical device.
|
|
</Text>
|
|
</>
|
|
) : null}
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
wrapper: {
|
|
gap: spacing.sm,
|
|
marginBottom: spacing.md,
|
|
},
|
|
wrapperEmbedded: {
|
|
marginBottom: 0,
|
|
},
|
|
trigger: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: spacing.xs,
|
|
minHeight: 36,
|
|
},
|
|
pressed: {
|
|
opacity: 0.7,
|
|
},
|
|
triggerText: {
|
|
fontSize: 13,
|
|
fontFamily: fonts.body,
|
|
},
|
|
summary: {
|
|
fontFamily: fonts.bodyMedium,
|
|
fontSize: 13,
|
|
},
|
|
panel: {
|
|
borderWidth: 1,
|
|
borderRadius: 14,
|
|
padding: spacing.md,
|
|
gap: spacing.sm,
|
|
},
|
|
option: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
minHeight: 44,
|
|
paddingHorizontal: spacing.md,
|
|
borderRadius: 10,
|
|
borderWidth: 1,
|
|
},
|
|
optionLabel: {
|
|
fontFamily: fonts.bodyMedium,
|
|
fontSize: 15,
|
|
},
|
|
hint: {
|
|
fontSize: 12,
|
|
fontFamily: fonts.body,
|
|
lineHeight: 16,
|
|
},
|
|
});
|