Archived
Add in-app account deletion
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user