Add in-app account deletion

This commit is contained in:
2026-08-15 13:46:47 -04:00
parent 9eadb0d7fb
commit b9c7fb325a
3 changed files with 198 additions and 31 deletions
+189 -30
View File
@@ -2,7 +2,15 @@ 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 {
Alert,
Platform,
Pressable,
StyleSheet,
Switch,
Text,
View,
} from "react-native";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
@@ -20,7 +28,10 @@ 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 { confirmRemoveAccount, finishAccountRemoval } from "@/lib/account-actions";
import {
confirmRemoveAccount,
finishAccountRemoval,
} from "@/lib/account-actions";
import { performAuthReset } from "@/lib/auth-session";
import { api } from "@/lib/trpc";
@@ -61,6 +72,7 @@ export default function SettingsScreen() {
lock,
} = useAppLock();
const profileQuery = api.settings.getProfile.useQuery();
const deleteAccountMutation = api.settings.deleteAccount.useMutation();
const [pinPrompt, setPinPrompt] = useState<
| { mode: "create" }
@@ -110,10 +122,54 @@ export default function SettingsScreen() {
function confirmSignOut() {
Alert.alert("Sign out", "Sign out of this account on this device?", [
{ text: "Cancel", style: "cancel" },
{ text: "Sign out", style: "destructive", onPress: () => void handleSignOut() },
{
text: "Sign out",
style: "destructive",
onPress: () => void handleSignOut(),
},
]);
}
async function handleDeleteAccount() {
if (!activeAccountId) return;
try {
await deleteAccountMutation.mutateAsync({
confirmText: "DELETE MY ACCOUNT",
});
const result = await removeAccount(activeAccountId);
await finishAccountRemoval({
result,
authClient,
clearActiveAccount,
activeAccountId,
});
if (result.remainingCount > 0) {
router.replace("/(auth)/select-account");
}
} catch (error) {
Alert.alert(
"Could not delete account",
error instanceof Error ? error.message : "Please try again.",
);
}
}
function confirmDeleteAccount() {
Alert.alert(
"Permanently delete account?",
"This deletes your account, invoices, clients, businesses, expenses, time entries, uploaded files, and sign-in data. This cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete Account",
style: "destructive",
onPress: () => void handleDeleteAccount(),
},
],
);
}
function confirmInstanceChange() {
Alert.alert(
"Server updated",
@@ -145,7 +201,10 @@ export default function SettingsScreen() {
await enableLock(pin);
setPinPrompt(null);
} catch (err) {
Alert.alert("Could not enable lock", err instanceof Error ? err.message : "Try again");
Alert.alert(
"Could not enable lock",
err instanceof Error ? err.message : "Try again",
);
}
return;
}
@@ -169,7 +228,10 @@ export default function SettingsScreen() {
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.");
Alert.alert(
"Could not change PIN",
"Check your current PIN and try again.",
);
return;
}
setPendingPin("");
@@ -207,9 +269,13 @@ export default function SettingsScreen() {
: "Enter your current PIN."
}
confirmLabel={
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next" ? "Save" : "Continue"
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"
? "Save"
: "Continue"
}
requireConfirmation={
pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"
}
requireConfirmation={pinPrompt?.mode === "create" || pinPrompt?.mode === "change-next"}
onCancel={() => {
setPendingPin("");
setPinPrompt(null);
@@ -218,7 +284,10 @@ export default function SettingsScreen() {
/>
<TabScrollView
header={
<PageHeader title="Settings" subtitle="Account and app preferences" />
<PageHeader
title="Settings"
subtitle="Account and app preferences"
/>
}
keyboardShouldPersistTaps="handled"
>
@@ -253,21 +322,43 @@ export default function SettingsScreen() {
<Pressable
accessibilityRole="button"
onPress={() => void switchAccount(account.id)}
style={({ pressed }) => [styles.accountMain, pressed && styles.pressed]}
style={({ pressed }) => [
styles.accountMain,
pressed && styles.pressed,
]}
>
<View style={styles.accountMeta}>
<Text style={[styles.accountName, { color: colors.foreground }]}>
<Text
style={[
styles.accountName,
{ color: colors.foreground },
]}
>
{account.name || account.email}
</Text>
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
<Text
style={[
styles.accountSub,
{ color: colors.mutedForeground },
]}
>
{account.email}
</Text>
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}>
<Text
style={[
styles.accountSub,
{ color: colors.mutedForeground },
]}
>
{account.instanceUrl.replace(/^https?:\/\//, "")}
</Text>
</View>
{isActive ? (
<Text style={[styles.activeBadge, { color: colors.primary }]}>Active</Text>
<Text
style={[styles.activeBadge, { color: colors.primary }]}
>
Active
</Text>
) : null}
</Pressable>
<Pressable
@@ -275,11 +366,21 @@ export default function SettingsScreen() {
accessibilityLabel={`Remove ${account.name || account.email}`}
hitSlop={8}
onPress={() =>
handleRemoveAccount(account.id, account.name || account.email)
handleRemoveAccount(
account.id,
account.name || account.email,
)
}
style={({ pressed }) => [styles.removeButton, pressed && styles.pressed]}
style={({ pressed }) => [
styles.removeButton,
pressed && styles.pressed,
]}
>
<Ionicons name="trash-outline" size={18} color={colors.destructive} />
<Ionicons
name="trash-outline"
size={18}
color={colors.destructive}
/>
</Pressable>
</View>
);
@@ -293,10 +394,13 @@ export default function SettingsScreen() {
<Button
title="Add another account"
variant="secondary"
onPress={() => void startAdditionalAccountSignIn(clearActiveAccount)}
onPress={() =>
void startAdditionalAccountSignIn(clearActiveAccount)
}
/>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Tap an account to switch. Refresh updates names from saved sign-in data.
Tap an account to switch. Refresh updates names from saved sign-in
data.
</Text>
</Card>
@@ -309,7 +413,11 @@ export default function SettingsScreen() {
<Card title="Security">
<View style={styles.settingRow}>
<View style={styles.settingCopy}>
<Text style={[styles.settingTitle, { color: colors.foreground }]}>App lock</Text>
<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>
@@ -324,10 +432,14 @@ export default function SettingsScreen() {
{lockEnabled && biometricAvailable ? (
<View style={styles.settingRow}>
<View style={styles.settingCopy}>
<Text style={[styles.settingTitle, { color: colors.foreground }]}>
<Text
style={[styles.settingTitle, { color: colors.foreground }]}
>
{biometricLabel}
</Text>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
<Text
style={[styles.meta, { color: colors.mutedForeground }]}
>
Unlock with {biometricLabel.toLowerCase()} when available
</Text>
</View>
@@ -341,7 +453,11 @@ export default function SettingsScreen() {
{lockEnabled ? (
<>
<Button title="Change PIN" variant="secondary" onPress={handleChangePin} />
<Button
title="Change PIN"
variant="secondary"
onPress={handleChangePin}
/>
<Button title="Lock now" variant="secondary" onPress={lock} />
</>
) : null}
@@ -360,14 +476,20 @@ export default function SettingsScreen() {
styles.themeChip,
{
borderColor: selected ? colors.primary : colors.border,
backgroundColor: selected ? colors.muted : "transparent",
backgroundColor: selected
? colors.muted
: "transparent",
},
]}
>
<Text
style={[
styles.themeChipLabel,
{ color: selected ? colors.foreground : colors.mutedForeground },
{
color: selected
? colors.foreground
: colors.mutedForeground,
},
]}
>
{option.label}
@@ -380,24 +502,52 @@ export default function SettingsScreen() {
<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>
<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.meta, { color: colors.mutedForeground }]}>
Platform
</Text>
<Text style={[styles.appValue, { color: colors.foreground }]}>
{Constants.platform?.ios ? "iOS" : "Other"}
</Text>
</View>
</Card>
<Card title="Delete account">
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
Permanently delete this account and all of its data from the
server. This cannot be undone.
</Text>
<Button
title={
deleteAccountMutation.isPending
? "Deleting account…"
: "Delete Account"
}
variant="danger"
loading={deleteAccountMutation.isPending}
disabled={deleteAccountMutation.isPending}
onPress={confirmDeleteAccount}
/>
</Card>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: showAdvanced }}
onPress={() => setShowAdvanced((open) => !open)}
style={styles.advancedToggle}
>
<Text style={[styles.advancedLabel, { color: colors.mutedForeground }]}>Advanced</Text>
<Text
style={[styles.advancedLabel, { color: colors.mutedForeground }]}
>
Advanced
</Text>
<Ionicons
name={showAdvanced ? "chevron-up" : "chevron-down"}
size={16}
@@ -408,14 +558,23 @@ export default function SettingsScreen() {
{showAdvanced ? (
<Card title="Server instance">
<InstanceUrlField onSaved={confirmInstanceChange} />
<Text style={[styles.currentServer, { color: colors.mutedForeground }]}>
<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} />
<Button
title="Sign Out"
variant="danger"
onPress={confirmSignOut}
/>
</View>
</TabScrollView>
</TabPage>