Skip to content
Merged
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
97 changes: 97 additions & 0 deletions apps/mobile/app/(tabs)/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { getLanguage, getFlagEmoji } from '../../src/data/languages'
import { LanguagePickerModal } from '../../src/components/LanguagePickerModal'
import { VocabReminderSettingsRow } from '../../src/components/profile/VocabReminderSettingsRow'
import { supportedLanguages, type Language, authApi, getStorageUrl, getAnonymousReader } from '@textstack/shared'
import { deleteAccount } from '../../src/lib/api'
import { getAnonAvatarSource } from '../../src/lib/anonAvatarSource'
import { fonts } from '../../src/theme/typography'

Expand All @@ -34,6 +35,7 @@ export default function ProfileScreen() {
const [editName, setEditName] = useState('')
const [saving, setSaving] = useState(false)
const [langPickerOpen, setLangPickerOpen] = useState(false)
const [deleting, setDeleting] = useState(false)

const online = useOnline()
const isGuest = !!user?.isGuest
Expand Down Expand Up @@ -109,6 +111,61 @@ export default function ProfileScreen() {
}
}

// Permanently delete the account. Two-step confirm before the
// irreversible network call: a warning explaining what's lost, then a
// final confirm. On success we sign out (clears SecureStore token/user
// + per-user caches) and the (tabs) layout drops back to the signed-out
// state; on failure we keep the user signed in and surface the error.
const performDelete = async () => {
setDeleting(true)
try {
const token = await getAccessToken()
if (!token) {
Alert.alert('Not signed in', 'Your session expired. Sign in again and retry.')
return
}
await deleteAccount(token)
await signOut()
router.replace('/(tabs)')
} catch (e: any) {
const status: number | undefined = e?.status
let message = e?.message || 'Something went wrong. Please try again.'
if (status === 401 || status === 403) {
message = 'Your session expired. Sign in again and retry.'
} else if (typeof status === 'number' && status >= 500) {
message = 'Our servers are having trouble. Please try again in a minute.'
} else if (!status) {
message = 'Check your connection and try again.'
}
Alert.alert('Delete failed', message)
} finally {
setDeleting(false)
}
}

const confirmDelete = () => {
Alert.alert(
'Delete account?',
'This permanently deletes your account and ALL your data — uploaded books, highlights, vocabulary, and reading history. This cannot be undone.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Continue',
style: 'destructive',
onPress: () =>
Alert.alert(
'Are you absolutely sure?',
'There is no way to recover your account or data after this.',
[
{ text: 'Keep my account', style: 'cancel' },
{ text: 'Delete account', style: 'destructive', onPress: performDelete },
],
),
},
],
)
}

