Archived
Add in-app account deletion
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.beenvoice.app",
|
||||
"buildNumber": "27",
|
||||
"buildNumber": "28",
|
||||
"icon": "./assets/beenvoice.icon",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
|
||||
+189
-30
@@ -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>
|
||||
|
||||
@@ -139,6 +139,13 @@ WHAT TO TEST
|
||||
• Invoices — list includes draft, sent, and paid examples.
|
||||
• 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
|
||||
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)
|
||||
- [ ] Live Activity tested on physical iPhone
|
||||
- [ ] 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
|
||||
- [ ] Review notes include demo credentials and server URL
|
||||
- [ ] Export compliance answered
|
||||
|
||||
Reference in New Issue
Block a user