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
+1 -1
View File
@@ -10,7 +10,7 @@
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "com.beenvoice.app", "bundleIdentifier": "com.beenvoice.app",
"buildNumber": "27", "buildNumber": "28",
"icon": "./assets/beenvoice.icon", "icon": "./assets/beenvoice.icon",
"infoPlist": { "infoPlist": {
"ITSAppUsesNonExemptEncryption": false, "ITSAppUsesNonExemptEncryption": false,
+189 -30
View File
@@ -2,7 +2,15 @@ import { useState } from "react";
import Constants from "expo-constants"; import Constants from "expo-constants";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router"; 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 { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView"; import { TabScrollView } from "@/components/TabScrollView";
@@ -20,7 +28,10 @@ import { useAppLock } from "@/contexts/AppLockContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext"; import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext"; import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
import { startAdditionalAccountSignIn } from "@/lib/add-account"; 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 { performAuthReset } from "@/lib/auth-session";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
@@ -61,6 +72,7 @@ export default function SettingsScreen() {
lock, lock,
} = useAppLock(); } = useAppLock();
const profileQuery = api.settings.getProfile.useQuery(); const profileQuery = api.settings.getProfile.useQuery();
const deleteAccountMutation = api.settings.deleteAccount.useMutation();
const [pinPrompt, setPinPrompt] = useState< const [pinPrompt, setPinPrompt] = useState<
| { mode: "create" } | { mode: "create" }
@@ -110,10 +122,54 @@ export default function SettingsScreen() {
function confirmSignOut() { function confirmSignOut() {
Alert.alert("Sign out", "Sign out of this account on this device?", [ Alert.alert("Sign out", "Sign out of this account on this device?", [
{ text: "Cancel", style: "cancel" }, { 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() { function confirmInstanceChange() {
Alert.alert( Alert.alert(
"Server updated", "Server updated",
@@ -145,7 +201,10 @@ export default function SettingsScreen() {
await enableLock(pin); await enableLock(pin);
setPinPrompt(null); setPinPrompt(null);
} catch (err) { } 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; return;
} }
@@ -169,7 +228,10 @@ export default function SettingsScreen() {
if (pinPrompt?.mode === "change-next") { if (pinPrompt?.mode === "change-next") {
const success = await changePin(pendingPin, pin); const success = await changePin(pendingPin, pin);
if (!success) { 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; return;
} }
setPendingPin(""); setPendingPin("");
@@ -207,9 +269,13 @@ export default function SettingsScreen() {
: "Enter your current PIN." : "Enter your current PIN."
} }
confirmLabel={ 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={() => { onCancel={() => {
setPendingPin(""); setPendingPin("");
setPinPrompt(null); setPinPrompt(null);
@@ -218,7 +284,10 @@ export default function SettingsScreen() {
/> />
<TabScrollView <TabScrollView
header={ header={
<PageHeader title="Settings" subtitle="Account and app preferences" /> <PageHeader
title="Settings"
subtitle="Account and app preferences"
/>
} }
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
@@ -253,21 +322,43 @@ export default function SettingsScreen() {
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
onPress={() => void switchAccount(account.id)} onPress={() => void switchAccount(account.id)}
style={({ pressed }) => [styles.accountMain, pressed && styles.pressed]} style={({ pressed }) => [
styles.accountMain,
pressed && styles.pressed,
]}
> >
<View style={styles.accountMeta}> <View style={styles.accountMeta}>
<Text style={[styles.accountName, { color: colors.foreground }]}> <Text
style={[
styles.accountName,
{ color: colors.foreground },
]}
>
{account.name || account.email} {account.name || account.email}
</Text> </Text>
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}> <Text
style={[
styles.accountSub,
{ color: colors.mutedForeground },
]}
>
{account.email} {account.email}
</Text> </Text>
<Text style={[styles.accountSub, { color: colors.mutedForeground }]}> <Text
style={[
styles.accountSub,
{ color: colors.mutedForeground },
]}
>
{account.instanceUrl.replace(/^https?:\/\//, "")} {account.instanceUrl.replace(/^https?:\/\//, "")}
</Text> </Text>
</View> </View>
{isActive ? ( {isActive ? (
<Text style={[styles.activeBadge, { color: colors.primary }]}>Active</Text> <Text
style={[styles.activeBadge, { color: colors.primary }]}
>
Active
</Text>
) : null} ) : null}
</Pressable> </Pressable>
<Pressable <Pressable
@@ -275,11 +366,21 @@ export default function SettingsScreen() {
accessibilityLabel={`Remove ${account.name || account.email}`} accessibilityLabel={`Remove ${account.name || account.email}`}
hitSlop={8} hitSlop={8}
onPress={() => 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> </Pressable>
</View> </View>
); );
@@ -293,10 +394,13 @@ export default function SettingsScreen() {
<Button <Button
title="Add another account" title="Add another account"
variant="secondary" variant="secondary"
onPress={() => void startAdditionalAccountSignIn(clearActiveAccount)} onPress={() =>
void startAdditionalAccountSignIn(clearActiveAccount)
}
/> />
<Text style={[styles.meta, { color: colors.mutedForeground }]}> <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> </Text>
</Card> </Card>
@@ -309,7 +413,11 @@ export default function SettingsScreen() {
<Card title="Security"> <Card title="Security">
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingCopy}> <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 }]}> <Text style={[styles.meta, { color: colors.mutedForeground }]}>
Require a PIN when reopening the app Require a PIN when reopening the app
</Text> </Text>
@@ -324,10 +432,14 @@ export default function SettingsScreen() {
{lockEnabled && biometricAvailable ? ( {lockEnabled && biometricAvailable ? (
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingCopy}> <View style={styles.settingCopy}>
<Text style={[styles.settingTitle, { color: colors.foreground }]}> <Text
style={[styles.settingTitle, { color: colors.foreground }]}
>
{biometricLabel} {biometricLabel}
</Text> </Text>
<Text style={[styles.meta, { color: colors.mutedForeground }]}> <Text
style={[styles.meta, { color: colors.mutedForeground }]}
>
Unlock with {biometricLabel.toLowerCase()} when available Unlock with {biometricLabel.toLowerCase()} when available
</Text> </Text>
</View> </View>
@@ -341,7 +453,11 @@ export default function SettingsScreen() {
{lockEnabled ? ( {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} /> <Button title="Lock now" variant="secondary" onPress={lock} />
</> </>
) : null} ) : null}
@@ -360,14 +476,20 @@ export default function SettingsScreen() {
styles.themeChip, styles.themeChip,
{ {
borderColor: selected ? colors.primary : colors.border, borderColor: selected ? colors.primary : colors.border,
backgroundColor: selected ? colors.muted : "transparent", backgroundColor: selected
? colors.muted
: "transparent",
}, },
]} ]}
> >
<Text <Text
style={[ style={[
styles.themeChipLabel, styles.themeChipLabel,
{ color: selected ? colors.foreground : colors.mutedForeground }, {
color: selected
? colors.foreground
: colors.mutedForeground,
},
]} ]}
> >
{option.label} {option.label}
@@ -380,24 +502,52 @@ export default function SettingsScreen() {
<Card title="App"> <Card title="App">
<View style={styles.appRow}> <View style={styles.appRow}>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>Version</Text> <Text style={[styles.meta, { color: colors.mutedForeground }]}>
<Text style={[styles.appValue, { color: colors.foreground }]}>{appVersion}</Text> Version
</Text>
<Text style={[styles.appValue, { color: colors.foreground }]}>
{appVersion}
</Text>
</View> </View>
<View style={styles.appRow}> <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 }]}> <Text style={[styles.appValue, { color: colors.foreground }]}>
{Constants.platform?.ios ? "iOS" : "Other"} {Constants.platform?.ios ? "iOS" : "Other"}
</Text> </Text>
</View> </View>
</Card> </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 <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityState={{ expanded: showAdvanced }} accessibilityState={{ expanded: showAdvanced }}
onPress={() => setShowAdvanced((open) => !open)} onPress={() => setShowAdvanced((open) => !open)}
style={styles.advancedToggle} style={styles.advancedToggle}
> >
<Text style={[styles.advancedLabel, { color: colors.mutedForeground }]}>Advanced</Text> <Text
style={[styles.advancedLabel, { color: colors.mutedForeground }]}
>
Advanced
</Text>
<Ionicons <Ionicons
name={showAdvanced ? "chevron-up" : "chevron-down"} name={showAdvanced ? "chevron-up" : "chevron-down"}
size={16} size={16}
@@ -408,14 +558,23 @@ export default function SettingsScreen() {
{showAdvanced ? ( {showAdvanced ? (
<Card title="Server instance"> <Card title="Server instance">
<InstanceUrlField onSaved={confirmInstanceChange} /> <InstanceUrlField onSaved={confirmInstanceChange} />
<Text style={[styles.currentServer, { color: colors.mutedForeground }]}> <Text
style={[
styles.currentServer,
{ color: colors.mutedForeground },
]}
>
Connected to {activeAccount?.instanceUrl ?? apiUrl} Connected to {activeAccount?.instanceUrl ?? apiUrl}
</Text> </Text>
</Card> </Card>
) : null} ) : null}
<View style={styles.actions}> <View style={styles.actions}>
<Button title="Sign Out" variant="danger" onPress={confirmSignOut} /> <Button
title="Sign Out"
variant="danger"
onPress={confirmSignOut}
/>
</View> </View>
</TabScrollView> </TabScrollView>
</TabPage> </TabPage>
+8
View File
@@ -139,6 +139,13 @@ WHAT TO TEST
• Invoices — list includes draft, sent, and paid examples. • Invoices — list includes draft, sent, and paid examples.
• Settings — profile, theme, optional app lock (PIN / Face ID). • Settings — profile, theme, optional app lock (PIN / Face ID).
ACCOUNT DELETION
Settings → Delete account offers permanent in-app account deletion without contacting support.
After a destructive confirmation, the server removes the account record, sessions, access keys,
invoices, clients, businesses, recurring invoices, expenses and receipts, time entries, templates,
audit records, and uploaded files. The app then removes the local account and returns to sign-in.
The supplied demo account is shared, so please test deletion last if deletion verification is required.
APP LOCK APP LOCK
Optional. Enable in Settings → App Lock. Face ID uses on-device biometrics only; no biometric data is sent to our servers. Optional. Enable in Settings → App Lock. Face ID uses on-device biometrics only; no biometric data is sent to our servers.
@@ -276,6 +283,7 @@ Prerequisites:
- [ ] TestFlight smoke test on device (login, timer, invoices, app lock) - [ ] TestFlight smoke test on device (login, timer, invoices, app lock)
- [ ] Live Activity tested on physical iPhone - [ ] Live Activity tested on physical iPhone
- [ ] App Privacy answers match actual data flows - [ ] App Privacy answers match actual data flows
- [ ] In-app account deletion succeeds from Settings and returns to sign-in
- [ ] Screenshots uploaded for required device sizes - [ ] Screenshots uploaded for required device sizes
- [ ] Review notes include demo credentials and server URL - [ ] Review notes include demo credentials and server URL
- [ ] Export compliance answered - [ ] Export compliance answered