From fb2b2a86cce46457e31ef524dc9bb68a1d6bd292 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Wed, 8 Jul 2026 15:51:09 +0200 Subject: [PATCH 1/2] feat: add account deletion with password verification and cascading DB cleanup --- src/app/layouts/ProtectedLayout.tsx | 2 + src/features/auth/model/auth-service.test.ts | 83 +++++++++++++++++++ src/features/auth/model/auth-service.ts | 34 ++++++++ .../model/delete-account-error-messages.ts | 24 ++++++ .../auth/ui/DeleteAccountDialog.test.tsx | 68 +++++++++++++++ src/features/auth/ui/DeleteAccountDialog.tsx | 43 ++++++++++ .../settings/ui/AccountSection.test.tsx | 12 ++- src/features/settings/ui/AccountSection.tsx | 3 +- src/shared/api/supabase-account.test.ts | 46 ++++++++++ src/shared/api/supabase-account.ts | 17 ++++ src/shared/auth/auth.types.ts | 1 + src/shared/auth/supabase-adapter.ts | 11 +++ src/shared/i18n/locales/cs/auth.json | 12 +++ src/shared/i18n/locales/en/auth.json | 12 +++ src/shared/stores/dialogs-store.ts | 1 + src/shared/types/supabase-schema.ts | 3 + src/shared/ui/PasswordConfirmDialog.test.tsx | 5 ++ src/shared/ui/PasswordConfirmDialog.tsx | 6 +- src/shared/ui/form/SubmitButton.tsx | 3 + supabase/migrations/00006_recovery_rpc.sql | 6 ++ .../migrations/00007_key_rotation_rpc.sql | 4 + .../migrations/00008_delete_account_rpc.sql | 37 +++++++++ 22 files changed, 427 insertions(+), 6 deletions(-) create mode 100644 src/features/auth/model/delete-account-error-messages.ts create mode 100644 src/features/auth/ui/DeleteAccountDialog.test.tsx create mode 100644 src/features/auth/ui/DeleteAccountDialog.tsx create mode 100644 src/shared/api/supabase-account.test.ts create mode 100644 src/shared/api/supabase-account.ts create mode 100644 supabase/migrations/00008_delete_account_rpc.sql 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 (