if (!isAuthenticated) {
return (
<View style={[styles.center, { backgroundColor: colors.background }]}>
Expand Down Expand Up @@ -317,6 +374,33 @@ export default function ProfileScreen() {
<Ionicons name="log-out-outline" size={20} color={colors.error} style={styles.menuIcon} />
<Text style={[styles.menuText, { color: colors.error }]}>Sign Out</Text>
</TouchableOpacity>

{/* Danger zone — destructive, visually separated from normal settings.
Hidden for guest accounts (nothing server-side to delete). */}
{!isGuest && (
<View style={[styles.dangerZone, { borderColor: colors.error }]}>
<Text style={[styles.sectionLabel, { color: colors.error, marginTop: 0 }]}>Danger zone</Text>
<TouchableOpacity
style={styles.dangerRow}
onPress={confirmDelete}
disabled={deleting}
activeOpacity={0.7}
accessibilityLabel="Delete account"
>
{deleting ? (
<ActivityIndicator size="small" color={colors.error} style={styles.menuIcon} />
) : (
<Ionicons name="trash-outline" size={20} color={colors.error} style={styles.menuIcon} />
)}
<View style={{ flex: 1 }}>
<Text style={[styles.menuText, { color: colors.error }]}>Delete account</Text>
<Text style={[styles.dangerHint, { color: colors.textSecondary }]}>
Permanently removes your account and all data. Cannot be undone.
</Text>
</View>
</TouchableOpacity>
</View>
)}
</View>

<LanguagePickerModal
Expand Down Expand Up @@ -407,4 +491,17 @@ const styles = StyleSheet.create({
paddingVertical: 8,
borderRadius: 8,
},
dangerZone: {
marginTop: 32,
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
},
dangerRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
},
dangerHint: { fontFamily: fonts.sans, fontSize: 12, marginTop: 2 },
})
21 changes: 21 additions & 0 deletions apps/mobile/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,25 @@ export function setupApi() {
initApi({ baseUrl: API_URL, getAccessToken, onUnauthorized })
}

/**
* Permanently delete the signed-in user and ALL their data
* (`DELETE /me/account` → 204). Irreversible. The caller is responsible
* for signing the user out on success. The Bearer token is passed
* explicitly (same pattern as `authApi.logout`/`deleteAvatar`) so this
* works regardless of the shared client's getAccessToken wiring.
*/
export async function deleteAccount(accessToken: string): Promise<void> {
const res = await fetch(`${API_URL}/me/account`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!res.ok) {
const data = await res.json().catch(() => null)
throw Object.assign(
new Error(data?.error || `Failed to delete account: ${res.status}`),
{ status: res.status },
)
}
}

export { API_URL }
162 changes: 162 additions & 0 deletions apps/web/e2e/tests/delete-account.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { test, expect } from '../fixtures/auth.fixture'
import type { Page } from '@playwright/test'

// E2E coverage for the destructive account-deletion flow:
// User menu → Edit profile (ProfileModal) → Danger zone → Delete account
// → DeleteAccountDialog (type DELETE to arm) → DELETE /api/me/account
// → session cleared → redirect to localized home /:lang/.
//
// SAFETY: we NEVER let a real DELETE reach the backend. The shared e2e test user
// is created once in global-setup and reused by every other spec — actually
// deleting it would break the whole suite. So the destructive happy-path and the
// error-path both stub `**/me/account` via page.route(); the gating + public-page
// tests touch no network at all.

const CONFIRM_PHRASE = 'DELETE'

// Opens the user menu and clicks "Edit profile" to mount the ProfileModal.
async function openProfileModal(page: Page) {
await page.goto('/en/')
await page.waitForLoadState('networkidle')

// The signed-in header renders the UserMenu trigger; click to open the dropdown.
const trigger = page.locator('.user-menu__trigger')
await expect(trigger).toBeVisible({ timeout: 15_000 })
await trigger.click()

await page.getByRole('button', { name: 'Edit profile' }).click()
await expect(page.locator('.profile-modal').first()).toBeVisible()
}

// Opens the destructive DeleteAccountDialog from within the ProfileModal.
async function openDeleteDialog(page: Page) {
await openProfileModal(page)

// Danger zone only renders for non-guest users (the test-login user is non-guest).
const dangerTitle = page.locator('.profile-modal__danger-title')
await expect(dangerTitle).toBeVisible()
await expect(dangerTitle).toHaveText('Danger zone')

await page.locator('.profile-modal__danger-zone .profile-modal__btn--danger').click()

// The dialog (role="dialog", aria-labelledby) mounts on top.
const dialog = page.getByRole('dialog', { name: /delete your account\?/i })
await expect(dialog).toBeVisible()
return dialog
}

test.describe('Account deletion flow', () => {
test('danger zone + Delete account button visible for authed user', async ({ authedPage: page }) => {
await openProfileModal(page)

const dangerZone = page.locator('.profile-modal__danger-zone')
await expect(dangerZone).toBeVisible()
await expect(dangerZone.locator('.profile-modal__danger-title')).toHaveText('Danger zone')
await expect(
dangerZone.getByRole('button', { name: 'Delete account' }),
).toBeVisible()
})

test('confirm button disabled until exact DELETE phrase typed', async ({ authedPage: page }) => {
const dialog = await openDeleteDialog(page)

const confirmBtn = dialog.locator('.profile-modal__btn--danger')
const input = dialog.locator('#delete-account-confirm')

// Disabled on open (empty input).
await expect(confirmBtn).toBeDisabled()

// Wrong / partial phrase keeps it disabled.
await input.fill('delete')
await expect(confirmBtn).toBeDisabled()
await input.fill('DELETE ME')
await expect(confirmBtn).toBeDisabled()

// Exact phrase arms it. (Trailing whitespace is trimmed → still armed.)
await input.fill(CONFIRM_PHRASE)
await expect(confirmBtn).toBeEnabled()
await input.fill(` ${CONFIRM_PHRASE} `)
await expect(confirmBtn).toBeEnabled()

// Clearing disarms again.
await input.fill('')
await expect(confirmBtn).toBeDisabled()
})

test('confirm with stubbed 204 → signs out and redirects home', async ({ authedPage: page }) => {
let deleteCalled = false
// Stub the destructive call so NO real deletion happens; assert it was hit.
await page.route('**/me/account', async route => {
if (route.request().method() === 'DELETE') {
deleteCalled = true
await route.fulfill({ status: 204, body: '' })
} else {
await route.continue()
}
})

const dialog = await openDeleteDialog(page)
await dialog.locator('#delete-account-confirm').fill(CONFIRM_PHRASE)
await dialog.locator('.profile-modal__btn--danger').click()

// Redirect to localized home.
await page.waitForURL(/\/en\/?($|\?)/, { timeout: 15_000 })
expect(deleteCalled).toBe(true)

// Signed-out header: UserMenu trigger gone, Sign in icon button present.
// (exact match — "Sign in to upload" also exists in the signed-out header.)
await expect(page.locator('.user-menu__trigger')).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible()
})

test('confirm with stubbed 500 → stays signed in, error shown', async ({ authedPage: page }) => {
await page.route('**/me/account', async route => {
if (route.request().method() === 'DELETE') {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ message: 'boom' }),
})
} else {
await route.continue()
}
})

const dialog = await openDeleteDialog(page)
await dialog.locator('#delete-account-confirm').fill(CONFIRM_PHRASE)
await dialog.locator('.profile-modal__btn--danger').click()

// Error surfaced inside the dialog; dialog stays open.
await expect(dialog.locator('.profile-modal__error')).toBeVisible({ timeout: 15_000 })
await expect(dialog).toBeVisible()

// Still on a non-home route is not guaranteed, but the user must remain
// signed in — the UserMenu trigger is still mountable. Reload to confirm
// the session survived (no navigation occurred, user not nulled).
await expect(page.locator('.user-menu__trigger')).toBeVisible()
await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toHaveCount(0)
})
})

