diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts index 93e7ef7..79b4051 100644 --- a/e2e/auth.spec.ts +++ b/e2e/auth.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test' -import { resetUserData } from './helpers/db' -import { clickSidebarButton, clickSidebarButtonByName } from './helpers/navigation' +import { queryRaw, resetUserData } from './helpers/db' +import { clickFirstEntry, clickSidebarButton, clickSidebarButtonByName, navigateToSettings } from './helpers/navigation' import { login, registerUser, uniqueUsername } from './helpers/users' /** @@ -138,4 +138,72 @@ test.describe('auth', () => { await expect(page.getByTestId('register-submit')).toBeDisabled() await expect(page).toHaveURL(/\/register$/) }) + + test('delete account: wrong password shows error and leaves the session intact', async ({ page }, testInfo) => { + const username = uniqueUsername('delbadpw') + await registerUser(page, username, PASSWORD) + + // Settings → Delete Account. Navigate in-app so the vault stays unlocked. + await navigateToSettings(page, testInfo) + await page.waitForURL('**/settings') + await page.getByTestId('settings-delete-account').click() + + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + + // Wrong password: deleteUserAccount re-derives authHash and calls + // authAdapter.login → INVALID_CREDENTIALS → the dialog maps it to the + // wrong-password error string. No server state mutates. + await dialog.locator('#password-confirm').fill('DefinitelyNotThePassword!') + await page.getByTestId('delete-account-submit').click() + + await expect(page.getByText('Password is incorrect', { exact: true })).toBeVisible() + // The dialog stays open for retry — dismiss it and verify the session is + // still alive (vault unlocked, can navigate to an entry). + await page.keyboard.press('Escape') + await expect(dialog).not.toBeVisible() + + // Session intact: navigate back to an entry. The field editor renders only + // while unlocked, so field-input-note visible proves the keys survived. + await clickFirstEntry(page, testInfo) + await expect(page).toHaveURL(/\/dashboard\/[^/]+$/) + await expect(page.getByTestId('field-input-note')).toBeVisible() + }) + + test('delete account: correct password deletes the user and login fails afterward', async ({ page }, testInfo) => { + const username = uniqueUsername('delete') + await registerUser(page, username, PASSWORD) + + // Settings → Delete Account. Navigate in-app so the vault stays unlocked. + await navigateToSettings(page, testInfo) + await page.waitForURL('**/settings') + await page.getByTestId('settings-delete-account').click() + + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + + await dialog.locator('#password-confirm').fill(PASSWORD) + await page.getByTestId('delete-account-submit').click() + + // Correct password → deleteUserAccount succeeds → logoutCleanup clears + // local state → navigate to /login → success toast. + await expect(page).toHaveURL(/\/login$/) + await expect(page.getByText('Account deleted successfully', { exact: true })).toBeVisible() + + // The delete_account RPC deleted from auth.users with ON DELETE CASCADE, + // so all user data (public.users, login_salts, master_keys, field_keys, + // entries, encrypted_fields, recovery_keys) must be gone. + const userRows = await queryRaw<{ id: string }>( + 'SELECT id FROM auth.users WHERE email = $1', + [`${username}@ciphernote.internal`], + ) + expect(userRows).toHaveLength(0) + + // Login with the now-deleted user's credentials must fail. + await page.locator('#username').fill(username) + await page.locator('#password').fill(PASSWORD) + await page.getByTestId('login-submit').click() + await expect(page).toHaveURL(/\/login$/) + await expect(page.getByText('Invalid username or password', { exact: true })).toBeVisible() + }) }) diff --git a/src/app/layouts/ProtectedLayout.tsx b/src/app/layouts/ProtectedLayout.tsx index 7549070..2275cd8 100644 --- a/src/app/layouts/ProtectedLayout.tsx +++ b/src/app/layouts/ProtectedLayout.tsx @@ -10,6 +10,7 @@ import { ResizeHandle } from '@/shared/ui/nav/ResizeHandle' import { VaultIndicator } from '@/features/vault/ui/VaultIndicator' import { VaultUnlockDialog } from '@/features/vault/ui/VaultUnlockDialog' import { ChangePasswordDialog } from '@/features/auth/ui/ChangePasswordDialog' +import { DeleteAccountDialog } from '@/features/auth/ui/DeleteAccountDialog' import { RegenerateMnemonicDialog } from '@/features/auth/ui/RegenerateMnemonicDialog' import { VerifyMnemonicDialog } from '@/features/auth/ui/VerifyMnemonicDialog' import { RotateFieldKeyDialog } from '@/features/fields/ui/RotateFieldKeyDialog' @@ -110,6 +111,7 @@ function AuthenticatedLayout() { {/* Dialogs */} + diff --git a/src/features/auth/model/auth-service.test.ts b/src/features/auth/model/auth-service.test.ts index 65c3064..d783b30 100644 --- a/src/features/auth/model/auth-service.test.ts +++ b/src/features/auth/model/auth-service.test.ts @@ -117,6 +117,7 @@ vi.mock('@/shared/auth/supabase-adapter', () => ({ getSession: vi.fn().mockResolvedValue(null), onAuthStateChange: vi.fn().mockReturnValue(vi.fn()), updatePassword: vi.fn().mockResolvedValue(undefined), + deleteAccount: vi.fn().mockResolvedValue(undefined), }, })) @@ -192,6 +193,7 @@ import { restoreSession, subscribeToAuthChanges, changeUserPassword, + deleteUserAccount, } from '@/features/auth/model/auth-service' import { deriveRegistrationKeys } from '@/features/auth/model/registration-crypto' import { authAdapter } from '@/shared/auth/supabase-adapter' @@ -688,3 +690,84 @@ describe('changeUserPassword', () => { expect(authAdapter.updatePassword).not.toHaveBeenCalled() }) }) + +describe('deleteUserAccount', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useAuthStore.getState).mockReturnValue({ + setLoading: mockSetLoading, + setAuth: mockSetAuth, + setRestoringSession: mockSetRestoringSession, + reset: mockReset, + isRestoringSession: false, + user: { id: 'user-1', username: 'testuser', createdAt: '2024-01-01' }, + session: { accessToken: 'tok', expiresAt: 0 }, + setUser: vi.fn(), + setSession: vi.fn(), + isLoading: false, + }) + }) + + it('fetches salts, derives credentials, verifies password, then deletes account', async () => { + vi.mocked(authAdapter.login).mockResolvedValueOnce({ + user: { id: 'user-1', username: 'testuser', createdAt: '2024-01-01' }, + session: { accessToken: 'tok', expiresAt: 0 }, + }) + vi.mocked(authAdapter.deleteAccount).mockResolvedValueOnce(undefined) + + await deleteUserAccount('testpass123') + + expect(fetchLoginSalts).toHaveBeenCalledWith('testuser') + expect(deriveAuthCredentials).toHaveBeenCalledWith('testpass123', expect.any(Uint8Array)) + expect(authAdapter.login).toHaveBeenCalledWith('testuser', 'a'.repeat(64)) + expect(authAdapter.deleteAccount).toHaveBeenCalled() + }) + + it('calls logoutCleanup after successful deletion', async () => { + vi.mocked(authAdapter.login).mockResolvedValueOnce({ + user: { id: 'user-1', username: 'testuser', createdAt: '2024-01-01' }, + session: { accessToken: 'tok', expiresAt: 0 }, + }) + vi.mocked(authAdapter.deleteAccount).mockResolvedValueOnce(undefined) + + await deleteUserAccount('testpass123') + + expect(mockClearVault).toHaveBeenCalled() + expect(mockReset).toHaveBeenCalled() + expect(terminateWorker).toHaveBeenCalled() + }) + + it('throws AuthError(INVALID_CREDENTIALS) when password is wrong', async () => { + vi.mocked(authAdapter.login).mockRejectedValueOnce(new AuthError(AuthErrorCode.INVALID_CREDENTIALS)) + + await expect(deleteUserAccount('wrongpass')).rejects.toThrow() + + expect(authAdapter.deleteAccount).not.toHaveBeenCalled() + expect(mockClearVault).not.toHaveBeenCalled() + }) + + it('re-throws non-INVALID_CREDENTIALS errors from login', async () => { + vi.mocked(authAdapter.login).mockRejectedValueOnce(new AuthError(AuthErrorCode.NETWORK_ERROR)) + + await expect(deleteUserAccount('testpass123')).rejects.toThrow() + + expect(authAdapter.deleteAccount).not.toHaveBeenCalled() + }) + + it('throws when no user is authenticated', async () => { + vi.mocked(useAuthStore.getState).mockReturnValue({ + setLoading: mockSetLoading, + setAuth: mockSetAuth, + setRestoringSession: mockSetRestoringSession, + reset: mockReset, + isRestoringSession: false, + user: null, + session: null, + isLoading: false, + setUser: vi.fn(), + setSession: vi.fn(), + }) + + await expect(deleteUserAccount('testpass123')).rejects.toThrow('No authenticated user') + }) +}) diff --git a/src/features/auth/model/auth-service.ts b/src/features/auth/model/auth-service.ts index 30748f8..52f4a08 100644 --- a/src/features/auth/model/auth-service.ts +++ b/src/features/auth/model/auth-service.ts @@ -2,6 +2,7 @@ import { deriveRegistrationKeys } from '@/features/auth/model/registration-crypt import { useAuthStore } from '@/features/auth/model/auth-store' import { useCryptoStore } from '@/shared/crypto/vault/crypto-store' import { authAdapter } from '@/shared/auth/supabase-adapter' +import { AuthErrorCode, isAuthError } from '@/shared/auth/auth-errors' import { uploadRegistrationData } from '@/shared/api/supabase-registration' import { fetchLoginSalts, updateMasterKeyEnvelope, fetchFreshEnvelope } from '@/shared/api/supabase-keys' import { hexDecode, hexEncode, zeroFill } from '@/shared/crypto/core/crypto-utils' @@ -192,6 +193,39 @@ export async function restoreSession(): Promise { } } +/** + * Deletes the authenticated user's account and all associated data. + * + * Verifies the password by re-deriving the auth hash and calling login. + * If the password is wrong, throws AuthError(INVALID_CREDENTIALS). + * If the password is correct, calls the server-side delete_account RPC + * (which cascades through all user data), then clears all local state. + */ +export async function deleteUserAccount(password: string): Promise { + const { user } = useAuthStore.getState() + if (!user) throw new Error('No authenticated user') + + // 1. Verify the password by re-deriving authHash and attempting login. + // This prevents accidental deletion from an unlocked session. + const { kdfSalt } = await fetchLoginSalts(user.username) + const { authHash } = await deriveAuthCredentials(password, hexDecode(kdfSalt)) + + try { + await authAdapter.login(user.username, authHash) + } catch (error) { + if (isAuthError(error) && error.code === AuthErrorCode.INVALID_CREDENTIALS) { + throw error + } + throw error + } + + // 2. Delete the account (server-side RPC + signOut) + await authAdapter.deleteAccount() + + // 3. Clear all local state + logoutCleanup() +} + /** * Subscribes to auth state changes from the adapter and syncs the * current user/session into the auth store. diff --git a/src/features/auth/model/delete-account-error-messages.ts b/src/features/auth/model/delete-account-error-messages.ts new file mode 100644 index 0000000..c4df7e4 --- /dev/null +++ b/src/features/auth/model/delete-account-error-messages.ts @@ -0,0 +1,24 @@ +import type { TFunction } from 'i18next' +import { AuthErrorCode } from '@/shared/auth/auth-errors' +import { ApiErrorCode } from '@/shared/api/api-errors' +import { mapErrorToMessage, type ErrorKeySpec } from '@/shared/lib/error-messages' + +const DELETE_ACCOUNT_ERROR_SPEC: ErrorKeySpec = { + authCodes: { + [AuthErrorCode.INVALID_CREDENTIALS]: 'deleteAccount.errors.wrongPassword', + [AuthErrorCode.NETWORK_ERROR]: 'deleteAccount.errors.networkError', + }, + apiCodes: { + [ApiErrorCode.NETWORK_ERROR]: 'deleteAccount.errors.networkError', + }, + networkKey: 'deleteAccount.errors.networkError', + fallbackKey: 'deleteAccount.errors.unexpectedError', +} + +/** + * Maps errors from the delete account flow to user-facing i18n strings + * in the 'auth' namespace. + */ +export function getDeleteAccountErrorMessage(error: unknown, t: TFunction<'auth'>): string { + return mapErrorToMessage(error, t, DELETE_ACCOUNT_ERROR_SPEC) +} diff --git a/src/features/auth/ui/DeleteAccountDialog.test.tsx b/src/features/auth/ui/DeleteAccountDialog.test.tsx new file mode 100644 index 0000000..211e9ec --- /dev/null +++ b/src/features/auth/ui/DeleteAccountDialog.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@/test/utils' +import userEvent from '@testing-library/user-event' + +import { DeleteAccountDialog } from './DeleteAccountDialog' +import { useDeleteAccountDialogStore } from '@/shared/stores/dialogs-store' + +vi.mock('@/features/auth/model/auth-service', () => ({ + deleteUserAccount: vi.fn(), +})) + +vi.mock('@/features/auth/model/delete-account-error-messages', () => ({ + getDeleteAccountErrorMessage: vi.fn((_error: unknown, t: (key: string) => string) => + t('deleteAccount.errors.wrongPassword'), + ), +})) + +import { deleteUserAccount } from '@/features/auth/model/auth-service' + +const mockDeleteUserAccount = vi.mocked(deleteUserAccount) + +describe('DeleteAccountDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + useDeleteAccountDialogStore.getState().close() + }) + + it('renders password confirm dialog when open', () => { + useDeleteAccountDialogStore.getState().open() + render() + + expect(screen.getByLabelText(/password/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /delete account/i })).toBeInTheDocument() + }) + + it('does not render when closed', () => { + render() + + expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument() + }) + + it('calls deleteUserAccount with entered password on submit', async () => { + useDeleteAccountDialogStore.getState().open() + mockDeleteUserAccount.mockResolvedValueOnce(undefined) + render() + const user = userEvent.setup() + + await user.type(screen.getByLabelText(/password/i), 'my-password') + await user.click(screen.getByRole('button', { name: /delete account/i })) + + expect(mockDeleteUserAccount).toHaveBeenCalledWith('my-password') + }) + + it('shows error message when password confirmation fails', async () => { + useDeleteAccountDialogStore.getState().open() + const { AuthError, AuthErrorCode } = await import('@/shared/auth/auth-errors') + mockDeleteUserAccount.mockRejectedValueOnce(new AuthError(AuthErrorCode.INVALID_CREDENTIALS)) + render() + const user = userEvent.setup() + + await user.type(screen.getByLabelText(/password/i), 'wrong') + await user.click(screen.getByRole('button', { name: /delete account/i })) + + await vi.waitFor(() => { + expect(screen.getByText(/password is incorrect/i)).toBeInTheDocument() + }) + }) +}) diff --git a/src/features/auth/ui/DeleteAccountDialog.tsx b/src/features/auth/ui/DeleteAccountDialog.tsx new file mode 100644 index 0000000..b6aa5fe --- /dev/null +++ b/src/features/auth/ui/DeleteAccountDialog.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from 'react-i18next' +import { useNavigate } from '@tanstack/react-router' +import { toast } from 'sonner' + +import { PasswordConfirmDialog } from '@/shared/ui/PasswordConfirmDialog' +import { useDeleteAccountDialogStore } from '@/shared/stores/dialogs-store' +import { deleteUserAccount } from '@/features/auth/model/auth-service' +import { getDeleteAccountErrorMessage } from '@/features/auth/model/delete-account-error-messages' + +function DeleteAccountDialog() { + const { t } = useTranslation('auth') + const navigate = useNavigate() + const isOpen = useDeleteAccountDialogStore((s) => s.isOpen) + const closeDialog = useDeleteAccountDialogStore((s) => s.close) + + async function handleConfirm(password: string) { + await deleteUserAccount(password) + toast.success(t('deleteAccount.success')) + closeDialog() + navigate({ to: '/login' }) + } + + function mapError(error: unknown) { + return getDeleteAccountErrorMessage(error, t) + } + + return ( + + ) +} + +export { DeleteAccountDialog } diff --git a/src/features/settings/ui/AccountSection.test.tsx b/src/features/settings/ui/AccountSection.test.tsx index e3be778..2fe5665 100644 --- a/src/features/settings/ui/AccountSection.test.tsx +++ b/src/features/settings/ui/AccountSection.test.tsx @@ -2,13 +2,14 @@ import { describe, it, expect, beforeEach } from 'vitest' import { render, screen } from '@/test/utils' import userEvent from '@testing-library/user-event' import { useAuthStore } from '@/features/auth/model/auth-store' -import { useChangePasswordDialogStore } from '@/shared/stores/dialogs-store' +import { useChangePasswordDialogStore, useDeleteAccountDialogStore } from '@/shared/stores/dialogs-store' import { AccountSection } from './AccountSection' describe('AccountSection', () => { beforeEach(() => { useChangePasswordDialogStore.setState({ isOpen: false }) + useDeleteAccountDialogStore.setState({ isOpen: false }) }) it('renders section title and description', () => { @@ -43,9 +44,12 @@ describe('AccountSection', () => { expect(useChangePasswordDialogStore.getState().isOpen).toBe(true) }) - it('renders delete account item as inactive (no onClick handler)', () => { + it('opens delete account dialog when clicking "Delete account"', async () => { + const user = userEvent.setup() render() - expect(screen.getByText('Delete account')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /delete account/i })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /delete account/i })) + + expect(useDeleteAccountDialogStore.getState().isOpen).toBe(true) }) }) diff --git a/src/features/settings/ui/AccountSection.tsx b/src/features/settings/ui/AccountSection.tsx index 8460b60..6350ed9 100644 --- a/src/features/settings/ui/AccountSection.tsx +++ b/src/features/settings/ui/AccountSection.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next' import { KeyRound, Trash2, User } from 'lucide-react' import { useCurrentUser } from '@/shared/auth/use-current-user' -import { useChangePasswordDialogStore } from '@/shared/stores/dialogs-store' +import { useChangePasswordDialogStore, useDeleteAccountDialogStore } from '@/shared/stores/dialogs-store' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/shared/ui/card' import { Separator } from '@/shared/ui/separator' import { SettingsItem } from '@/features/settings/ui/SettingsItem' @@ -38,6 +38,7 @@ function AccountSection() { label={t('account.deleteAccount')} variant="destructive" testId="settings-delete-account" + onClick={() => useDeleteAccountDialogStore.getState().open()} /> diff --git a/src/shared/api/supabase-account.test.ts b/src/shared/api/supabase-account.test.ts new file mode 100644 index 0000000..73f73c0 --- /dev/null +++ b/src/shared/api/supabase-account.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ApiError, ApiErrorCode } from '@/shared/api/api-errors' + +vi.mock('@/shared/api/supabase-client', () => ({ + getSupabase: vi.fn(), +})) + +import { getSupabase } from '@/shared/api/supabase-client' +import { deleteAccount } from './supabase-account' + +const mockRpc = vi.fn() +const mockGetSupabase = vi.mocked(getSupabase) + +beforeEach(() => { + vi.clearAllMocks() + mockGetSupabase.mockReturnValue({ auth: {}, from: vi.fn(), rpc: mockRpc } as unknown as ReturnType< + typeof getSupabase + >) +}) + +describe('deleteAccount', () => { + it('calls the delete_account RPC', async () => { + mockRpc.mockResolvedValue({ data: null, error: null }) + + await deleteAccount() + + expect(mockRpc).toHaveBeenCalledWith('delete_account') + }) + + it('returns void on success', async () => { + mockRpc.mockResolvedValue({ data: null, error: null }) + + const result = await deleteAccount() + + expect(result).toBeUndefined() + }) + + it('throws ApiError(UNEXPECTED) when RPC returns an error', async () => { + const rpcError = { message: 'Not authenticated', code: 'P0001', details: '' } + mockRpc.mockResolvedValue({ data: null, error: rpcError }) + + const error = await deleteAccount().catch((e) => e) + expect(error).toBeInstanceOf(ApiError) + expect((error as ApiError).code).toBe(ApiErrorCode.UNEXPECTED) + }) +}) diff --git a/src/shared/api/supabase-account.ts b/src/shared/api/supabase-account.ts new file mode 100644 index 0000000..97a88f5 --- /dev/null +++ b/src/shared/api/supabase-account.ts @@ -0,0 +1,17 @@ +import { getSupabase } from '@/shared/api/supabase-client' +import { wrapApiError } from '@/shared/api/api-errors' +import { DELETE_ACCOUNT_RPC } from '@/shared/types/supabase-schema' + +/** + * Delete the authenticated user's account and all associated data. + * + * Calls the `delete_account` SECURITY DEFINER RPC, which deletes from + * `auth.users` — cascading through all public tables via ON DELETE CASCADE. + * The client must verify the user's password before calling this function + * to prevent accidental deletion from an unlocked session. + */ +export async function deleteAccount(): Promise { + const { error } = await getSupabase().rpc(DELETE_ACCOUNT_RPC) + + if (error) throw wrapApiError(error) +} diff --git a/src/shared/auth/auth.types.ts b/src/shared/auth/auth.types.ts index d34556e..a79a21a 100644 --- a/src/shared/auth/auth.types.ts +++ b/src/shared/auth/auth.types.ts @@ -20,6 +20,7 @@ export interface IAuthAdapter { signup(username: string, authHash: string): Promise recoverPassword(username: string, recoveryData: RecoveryCredentials): Promise updatePassword(newAuthHash: string): Promise + deleteAccount(): Promise onAuthStateChange(callback: AuthStateChangeCallback): AuthUnsubscribe } diff --git a/src/shared/auth/supabase-adapter.ts b/src/shared/auth/supabase-adapter.ts index 7577318..0d9f953 100644 --- a/src/shared/auth/supabase-adapter.ts +++ b/src/shared/auth/supabase-adapter.ts @@ -1,4 +1,5 @@ import type { User, UserSession } from '@/shared/types/entities/user.types' +import { deleteAccount as deleteAccountRpc } from '@/shared/api/supabase-account' import type { AuthResult, AuthStateChangeCallback, @@ -68,6 +69,16 @@ class SupabaseAuthAdapter implements IAuthAdapter { if (error) throw wrapSupabaseAuthError(error) } + async deleteAccount(): Promise { + await deleteAccountRpc() + // Session is invalidated by the DELETE; signOut clears local state. + try { + await getSupabase().auth.signOut() + } catch { + // signOut may fail since the user no longer exists server-side + } + } + onAuthStateChange(callback: AuthStateChangeCallback): AuthUnsubscribe { const { data } = getSupabase().auth.onAuthStateChange((_event, supabaseSession) => { if (!supabaseSession) { diff --git a/src/shared/i18n/locales/cs/auth.json b/src/shared/i18n/locales/cs/auth.json index c0bc93c..466e87a 100644 --- a/src/shared/i18n/locales/cs/auth.json +++ b/src/shared/i18n/locales/cs/auth.json @@ -148,5 +148,17 @@ "notFound": "Účet nenalezen. Přihlaste se znovu.", "unexpectedError": "Došlo k neočekávané chybě. Zkuste to znovu." } + }, + "deleteAccount": { + "title": "Smazat účet", + "description": "⚠️ Tato akce trvale smaže váš účet a všechna vaše data. Nelze ji vrátit zpět.", + "submit": "Smazat účet", + "submitting": "Mazání...", + "success": "Účet byl úspěšně smazán", + "errors": { + "wrongPassword": "Nesprávné heslo", + "networkError": "Chyba sítě. Zkuste to prosím znovu.", + "unexpectedError": "Došlo k neočekávané chybě. Zkuste to prosím znovu." + } } } diff --git a/src/shared/i18n/locales/en/auth.json b/src/shared/i18n/locales/en/auth.json index def3466..bbfa5e1 100644 --- a/src/shared/i18n/locales/en/auth.json +++ b/src/shared/i18n/locales/en/auth.json @@ -148,5 +148,17 @@ "notFound": "Account not found. Please log in again.", "unexpectedError": "An unexpected error occurred. Please try again." } + }, + "deleteAccount": { + "title": "Delete Account", + "description": "⚠️ This will permanently delete your account and all your data. This action cannot be undone.", + "submit": "Delete Account", + "submitting": "Deleting...", + "success": "Account deleted successfully", + "errors": { + "wrongPassword": "Password is incorrect", + "networkError": "Network error. Please try again.", + "unexpectedError": "An unexpected error occurred. Please try again." + } } } diff --git a/src/shared/stores/dialogs-store.ts b/src/shared/stores/dialogs-store.ts index 9a57be8..21fca62 100644 --- a/src/shared/stores/dialogs-store.ts +++ b/src/shared/stores/dialogs-store.ts @@ -4,6 +4,7 @@ import type { FieldName } from '@/shared/types/entities/field.types' export const useChangePasswordDialogStore = createDialogStore('ChangePasswordDialogStore') export const useRegenerateMnemonicDialogStore = createDialogStore('RegenerateMnemonicDialogStore') export const useVerifyMnemonicDialogStore = createDialogStore('VerifyMnemonicDialogStore') +export const useDeleteAccountDialogStore = createDialogStore('DeleteAccountDialogStore') /** * Payload for the field-key rotation dialog. `fieldName` is null for the diff --git a/src/shared/types/supabase-schema.ts b/src/shared/types/supabase-schema.ts index c24ab91..11e21df 100644 --- a/src/shared/types/supabase-schema.ts +++ b/src/shared/types/supabase-schema.ts @@ -24,6 +24,9 @@ export const SAVE_RECOVERY_DATA_RPC = 'save_recovery_data' /** RPC function name for atomic field-key rotation (authenticated, SECURITY DEFINER). */ export const ROTATE_FIELD_KEY_RPC = 'rotate_field_key' +/** RPC function name for account deletion (authenticated, SECURITY DEFINER). */ +export const DELETE_ACCOUNT_RPC = 'delete_account' + /** Snake_case row delivered by Supabase for an `encrypted_fields` change. */ export interface EncryptedFieldRow { entry_id: string diff --git a/src/shared/ui/PasswordConfirmDialog.test.tsx b/src/shared/ui/PasswordConfirmDialog.test.tsx index d900d7a..c4c55af 100644 --- a/src/shared/ui/PasswordConfirmDialog.test.tsx +++ b/src/shared/ui/PasswordConfirmDialog.test.tsx @@ -125,6 +125,11 @@ describe('PasswordConfirmDialog', () => { await user.click(screen.getByRole('button', { name: /close/i })) }) + it('applies destructive styling when variant is destructive', () => { + renderDialog({ variant: 'destructive' }) + expect(screen.getByText('Confirm Action')).toHaveClass('text-destructive') + }) + it('hides close button and blocks Escape during submission', async () => { let resolve!: () => void const onConfirm = vi.fn(() => new Promise((r) => (resolve = r))) diff --git a/src/shared/ui/PasswordConfirmDialog.tsx b/src/shared/ui/PasswordConfirmDialog.tsx index 7936079..c8de159 100644 --- a/src/shared/ui/PasswordConfirmDialog.tsx +++ b/src/shared/ui/PasswordConfirmDialog.tsx @@ -24,6 +24,8 @@ interface PasswordConfirmDialogProps { description: string submitLabel: string isSubmittingLabel: string + /** When 'destructive', the submit button uses danger styling and the title turns red. */ + variant?: 'default' | 'destructive' /** Stable selector for the submit button (rendered as `data-testid`). */ submitTestId?: string } @@ -37,6 +39,7 @@ function PasswordConfirmDialog({ description, submitLabel, isSubmittingLabel, + variant, submitTestId, }: PasswordConfirmDialogProps) { const { t } = useTranslation('auth') @@ -74,7 +77,7 @@ function PasswordConfirmDialog({ - {title} + {title} {description} @@ -94,6 +97,7 @@ function PasswordConfirmDialog({ isSubmitting={isSubmitting} submitLabel={submitLabel} submittingLabel={isSubmittingLabel} + variant={variant} dataTestId={submitTestId} /> diff --git a/src/shared/ui/form/SubmitButton.tsx b/src/shared/ui/form/SubmitButton.tsx index 35a9288..b643291 100644 --- a/src/shared/ui/form/SubmitButton.tsx +++ b/src/shared/ui/form/SubmitButton.tsx @@ -8,6 +8,7 @@ interface SubmitButtonProps { submittingLabel: string disabled?: boolean className?: string + variant?: 'default' | 'destructive' /** Stable selector for E2E tests (rendered as `data-testid`). */ dataTestId?: string } @@ -18,11 +19,13 @@ function SubmitButton({ submittingLabel, disabled, className, + variant, dataTestId, }: SubmitButtonProps) { return (