diff --git a/apps/mobile/app/(tabs)/profile.tsx b/apps/mobile/app/(tabs)/profile.tsx index 5f85e0b0..8b85dfe5 100644 --- a/apps/mobile/app/(tabs)/profile.tsx +++ b/apps/mobile/app/(tabs)/profile.tsx @@ -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' @@ -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 @@ -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 ( @@ -317,6 +374,33 @@ export default function ProfileScreen() { Sign Out + + {/* Danger zone — destructive, visually separated from normal settings. + Hidden for guest accounts (nothing server-side to delete). */} + {!isGuest && ( + + Danger zone + + {deleting ? ( + + ) : ( + + )} + + Delete account + + Permanently removes your account and all data. Cannot be undone. + + + + + )} { + 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 } diff --git a/apps/web/e2e/tests/delete-account.spec.ts b/apps/web/e2e/tests/delete-account.spec.ts new file mode 100644 index 00000000..b6b451f2 --- /dev/null +++ b/apps/web/e2e/tests/delete-account.spec.ts @@ -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() + }) +}) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c6e67ba5..61ab9cdc 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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' @@ -95,6 +96,7 @@ function LanguageRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -149,6 +151,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/api/__tests__/deleteAccount.test.ts b/apps/web/src/api/__tests__/deleteAccount.test.ts new file mode 100644 index 00000000..cfae2b32 --- /dev/null +++ b/apps/web/src/api/__tests__/deleteAccount.test.ts @@ -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() + }) +}) diff --git a/apps/web/src/api/auth.ts b/apps/web/src/api/auth.ts index a2713c5b..8ff4ea83 100644 --- a/apps/web/src/api/auth.ts +++ b/apps/web/src/api/auth.ts @@ -157,6 +157,11 @@ export async function deleteAvatar(): Promise { await authFetch('/me/profile/avatar', { method: 'DELETE' }) } +// Permanently deletes the authenticated user and ALL their data. Backend → 204. +export async function deleteAccount(): Promise { + await authFetch('/me/account', { method: 'DELETE' }) +} + // Reading Progress API export interface ReadingProgressDto { editionId: string diff --git a/apps/web/src/components/Footer.tsx b/apps/web/src/components/Footer.tsx index 8f5aeacb..b380ca6a 100644 --- a/apps/web/src/components/Footer.tsx +++ b/apps/web/src/components/Footer.tsx @@ -19,6 +19,7 @@ export function Footer() { {t('footer.privacy')} {t('footer.terms')} {t('footer.dmca')} + {t('footer.deleteAccount')} {t('footer.contact')} {t('footer.sitemap')} diff --git a/apps/web/src/components/auth/DeleteAccountDialog.tsx b/apps/web/src/components/auth/DeleteAccountDialog.tsx new file mode 100644 index 00000000..b533c5a9 --- /dev/null +++ b/apps/web/src/components/auth/DeleteAccountDialog.tsx @@ -0,0 +1,101 @@ +import { useState, FormEvent } from 'react' +import { createPortal } from 'react-dom' +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../../context/AuthContext' +import { useLanguage } from '../../context/LanguageContext' +import { useTranslation } from '../../hooks/useTranslation' + +/** The exact phrase the user must type to arm the destructive confirm button. */ +export const DELETE_CONFIRM_PHRASE = 'DELETE' + +/** Pure gate: confirm is enabled only when the typed phrase matches exactly (trimmed). */ +export function isDeleteConfirmed(input: string): boolean { + return input.trim() === DELETE_CONFIRM_PHRASE +} + +export function DeleteAccountDialog({ onClose }: { onClose: () => void }) { + const { deleteAccount } = useAuth() + const { getLocalizedPath } = useLanguage() + const { t } = useTranslation() + const navigate = useNavigate() + const [confirmText, setConfirmText] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + const armed = isDeleteConfirmed(confirmText) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + if (!armed || loading) return + setError('') + setLoading(true) + try { + await deleteAccount() + // Session cleared by deleteAccount() — leave to home. + navigate(getLocalizedPath('/'), { replace: true }) + } catch (err: any) { + setError(err?.message || t('deleteAccount.error')) + setLoading(false) + } + } + + return createPortal( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="delete-account-title" + > + {!loading && ( + + )} +
+

+ {t('deleteAccount.title')} +

+
+

