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>
483 lines
15 KiB
TypeScript
483 lines
15 KiB
TypeScript
import { useState } from "react";
|
||
import Constants from "expo-constants";
|
||
import { Ionicons } from "@expo/vector-icons";
|
||
import { router } from "expo-router";
|
||
import { Alert, Platform, Pressable, StyleSheet, Switch, Text, View } from "react-native";
|
||
import { TabPage } from "@/components/TabPage";
|
||
import { TabScrollView } from "@/components/TabScrollView";
|
||
|
||
import { AppBackground } from "@/components/AppBackground";
|
||
import { InstanceUrlField } from "@/components/InstanceUrlField";
|
||
import { LoadingScreen } from "@/components/LoadingScreen";
|
||
import { PageHeader } from "@/components/PageHeader";
|
||
import { PinPrompt } from "@/components/PinPrompt";
|
||
import { Button } from "@/components/ui/Button";
|
||
import { Card } from "@/components/ui/Card";
|
||
import { fonts, spacing } from "@/constants/theme";
|
||
import { useAccounts } from "@/contexts/AccountsContext";
|
||
import { useAppLock } from "@/contexts/AppLockContext";
|
||
import { useAuthClient, useSession } from "@/contexts/AuthContext";
|
||
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
|
||
import { startAdditionalAccountSignIn } from "@/lib/add-account";
|
||
import { api } from "@/lib/trpc";
|
||
|
||
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
|
||
{ value: "system", label: "System" },
|
||
{ value: "light", label: "Light" },
|
||
{ value: "dark", label: "Dark" },
|
||
];
|
||
|
||
export default function SettingsScreen() {
|
||
const authClient = useAuthClient();
|
||
const { data: session } = useSession();
|
||
const {
|
||
accounts,
|
||
activeAccount,
|
||
activeAccountId,
|
||
apiUrl,
|
||
switchAccount,
|
||
removeAccount,
|
||
clearActiveAccount,
|
||
} = useAccounts();
|
||
const { colors, colorMode, setColorMode } = useAppTheme();
|
||
const switchProps = {
|
||
trackColor: { false: colors.switchTrackOff, true: colors.switchTrackOn },
|
||
thumbColor: Platform.OS === "android" ? colors.switchThumb : undefined,
|
||
ios_backgroundColor: colors.switchIosBackground,
|
||
};
|
||
const {
|
||
enabled: lockEnabled,
|
||
biometricEnabled,
|
||
biometricAvailable,
|
||
biometricLabel,
|
||
enableLock,
|
||
disableLock,
|
||
changePin,
|
||
setUseBiometric,
|
||
lock,
|
||
} = useAppLock();
|
||
const profileQuery = api.settings.getProfile.useQuery();
|
||
|
||
const [pinPrompt, setPinPrompt] = useState<
|
||
| { mode: "create" }
|
||
| { mode: "confirm-disable" }
|
||
| { mode: "change-current" }
|
||
| { mode: "change-next" }
|
||
| null
|
||
>(null);
|
||
const [pendingPin, setPendingPin] = useState("");
|
||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||
|
||
async function handleSignOut() {
|
||
await authClient.signOut();
|
||
await clearActiveAccount();
|
||
router.replace("/(auth)/sign-in");
|
||
}
|
||
|
||
function confirmSignOut() {
|
||
Alert.alert("Sign out", "Sign out of this account on this device?", [
|
||
{ text: "Cancel", style: "cancel" },
|
||
{ text: "Sign out", style: "destructive", onPress: handleSignOut },
|
||
]);
|
||
}
|
||
|
||
function confirmRemoveAccount(accountId: string, label: string) {
|
||
Alert.alert("Remove account", `Remove ${label} from this device?`, [
|
||
{ text: "Cancel", style: "cancel" },
|
||
{
|
||
text: "Remove",
|
||
style: "destructive",
|
||
onPress: () => void removeAccount(accountId),
|
||
},
|
||
]);
|
||
}
|
||
|
||
function confirmInstanceChange() {
|
||
Alert.alert(
|
||
"Server updated",
|
||
"You may need to sign in again if you switched to a different instance.",
|
||
[{ text: "OK" }],
|
||
);
|
||
}
|
||
|
||
function handleLockToggle(next: boolean) {
|
||
if (next) {
|
||
setPinPrompt({ mode: "create" });
|
||
return;
|
||
}
|
||
setPinPrompt({ mode: "confirm-disable" });
|
||
}
|
||
|
||
function handleChangePin() {
|
||
setPendingPin("");
|
||
setPinPrompt({ mode: "change-current" });
|
||
}
|
||
|
||
function handleBiometricToggle(next: boolean) {
|
||
void setUseBiometric(next);
|
||
}
|
||
|
||
async function handlePinPromptSubmit(pin: string) {
|
||
if (pinPrompt?.mode === "create") {
|
||
try {
|
||
await enableLock(pin);
|
||
setPinPrompt(null);
|
||
} catch (err) {
|
||
Alert.alert("Could not enable lock", err instanceof Error ? err.message : "Try again");
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (pinPrompt?.mode === "confirm-disable") {
|
||
const success = await disableLock(pin);
|
||
if (!success) {
|
||
Alert.alert("Incorrect PIN", "Could not disable app lock.");
|
||
return;
|
||
}
|
||
setPinPrompt(null);
|
||
return;
|
||
}
|
||
|
||
if (pinPrompt?.mode === "change-current") {
|
||
setPendingPin(pin);
|
||
setPinPrompt({ mode: "change-next" });
|
||
return;
|
||
}
|
||
|
||
if (pinPrompt?.mode === "change-next") {
|
||
const success = await changePin(pendingPin, pin);
|
||
if (!success) {
|
||
Alert.alert("Could not change PIN", "Check your current PIN and try again.");
|
||
return;
|
||
}
|
||
setPendingPin("");
|
||
setPinPrompt(null);
|
||
Alert.alert("PIN updated", "Your app lock PIN has been changed.");
|
||
}
|
||
}
|
||
|
||
if (profileQuery.isLoading) {
|
||
return <LoadingScreen message="Loading profile…" />;
|
||
}
|
||
|
||
const profile = profileQuery.data;
|
||
const appVersion = Constants.expoConfig?.version ?? "1.0.0";
|
||
|
||
return (
|
||
<AppBackground>
|
||
<TabPage>
|
||
<PinPrompt
|
||
visible={pinPrompt !== null}
|
||
title={
|
||
pinPrompt?.mode === "create"
|
||
? "Create PIN"
|
||
: pinPrompt?.mode === "confirm-disable"
|
||
? "Disable app lock"
|
||
: pinPrompt?.mode === "change-current"
|
||
? "Current PIN"
|
||
: "New PIN"
|
||
}
|
||
message={
|
||
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"
|
||
? "Choose a 4–6 digit PIN."
|
||
: pinPrompt?.mode === "confirm-disable"
|
||
? "Enter your PIN to turn off app lock."
|
||
: "Enter your current PIN."
|
||
}
|
||
confirmLabel={
|
||
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next" ? "Save" : "Continue"
|
||
}
|
||
requireConfirmation={pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"}
|
||
onCancel={() => {
|
||
setPendingPin("");
|
||
setPinPrompt(null);
|
||
}}
|
||
onSubmit={(pin) => void handlePinPromptSubmit(pin)}
|
||
/>
|
||
<TabScrollView
|
||
header={
|
||
<PageHeader title="Settings" subtitle="Account and app preferences" />
|
||
}
|
||
keyboardShouldPersistTaps="handled"
|
||
>
|
||
<Card title="Account">
|
||
<Text style={[styles.name, { color: colors.foreground }]}>
|
||
{profile?.name ?? session?.user.name ?? "User"}
|
||
</Text>
|
||
<Text style={[styles.email, { color: colors.mutedForeground }]}>
|
||
{profile?.email ?? session?.user.email}
|
||
</Text>
|
||
{profile?.role ? (
|
||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||
Role: {profile.role}
|
||
</Text>
|
||
) : null}
|
||
</Card>
|
||
|
||
<Card title="Accounts">
|
||
{accounts.map((account) => {
|
||
const isActive = account.id === activeAccountId;
|
||
return (
|
||
<Pressable
|
||
key={account.id}
|
||
accessibilityRole="button"
|
||
onPress={() => void switchAccount(account.id)}
|
||
onLongPress={() => confirmRemoveAccount(account.id, account.email)}
|
||
style={({ pressed }) => [
|
||
styles.accountRow,
|
||
{
|
||
borderColor: colors.border,
|
||
backgroundColor: isActive ? colors.muted : "transparent",
|
||
},
|
||
pressed && styles.pressed,
|
||
]}
|
||
>
|
||
<View style={styles.accountMeta}>
|
||
<Text style={[styles.accountName, { color: colors.foreground }]}>
|
||
{account.name || account.email}
|
||
</Text>
|
||
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
||
{account.email}
|
||
</Text>
|
||
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
|
||
{account.instanceUrl.replace(/^https?:\/\//, "")}
|
||
</Text>
|
||
</View>
|
||
{isActive ? (
|
||
<Text style={[styles.activeBadge, { color: colors.primary }]}>Active</Text>
|
||
) : null}
|
||
</Pressable>
|
||
);
|
||
})}
|
||
<Button
|
||
title="Add another account"
|
||
variant="secondary"
|
||
onPress={() => void startAdditionalAccountSignIn(clearActiveAccount)}
|
||
/>
|
||
{accounts.length > 1 ? (
|
||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||
Long-press an account to remove it from this device.
|
||
</Text>
|
||
) : null}
|
||
</Card>
|
||
|
||
<Card title="Security">
|
||
<View style={styles.settingRow}>
|
||
<View style={styles.settingCopy}>
|
||
<Text style={[styles.settingTitle, { color: colors.foreground }]}>App lock</Text>
|
||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||
Require a PIN when reopening the app
|
||
</Text>
|
||
</View>
|
||
<Switch
|
||
value={lockEnabled}
|
||
onValueChange={handleLockToggle}
|
||
{...switchProps}
|
||
/>
|
||
</View>
|
||
|
||
{lockEnabled && biometricAvailable ? (
|
||
<View style={styles.settingRow}>
|
||
<View style={styles.settingCopy}>
|
||
<Text style={[styles.settingTitle, { color: colors.foreground }]}>
|
||
{biometricLabel}
|
||
</Text>
|
||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
|
||
Unlock with {biometricLabel.toLowerCase()} when available
|
||
</Text>
|
||
</View>
|
||
<Switch
|
||
value={biometricEnabled}
|
||
onValueChange={handleBiometricToggle}
|
||
{...switchProps}
|
||
/>
|
||
</View>
|
||
) : null}
|
||
|
||
{lockEnabled ? (
|
||
<>
|
||
<Button title="Change PIN" variant="secondary" onPress={handleChangePin} />
|
||
<Button title="Lock now" variant="secondary" onPress={lock} />
|
||
</>
|
||
) : null}
|
||
</Card>
|
||
|
||
<Card title="Appearance">
|
||
<View style={styles.themeRow}>
|
||
{THEME_OPTIONS.map((option) => {
|
||
const selected = colorMode === option.value;
|
||
return (
|
||
<Pressable
|
||
key={option.value}
|
||
accessibilityRole="button"
|
||
onPress={() => void setColorMode(option.value)}
|
||
style={[
|
||
styles.themeChip,
|
||
{
|
||
borderColor: selected ? colors.primary : colors.border,
|
||
backgroundColor: selected ? colors.muted : "transparent",
|
||
},
|
||
]}
|
||
>
|
||
<Text
|
||
style={[
|
||
styles.themeChipLabel,
|
||
{ color: selected ? colors.foreground : colors.mutedForeground },
|
||
]}
|
||
>
|
||
{option.label}
|
||
</Text>
|
||
</Pressable>
|
||
);
|
||
})}
|
||
</View>
|
||
</Card>
|
||
|
||
<Card title="App">
|
||
<View style={styles.appRow}>
|
||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>Version</Text>
|
||
<Text style={[styles.appValue, { color: colors.foreground }]}>{appVersion}</Text>
|
||
</View>
|
||
<View style={styles.appRow}>
|
||
<Text style={[styles.meta, { color: colors.mutedForeground }]}>Platform</Text>
|
||
<Text style={[styles.appValue, { color: colors.foreground }]}>
|
||
{Constants.platform?.ios ? "iOS" : "Other"}
|
||
</Text>
|
||
</View>
|
||
</Card>
|
||
|
||
<Pressable
|
||
accessibilityRole="button"
|
||
accessibilityState={{ expanded: showAdvanced }}
|
||
onPress={() => setShowAdvanced((open) => !open)}
|
||
style={styles.advancedToggle}
|
||
>
|
||
<Text style={[styles.advancedLabel, { color: colors.mutedForeground }]}>Advanced</Text>
|
||
<Ionicons
|
||
name={showAdvanced ? "chevron-up" : "chevron-down"}
|
||
size={16}
|
||
color={colors.mutedForeground}
|
||
/>
|
||
</Pressable>
|
||
|
||
{showAdvanced ? (
|
||
<Card title="Server instance">
|
||
<InstanceUrlField onSaved={confirmInstanceChange} />
|
||
<Text style={[styles.currentServer, { color: colors.mutedForeground }]}>
|
||
Connected to {activeAccount?.instanceUrl ?? apiUrl}
|
||
</Text>
|
||
</Card>
|
||
) : null}
|
||
|
||
<View style={styles.actions}>
|
||
<Button title="Sign Out" variant="danger" onPress={confirmSignOut} />
|
||
</View>
|
||
</TabScrollView>
|
||
</TabPage>
|
||
</AppBackground>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
name: {
|
||
fontSize: 20,
|
||
fontFamily: fonts.heading,
|
||
},
|
||
email: {
|
||
fontSize: 15,
|
||
fontFamily: fonts.body,
|
||
},
|
||
meta: {
|
||
fontSize: 13,
|
||
fontFamily: fonts.bodyMedium,
|
||
},
|
||
currentServer: {
|
||
fontSize: 12,
|
||
fontFamily: fonts.mono,
|
||
},
|
||
advancedToggle: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
gap: spacing.xs,
|
||
minHeight: 36,
|
||
},
|
||
advancedLabel: {
|
||
fontSize: 13,
|
||
fontFamily: fonts.bodyMedium,
|
||
},
|
||
appRow: {
|
||
flexDirection: "row",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
},
|
||
appValue: {
|
||
fontSize: 14,
|
||
fontFamily: fonts.bodyMedium,
|
||
},
|
||
accountRow: {
|
||
borderWidth: 1,
|
||
borderRadius: 12,
|
||
padding: spacing.md,
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
gap: spacing.md,
|
||
},
|
||
pressed: {
|
||
opacity: 0.92,
|
||
},
|
||
accountMeta: {
|
||
flex: 1,
|
||
gap: 2,
|
||
},
|
||
accountName: {
|
||
fontSize: 15,
|
||
fontFamily: fonts.bodySemiBold,
|
||
},
|
||
accountSub: {
|
||
fontSize: 12,
|
||
fontFamily: fonts.body,
|
||
},
|
||
activeBadge: {
|
||
fontSize: 12,
|
||
fontFamily: fonts.bodySemiBold,
|
||
},
|
||
themeRow: {
|
||
flexDirection: "row",
|
||
gap: spacing.sm,
|
||
},
|
||
themeChip: {
|
||
flex: 1,
|
||
borderWidth: 1,
|
||
borderRadius: 10,
|
||
minHeight: 40,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
paddingHorizontal: spacing.sm,
|
||
},
|
||
themeChipLabel: {
|
||
fontSize: 13,
|
||
fontFamily: fonts.bodyMedium,
|
||
lineHeight: 18,
|
||
...(Platform.OS === "android" ? { includeFontPadding: false } : null),
|
||
},
|
||
settingRow: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
gap: spacing.md,
|
||
},
|
||
settingCopy: {
|
||
flex: 1,
|
||
gap: 2,
|
||
},
|
||
settingTitle: {
|
||
fontSize: 15,
|
||
fontFamily: fonts.bodySemiBold,
|
||
},
|
||
actions: {
|
||
marginTop: spacing.sm,
|
||
},
|
||
});
|