Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ devenv.local.yaml
.amp/*
!.amp/services.yaml

# Volatile task packets
/tasks/

# Vendored Effect source for the effect-ts skill's research prerequisite (see root AGENTS.md).
# Bootstrap: git clone https://github.com/Effect-TS/effect-smol .repos/effect
.repos/
2 changes: 2 additions & 0 deletions apps/mobile/src/app/account.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Button, Form, Host, ProgressView, Section } from '@expo/ui/swift-ui';
import { DeleteAccountSection } from '@mobile/components/account/delete-account-section';
import { DevicesSection } from '@mobile/components/account/devices-section';
import { ProfileRow } from '@mobile/components/account/profile-row';
import { signOutOfCloud, useCloudAccount } from '@mobile/runtime/cloud/account';
Expand Down Expand Up @@ -35,6 +36,7 @@ export default function AccountScreen(): React.ReactNode {
}}
/>
</Section>
<DeleteAccountSection />
</>
)}
</Form>
Expand Down
82 changes: 82 additions & 0 deletions apps/mobile/src/components/account/delete-account-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Button, Section } from '@expo/ui/swift-ui';
import { disabled } from '@expo/ui/swift-ui/modifiers';
import { deleteAccount, runAccountDeletionTeardown } from '@mobile/runtime/cloud/deletion';
import { useState } from 'react';
import { Alert } from 'react-native';
import { useTranslations } from 'use-intl';

/**
* Permanent, in-app account deletion (App Store Guideline 5.1.1(v)). Its own
* Section, below Sign out, `Button role="destructive"` — not hidden behind
* any secondary menu, matching `DevicesSection`'s destructive-row precedent.
*/
export function DeleteAccountSection(): React.ReactNode {
const t = useTranslations('mobile.account');
const [busy, setBusy] = useState(false);

const failureMessage = (code: string | undefined): string => {
if (code === 'ACCOUNT_DELETION_EMERGENCY_AUDIT_HOLD') return t('deleteEmergencyHold');
if (code === 'ACCOUNT_DELETION_SOLE_ORGANIZATION_OWNER') return t('deleteSoleOwner');
return t('deleteFailed');
};

const run = async () => {
setBusy(true);
try {
const outcome = await deleteAccount();
if (outcome.kind === 'reauthentication-failed') {
Alert.alert(t('deleteReauthenticationFailed'));
return;
}
if (outcome.kind === 'failed') {
Alert.alert(failureMessage(outcome.code));
return;
}

// Both remaining outcomes (`pending` and `completed`) mean the server
// already accepted the deletion — local teardown runs regardless.
await runAccountDeletionTeardown();
if (outcome.kind === 'pending') {
Alert.alert(t('deletePending'));
return;
}
// Success never says "contact support" — a failed revocation is still
// a successful deletion, just with a manual Apple follow-up
// (design.md §3.4, TN3194).
if (outcome.authorizationRevocation === 'failed') {
Alert.alert(t('deleteRevocationFailedTitle'), t('deleteRevocationFailedMessage'));
} else {
Alert.alert(t('deleteCompleted'));
}
} finally {
setBusy(false);
}
};

const confirmDelete = () => {
Alert.alert(t('deleteTitle'), t('deleteMessage'), [
{ text: t('deleteCancel'), style: 'cancel' },
{
text: t('deleteConfirm'),
style: 'destructive',
onPress() {
// Deliberately no retry affordance — the server is idempotent on
// replay, but a client-side retry button would invite repeating
// an operation that may have already fully succeeded.
void run();
},
},
]);
};

return (
<Section>
<Button
role="destructive"
label={t('deleteAccount')}
onPress={confirmDelete}
modifiers={[disabled(busy)]}
/>
</Section>
);
}
4 changes: 4 additions & 0 deletions apps/mobile/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ declare namespace NodeJS {
EXPO_PUBLIC_POSTHOG_PROJECT_TOKEN?: string;
EXPO_PUBLIC_POSTHOG_HOST?: string;
EXPO_PUBLIC_CONFIG_SIGNING_POC?: string;
/** Overrides the Cloud API base URL; unset defaults to production. */
EXPO_PUBLIC_CLOUD_URL?: string;
/** Overrides the central IdP base URL; unset defaults to production. */
EXPO_PUBLIC_IDP_URL?: string;
}
}

Expand Down
50 changes: 50 additions & 0 deletions apps/mobile/src/runtime/cloud/__tests__/account.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
fetchSession: vi.fn(),
signInSocial: vi.fn(),
}));

vi.mock('../client', () => ({
CLOUD_URL: 'https://api.linkcode.ai',
cloudAuthClient: {
$fetch: mocks.fetchSession,
signIn: { social: mocks.signInSocial },
},
}));

vi.mock('../devices', () => ({ clearDeviceEnrollment: vi.fn() }));

import { reauthenticateToCloud } from '../account';

afterEach(() => {
vi.clearAllMocks();
});

describe('reauthenticateToCloud', () => {
it('rejects when the browser resolves without replacing the old session', async () => {
mocks.signInSocial.mockResolvedValueOnce({ error: null });
mocks.fetchSession
.mockResolvedValueOnce({ data: { session: { id: 'old-session' } }, error: null })
.mockResolvedValueOnce({ data: { session: { id: 'old-session' } }, error: null });

await expect(reauthenticateToCloud()).rejects.toThrow(
'browser re-authentication did not create a fresh session',
);
expect(mocks.fetchSession).toHaveBeenCalledTimes(2);
expect(mocks.fetchSession).toHaveBeenNthCalledWith(
2,
'https://api.linkcode.ai/auth/get-session?disableCookieCache=true',
{},
);
});

it('accepts a session created by the current browser flow', async () => {
mocks.signInSocial.mockResolvedValueOnce({ error: null });
mocks.fetchSession
.mockResolvedValueOnce({ data: { session: { id: 'old-session' } }, error: null })
.mockResolvedValueOnce({ data: { session: { id: 'new-session' } }, error: null });

await expect(reauthenticateToCloud()).resolves.toBeUndefined();
});
});
Loading