Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand Down Expand Up @@ -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()
})
})
2 changes: 2 additions & 0 deletions src/app/layouts/ProtectedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -110,6 +111,7 @@ function AuthenticatedLayout() {
{/* Dialogs */}
<VaultUnlockDialog />
<ChangePasswordDialog />
<DeleteAccountDialog />
<RegenerateMnemonicDialog />
<VerifyMnemonicDialog />
<RotateFieldKeyDialog />
Expand Down
83 changes: 83 additions & 0 deletions src/features/auth/model/auth-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
}))

Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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')
})
})
34 changes: 34 additions & 0 deletions src/features/auth/model/auth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -192,6 +193,39 @@ export async function restoreSession(): Promise<void> {
}
}

/**
* 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<void> {
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.
Expand Down
24 changes: 24 additions & 0 deletions src/features/auth/model/delete-account-error-messages.ts
Original file line number Diff line number Diff line change
@@ -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)
}
68 changes: 68 additions & 0 deletions src/features/auth/ui/DeleteAccountDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<DeleteAccountDialog />)

expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /delete account/i })).toBeInTheDocument()
})

it('does not render when closed', () => {
render(<DeleteAccountDialog />)

expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument()
})

it('calls deleteUserAccount with entered password on submit', async () => {
useDeleteAccountDialogStore.getState().open()
mockDeleteUserAccount.mockResolvedValueOnce(undefined)
render(<DeleteAccountDialog />)
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(<DeleteAccountDialog />)
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()
})
})
})
43 changes: 43 additions & 0 deletions src/features/auth/ui/DeleteAccountDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PasswordConfirmDialog
isOpen={isOpen}
onClose={closeDialog}
onConfirm={handleConfirm}
mapError={mapError}
title={t('deleteAccount.title')}
description={t('deleteAccount.description')}
submitLabel={t('deleteAccount.submit')}
isSubmittingLabel={t('deleteAccount.submitting')}
variant="destructive"
submitTestId="delete-account-submit"
/>
)
}

export { DeleteAccountDialog }
Loading
Loading