{t('deleteAccount.warning')}

+ + setConfirmText(e.target.value)} + placeholder={DELETE_CONFIRM_PHRASE} + autoComplete="off" + autoCapitalize="characters" + spellCheck={false} + disabled={loading} + /> + {error &&

{error}

} +
+ + +
+
+
+
+
, + document.body, + ) +} diff --git a/apps/web/src/components/auth/ProfileModal.tsx b/apps/web/src/components/auth/ProfileModal.tsx index dc6038b5..9e44cb1c 100644 --- a/apps/web/src/components/auth/ProfileModal.tsx +++ b/apps/web/src/components/auth/ProfileModal.tsx @@ -4,9 +4,13 @@ import { useAuth } from '../../context/AuthContext' import { POPULAR_LANGUAGES, getLanguage, getFlagUrl } from '../../data/languages' import { getAnonymousReader } from '@textstack/shared' import { getUserInitials } from '../../lib/userInitials' +import { useTranslation } from '../../hooks/useTranslation' +import { DeleteAccountDialog } from './DeleteAccountDialog' export function ProfileModal({ onClose }: { onClose: () => void }) { const { user, updateProfile, updateAvatar, deleteAvatar } = useAuth() + const { t } = useTranslation() + const [showDeleteAccount, setShowDeleteAccount] = useState(false) const [name, setName] = useState(user?.name || '') const [nativeLanguage, setNativeLanguage] = useState(user?.nativeLanguage || '') const [preview, setPreview] = useState(null) @@ -174,8 +178,22 @@ export function ProfileModal({ onClose }: { onClose: () => void }) { {loading ? 'Saving...' : 'Save changes'} + {!isGuest && ( +
+

{t('deleteAccount.dangerZone')}

+

{t('deleteAccount.sectionDesc')}

+ +
+ )} + {showDeleteAccount && setShowDeleteAccount(false)} />} , document.body, ) diff --git a/apps/web/src/components/auth/__tests__/DeleteAccountDialog.test.tsx b/apps/web/src/components/auth/__tests__/DeleteAccountDialog.test.tsx new file mode 100644 index 00000000..e6463818 --- /dev/null +++ b/apps/web/src/components/auth/__tests__/DeleteAccountDialog.test.tsx @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest' +import { isDeleteConfirmed, DELETE_CONFIRM_PHRASE } from '../DeleteAccountDialog' + +describe('isDeleteConfirmed', () => { + it('arms only on the exact phrase', () => { + expect(isDeleteConfirmed(DELETE_CONFIRM_PHRASE)).toBe(true) + }) + + it('trims surrounding whitespace', () => { + expect(isDeleteConfirmed(' DELETE ')).toBe(true) + }) + + it('rejects empty, partial, and wrong-case input', () => { + expect(isDeleteConfirmed('')).toBe(false) + expect(isDeleteConfirmed('DELET')).toBe(false) + expect(isDeleteConfirmed('delete')).toBe(false) + expect(isDeleteConfirmed('DELETE ME')).toBe(false) + }) +}) diff --git a/apps/web/src/context/AuthContext.tsx b/apps/web/src/context/AuthContext.tsx index 3ffe5c55..c3584b8d 100644 --- a/apps/web/src/context/AuthContext.tsx +++ b/apps/web/src/context/AuthContext.tsx @@ -3,6 +3,7 @@ import { User, UpdateProfilePayload, getCurrentUser, loginWithGoogle, logout as logoutApi, refreshToken, loginWithEmail as loginWithEmailApi, registerWithEmail as registerWithEmailApi, updateProfile as updateProfileApi, uploadAvatar as uploadAvatarApi, deleteAvatar as deleteAvatarApi, + deleteAccount as deleteAccountApi, createGuestSession as createGuestSessionApi, } from '../api/auth' import { flushLocalProgress } from '../lib/progressSync' @@ -26,6 +27,8 @@ interface AuthContextValue { updateProfile: (payload: UpdateProfilePayload) => Promise updateAvatar: (file: File) => Promise deleteAvatar: () => Promise + /** Permanently deletes the account + all data, then clears the session locally. Rejects on error (caller stays signed in). */ + deleteAccount: () => Promise /** Set to true after a successful register/login. Consumer shows toast then calls dismissAuthSuccessToast. */ authSuccessToast: boolean dismissAuthSuccessToast: () => void @@ -48,6 +51,7 @@ const AuthContext = createContext({ updateProfile: async () => {}, updateAvatar: async () => {}, deleteAvatar: async () => {}, + deleteAccount: async () => {}, authSuccessToast: false, dismissAuthSuccessToast: () => {}, }) @@ -265,6 +269,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(prev => prev ? { ...prev, picture: null } : null) }, []) + // Permanently deletes the account server-side, then clears the local session. + // Mirrors logout's anonymous-after sign-out: no guest re-create here. Rethrows + // on failure so the caller keeps the user signed in and surfaces an error. + const deleteAccount = useCallback(async () => { + await deleteAccountApi() + if (typeof google !== 'undefined') { + google.accounts.id.disableAutoSelect() + } + setUser(null) + }, []) + // Public: create a guest session if not authenticated. Routes through the single-flight // helper so concurrent callers (e.g. HeroSection upload + bootstrap) share one network call. const ensureSession = useCallback(async () => { @@ -307,6 +322,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { updateProfile, updateAvatar, deleteAvatar, + deleteAccount, authSuccessToast, dismissAuthSuccessToast, }} diff --git a/apps/web/src/locales/en.json b/apps/web/src/locales/en.json index 3c6e471d..d24eeab8 100644 --- a/apps/web/src/locales/en.json +++ b/apps/web/src/locales/en.json @@ -2,6 +2,28 @@ "userMenu": { "anonymousReader": "Anonymous reader" }, + "deleteAccount": { + "dangerZone": "Danger zone", + "sectionDesc": "Permanently delete your account and everything in it.", + "openButton": "Delete account", + "title": "Delete your account?", + "warning": "This permanently deletes your account and all data — books, highlights, vocabulary, and reading history. This cannot be undone.", + "confirmLabel": "Type {{phrase}} to confirm", + "cancel": "Cancel", + "confirm": "Delete account", + "deleting": "Deleting…", + "error": "Could not delete your account. Please try again.", + "seoTitle": "Delete your account - TextStack", + "seoDesc": "How to permanently delete your TextStack account and all associated data.", + "pageTitle": "Delete your account", + "intro": "You can permanently delete your TextStack account and all associated data at any time.", + "inAppHeading": "Delete in the app", + "inAppBody": "Sign in, open the user menu in the top-right corner, choose \"Edit profile\", then scroll to the \"Danger zone\" section and select \"Delete account\". You'll be asked to type DELETE to confirm.", + "dataHeading": "What gets deleted", + "dataBody": "Deleting your account permanently removes your profile, uploaded books, highlights and notes, saved vocabulary, reading progress, and reading history. This action cannot be undone.", + "contactHeading": "Need help?", + "contactBody": "If you can't access your account but want it deleted, contact us at" + }, "home": { "hero": { "title": "Finish the book you keep quitting.", @@ -383,6 +405,7 @@ "privacy": "Privacy Policy", "terms": "Terms of Service", "dmca": "DMCA", + "deleteAccount": "Delete Account", "authors": "Authors", "contact": "Contact Us", "sitemap": "Sitemap" diff --git a/apps/web/src/pages/DeleteAccountPage.tsx b/apps/web/src/pages/DeleteAccountPage.tsx new file mode 100644 index 00000000..0569bf33 --- /dev/null +++ b/apps/web/src/pages/DeleteAccountPage.tsx @@ -0,0 +1,47 @@ +import { SeoHead } from '../components/SeoHead' +import { Footer } from '../components/Footer' +import { useTranslation } from '../hooks/useTranslation' +import { useObfuscatedEmail } from '../hooks/useObfuscatedEmail' +import './LegalPage.css' + +export function DeleteAccountPage() { + const { t } = useTranslation() + const { email, mailto } = useObfuscatedEmail() + + return ( + <> +
+ + +
+

{t('deleteAccount.pageTitle')}

+
+
+ +

{t('deleteAccount.intro')}

+ +
+

{t('deleteAccount.inAppHeading')}

+

{t('deleteAccount.inAppBody')}

+
+ +
+

{t('deleteAccount.dataHeading')}

+

{t('deleteAccount.dataBody')}

+
+ +
+

{t('deleteAccount.contactHeading')}

+

+ {t('deleteAccount.contactBody')}{' '} + {email}. +

+
+
+