test.describe('Public delete-account info page', () => {
// No auth needed — this is the public GDPR/Play-Store info page.
test('renders irreversibility + what-gets-deleted copy', async ({ page }) => {
await page.goto('/en/delete-account')
await page.waitForLoadState('networkidle')

await expect(
page.getByRole('heading', { name: 'Delete your account', level: 1 }),
).toBeVisible()

// Key sections from en.json deleteAccount.*
await expect(page.getByRole('heading', { name: 'Delete in the app' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'What gets deleted' })).toBeVisible()

// Irreversibility copy must be present.
await expect(page.getByText('This action cannot be undone.')).toBeVisible()
// Enumerates deleted data so users know the blast radius.
await expect(
page.getByText(/highlights and notes, saved vocabulary, reading progress/i),
).toBeVisible()
})
})
3 changes: 3 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { AboutPage } from './pages/AboutPage'
import { PrivacyPage } from './pages/PrivacyPage'
import { TermsPage } from './pages/TermsPage'
import { DmcaPage } from './pages/DmcaPage'
import { DeleteAccountPage } from './pages/DeleteAccountPage'
import { ContactPage } from './pages/ContactPage'
import { McpLandingPage } from './pages/McpLandingPage'
import { ResetPasswordPage } from './pages/ResetPasswordPage'
Expand Down Expand Up @@ -95,6 +96,7 @@ function LanguageRoutes() {
<Route path="/privacy" element={<PrivacyPage />} />
<Route path="/terms" element={<TermsPage />} />
<Route path="/dmca" element={<DmcaPage />} />
<Route path="/delete-account" element={<DeleteAccountPage />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="/mcp" element={<McpLandingPage />} />
<Route path="/library" element={<LibraryPage />} />
Expand Down Expand Up @@ -149,6 +151,7 @@ function AppRoutes() {
<Route path="/privacy" element={<LegacyRedirect />} />
<Route path="/terms" element={<LegacyRedirect />} />
<Route path="/dmca" element={<LegacyRedirect />} />
<Route path="/delete-account" element={<LegacyRedirect />} />
<Route path="/contact" element={<LegacyRedirect />} />
<Route path="/library" element={<LegacyRedirect />} />
<Route path="/stats" element={<LegacyRedirect />} />
Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/api/__tests__/deleteAccount.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { deleteAccount } from '../auth'

describe('deleteAccount', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})

it('DELETEs /me/account with credentials and resolves on 204', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 204,
text: async () => '',
})
vi.stubGlobal('fetch', fetchMock)

await expect(deleteAccount()).resolves.toBeUndefined()

expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, opts] = fetchMock.mock.calls[0]
expect(String(url)).toContain('/me/account')
expect(opts.method).toBe('DELETE')
expect(opts.credentials).toBe('include')

vi.unstubAllGlobals()
})

it('rejects on a non-ok response', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ error: 'boom' }),
})
vi.stubGlobal('fetch', fetchMock)

await expect(deleteAccount()).rejects.toThrow('boom')

vi.unstubAllGlobals()
})
})
5 changes: 5 additions & 0 deletions apps/web/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,11 @@ export async function deleteAvatar(): Promise<void> {
await authFetch<void>('/me/profile/avatar', { method: 'DELETE' })
}

// Permanently deletes the authenticated user and ALL their data. Backend → 204.
export async function deleteAccount(): Promise<void> {
await authFetch<void>('/me/account', { method: 'DELETE' })
}

// Reading Progress API
export interface ReadingProgressDto {
editionId: string
Expand Down
Loading
Loading