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({