Skip to content

fix(auth): require verified email before account deletion#1798

Merged
riderx merged 7 commits into
mainfrom
riderx/block-unverified-2fa
Mar 16, 2026
Merged

fix(auth): require verified email before account deletion#1798
riderx merged 7 commits into
mainfrom
riderx/block-unverified-2fa

Conversation

@riderx
Copy link
Copy Markdown
Member

@riderx riderx commented Mar 16, 2026

Summary (AI generated)

  • Require a verified email before account deletion from both delete-account entry points, and redirect unverified users to /resend_email when the backend rejects deletion.
  • Add regression coverage proving delete_user() rejects unverified users and that an unverified user cannot obtain a normal Supabase SDK session.

Motivation (AI generated)

The account-deletion flow must enforce verified email ownership consistently at every user-facing entry point and keep explicit backend coverage for the RPC boundary.

Business Impact (AI generated)

This reduces the risk of account-lifecycle abuse and support escalations around deletion while making the security rule visible and consistent for users.

Test Plan (AI generated)

  • bun test tests/delete-user-reauth.test.ts
  • bunx eslint tests/delete-user-reauth.test.ts src/pages/settings/account/index.vue src/pages/delete_account.vue

Screenshots (AI generated)

  • None.

Checklist (AI generated)

  • My code follows the code style of this project and passes bun run lint:backend && bun run lint.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • My change has adequate E2E test coverage.
  • I have tested my code manually, and I have provided steps how to reproduce my tests

Generated with AI

Summary by CodeRabbit

  • New Features

    • Account deletion now enforces email verification: unverified users are redirected to resend verification and must confirm email before deleting.
  • Bug Fixes

    • Deletion errors indicating unverified email now trigger a redirect to the verification flow instead of failing silently.
  • Tests

    • Added tests covering unverified-user deletion attempts and session behavior for unverified accounts.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 16, 2026

📝 Walkthrough

Walkthrough

Adds an email-verification gate to account-deletion flows in two Vue pages: unverified users are redirected to /resend_email (with reason and return_to) and deletion is aborted. Tests added to exercise unverified-user deletion rejection and SDK session behavior.

Changes

Cohort / File(s) Summary
Email verification gate
src/pages/delete_account.vue, src/pages/settings/account/index.vue
Added isEmailVerified check and redirectToEmailVerification helper; deletion flows now abort and redirect when email is unconfirmed. Error handling updated to catch email_not_verified and redirect. settings also records orgId in internal paid-org tracking.
Unverified user tests
tests/delete-user-reauth.test.ts
Added setup/teardown to create and clean an unverified test user, utilities for anonymous SDK client, and tests asserting deletion is rejected with email_not_verified and that unverified users cannot obtain SDK sessions or access to_delete_accounts.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

💰 Rewarded

Poem

🐰 I hopped to delete, but the mail bell rang,

"Confirm first," said the link with a twang.
Back to resend, with a soft little thump,
Verify your email, then jump—jump—jump! 🥕✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The PR description covers Summary, Test Plan, and Screenshots sections. However, the Checklist section shows that linting, documentation updates, E2E test coverage, and manual testing documentation are incomplete or unchecked. Complete the Checklist items, particularly confirm linting passes, document any manual testing steps, and verify E2E test coverage is adequate for the email verification gate.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: requiring verified email before account deletion, which is the core objective of the PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch riderx/block-unverified-2fa
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8e92cc9c99

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/delete-user-reauth.test.ts Outdated
if (!env.SUPABASE_URL || !env.SUPABASE_ANON_KEY)
throw new Error('SUPABASE_URL or SUPABASE_ANON_KEY is missing for delete-user SDK test')

return createClient<Database>(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize SUPABASE_URL before creating anon test client

This helper passes env.SUPABASE_URL directly into createClient, but our test utilities intentionally normalize localhost to 127.0.0.1 to avoid Node/Undici preferring ::1 while Supabase is bound on IPv4. In environments where SUPABASE_URL is http://localhost:... (common in local runs), this new test can fail with connection errors unrelated to auth behavior, creating flaky CI/local results.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/pages/delete_account.vue (1)

44-53: ⚠️ Potential issue | 🔴 Critical

Mount-time session gating breaks the standalone delete flow.

/delete_account authenticates with the submitted credentials, so a verified user can legitimately arrive here without an existing session. getSession() then marks them unverified and hides the form, while the new test suite already shows that an unverified user never reaches the RPC path because normal SDK sign-in is rejected first. This route needs to treat “no session yet” as “not reauthenticated yet” and redirect from the reauth failure instead of blocking on mount.

Also applies to: 114-120, 167-200, 229-241

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/delete_account.vue` around lines 44 - 53, The mount-time gating
wrongly treats “no session” as an unverified user; update checkEmailVerification
(and the other similar blocks around the other occurrences) to distinguish
between a session fetch error and an absent session: call
supabase.auth.getSession(), and if there's no sessionResult?.session, do not
mark isEmailVerified.value = false — instead set a reauth-pending flag (e.g.,
isReauthenticated.value = false) or simply return so the UI remains in the
reauthentication flow; only set isEmailVerified.value = false when you receive
an explicit sessionError indicating a failure, and set isEmailVerified from
sessionResult.session.user.email_confirmed_at when a session exists. Ensure the
same change is applied to the other identical checks referenced (the blocks at
the other occurrences) so the standalone delete flow isn't blocked on mount.
src/pages/settings/account/index.vue (1)

232-241: ⚠️ Potential issue | 🟠 Major

Close the confirmation modal before redirecting.

The danger button is preventClose: true on Line 234 and only calls closeDialog() when performAccountDeletion() returns true. In both email_not_verified paths you navigate away and return false, so the dialog is never dismissed; that can leave /resend_email under stale modal state and keep onDialogDismiss() unresolved.

🛠️ Suggested fix
+async function redirectToEmailVerificationFromDialog() {
+  dialogStore.closeDialog({ text: t('button-cancel'), role: 'cancel' })
+  await redirectToEmailVerification()
+}
+
 async function performAccountDeletion(password: string) {
   if (!main.auth || main.auth?.email == null)
     return false
   const supabaseClient = useSupabase()
 
   if (!isEmailVerified.value) {
-    await redirectToEmailVerification()
+    await redirectToEmailVerificationFromDialog()
     return false
   }
@@
     if (deleteError.message?.includes('email_not_verified')) {
-      await redirectToEmailVerification()
+      await redirectToEmailVerificationFromDialog()
       return false
     }

Also applies to: 258-261, 333-336

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/settings/account/index.vue` around lines 232 - 241, The
confirmation dialog isn't closed on the error/navigation branches, leaving a
stale modal; update the danger-button handlers (the async handler passed to the
dialog where performAccountDeletion(deleteAccountPassword.value) is called) to
always call dialogStore.closeDialog(...) before navigating or returning false
(i.e., close the dialog immediately on the email_not_verified branches and any
early-return/error branches), and apply the same change to the other similar
handlers referenced (the handlers around the blocks at the other occurrences).
Use the same dialog identifier used elsewhere (dialogStore.closeDialog({ text:
t('i-am-sure'), role: 'danger' }) or the appropriate dialog text/role in those
spots) so onDialogDismiss() resolves before navigation or returning.
tests/delete-user-reauth.test.ts (1)

172-247: 🛠️ Refactor suggestion | 🟠 Major

Add the browser regression for the new resend-email redirects.

These tests cover the RPC/auth boundary, but this PR also changes /delete_account and /settings/account. We still need a Playwright case proving an unverified user is sent to /resend_email from both entry points.

As per coding guidelines, "Always cover database changes with Postgres-level tests and complement them with end-to-end tests for affected user flows".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/delete-user-reauth.test.ts` around lines 172 - 247, Add a Playwright
E2E test that proves an unverified user is redirected to /resend_email when they
try to access both /delete_account and /settings/account: create or reuse an
unverified test user (matching the unverified user used in
delete-user-reauth.test.ts), sign in via the browser session, navigate to
/delete_account and assert the page URL or navigation landed on /resend_email
(and repeat for /settings/account); ensure assertions also verify any expected
query param (e.g., original path) so the redirect is the new resend-email flow.
🧹 Nitpick comments (1)
tests/delete-user-reauth.test.ts (1)

19-28: Reuse the shared Supabase test fetch policy for the anon client.

getSupabaseClient() already wraps Supabase calls with retry-on-503 in tests/test-utils.ts:538-584. This client skips that wrapper, so the new SDK-session regression can fail on transient test infra noise instead of the email-verification behavior you actually want to assert.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/delete-user-reauth.test.ts` around lines 19 - 28, The anon Supabase
client in createAnonSupabaseClient bypasses the shared retry-on-503 test fetch
policy; change it to reuse the shared test wrapper from tests/test-utils.ts
(getSupabaseClient) instead of directly calling createClient. Update
createAnonSupabaseClient to call or delegate to getSupabaseClient (or to
construct the client using the same fetch/retry wrapper exported by
tests/test-utils.ts) while preserving the anon URL/key and auth.persistSession:
false so the anon client gets the retry-on-503 behavior used by other tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/pages/delete_account.vue`:
- Around line 44-53: The mount-time gating wrongly treats “no session” as an
unverified user; update checkEmailVerification (and the other similar blocks
around the other occurrences) to distinguish between a session fetch error and
an absent session: call supabase.auth.getSession(), and if there's no
sessionResult?.session, do not mark isEmailVerified.value = false — instead set
a reauth-pending flag (e.g., isReauthenticated.value = false) or simply return
so the UI remains in the reauthentication flow; only set isEmailVerified.value =
false when you receive an explicit sessionError indicating a failure, and set
isEmailVerified from sessionResult.session.user.email_confirmed_at when a
session exists. Ensure the same change is applied to the other identical checks
referenced (the blocks at the other occurrences) so the standalone delete flow
isn't blocked on mount.

In `@src/pages/settings/account/index.vue`:
- Around line 232-241: The confirmation dialog isn't closed on the
error/navigation branches, leaving a stale modal; update the danger-button
handlers (the async handler passed to the dialog where
performAccountDeletion(deleteAccountPassword.value) is called) to always call
dialogStore.closeDialog(...) before navigating or returning false (i.e., close
the dialog immediately on the email_not_verified branches and any
early-return/error branches), and apply the same change to the other similar
handlers referenced (the handlers around the blocks at the other occurrences).
Use the same dialog identifier used elsewhere (dialogStore.closeDialog({ text:
t('i-am-sure'), role: 'danger' }) or the appropriate dialog text/role in those
spots) so onDialogDismiss() resolves before navigation or returning.

In `@tests/delete-user-reauth.test.ts`:
- Around line 172-247: Add a Playwright E2E test that proves an unverified user
is redirected to /resend_email when they try to access both /delete_account and
/settings/account: create or reuse an unverified test user (matching the
unverified user used in delete-user-reauth.test.ts), sign in via the browser
session, navigate to /delete_account and assert the page URL or navigation
landed on /resend_email (and repeat for /settings/account); ensure assertions
also verify any expected query param (e.g., original path) so the redirect is
the new resend-email flow.

---

Nitpick comments:
In `@tests/delete-user-reauth.test.ts`:
- Around line 19-28: The anon Supabase client in createAnonSupabaseClient
bypasses the shared retry-on-503 test fetch policy; change it to reuse the
shared test wrapper from tests/test-utils.ts (getSupabaseClient) instead of
directly calling createClient. Update createAnonSupabaseClient to call or
delegate to getSupabaseClient (or to construct the client using the same
fetch/retry wrapper exported by tests/test-utils.ts) while preserving the anon
URL/key and auth.persistSession: false so the anon client gets the retry-on-503
behavior used by other tests.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 633bc88b-e618-4635-9978-a91855cb0157

📥 Commits

Reviewing files that changed from the base of the PR and between f2ae269 and 8e92cc9.

📒 Files selected for processing (3)
  • src/pages/delete_account.vue
  • src/pages/settings/account/index.vue
  • tests/delete-user-reauth.test.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/delete-user-reauth.test.ts (1)

184-259: ⚠️ Potential issue | 🟠 Major

Add E2E coverage for the two user-facing deletion entry points.

These tests validate RPC/DB behavior, but the PR also changes customer-facing redirect behavior. Please add Playwright coverage to assert unverified users are redirected to /resend_email from both account-deletion entry points.

I can draft a minimal Playwright spec covering both entry points if you want.
As per coding guidelines: "tests/**/*.test.ts: Always cover database changes with Postgres-level tests and complement them with end-to-end tests for affected user flows".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/delete-user-reauth.test.ts` around lines 184 - 259, Add Playwright E2E
tests that assert unverified users are redirected to /resend_email for both
deletion entry points mentioned in the PR; locate the related test utilities and
constants used in tests/delete-user-reauth.test.ts (e.g., deleteUserAs,
createAnonSupabaseClient, USER_EMAIL_DELETE_USER_UNVERIFIED,
userIdDeleteUserUnverified) and implement a new Playwright spec that (1) visits
the user-facing deletion endpoint (explicit UI route) and confirms the browser
is redirected to /resend_email for the unverified user, and (2) repeats the
check for the alternate deletion entry point (the other customer-facing route)
using the same unverified account, asserting the redirect and any expected UI
text. Ensure the new spec is placed under tests/e2e or tests/playwright per repo
conventions and uses the existing test constants and helpers to sign in or
simulate the unverified user session.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/delete-user-reauth.test.ts`:
- Around line 248-251: Replace the fragile string-match against the
human-readable error message with an assertion on the stable server error code:
change the expectation that checks signInError?.message to assert the Auth
error's code (e.g. expect(signInError?.code).toBe('email_not_confirmed')) and/or
its status if needed; locate the assertions around the signInData/signInError
variables in delete-user-reauth.test.ts and update the test to use
signInError?.code (and optionally signInError?.status) instead of the message
text.

---

Outside diff comments:
In `@tests/delete-user-reauth.test.ts`:
- Around line 184-259: Add Playwright E2E tests that assert unverified users are
redirected to /resend_email for both deletion entry points mentioned in the PR;
locate the related test utilities and constants used in
tests/delete-user-reauth.test.ts (e.g., deleteUserAs, createAnonSupabaseClient,
USER_EMAIL_DELETE_USER_UNVERIFIED, userIdDeleteUserUnverified) and implement a
new Playwright spec that (1) visits the user-facing deletion endpoint (explicit
UI route) and confirms the browser is redirected to /resend_email for the
unverified user, and (2) repeats the check for the alternate deletion entry
point (the other customer-facing route) using the same unverified account,
asserting the redirect and any expected UI text. Ensure the new spec is placed
under tests/e2e or tests/playwright per repo conventions and uses the existing
test constants and helpers to sign in or simulate the unverified user session.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 958ca9df-59eb-4df7-8c65-f4bb1f92bf8e

📥 Commits

Reviewing files that changed from the base of the PR and between 8e92cc9 and d84829a.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (1)
  • tests/delete-user-reauth.test.ts

Comment on lines +248 to +251
expect(signInData.session).toBeNull()
expect(signInError).toBeTruthy()
expect(signInError?.message ?? '').toContain('Email not confirmed')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In @supabase/supabase-js v2.93.3, what stable fields are guaranteed on AuthApiError returned by auth.signInWithPassword for an unconfirmed email (for example, status and code)?

💡 Result:

In @supabase/supabase-js@2.93.3, auth.signInWithPassword() failures coming back from the Auth HTTP API are returned as an AuthApiError (from @supabase/auth-js). For an unconfirmed email, the stable/guaranteed fields you can rely on are:

  • name: "AuthApiError" (set by the class) [1]
  • message: string (standard Error.message, passed into the constructor) [1]
  • status: number (HTTP status code for the response; AuthApiError always has a numeric status) [1]
  • code: string (server error code; for this case it is email_not_confirmed) [2]

Supabase’s Auth docs also state that server-originated AuthApiErrors always have code and status present. [2]

Sources:
[1] AuthApiError implementation in auth-js (src/lib/errors.ts) showing name, status, code being set. (github.com)
[2] Supabase Auth “Error Codes” docs (notes on AuthApiError having code+status, and the email_not_confirmed code). (supabase.com)

Citations:


Use stable code field instead of message text for auth error assertion.

Line 250 pins the error message text ('Email not confirmed') which is not guaranteed stable across Supabase SDK versions. The AuthApiError exposes stable fields: status (HTTP code) and code (server error code: 'email_not_confirmed' for this case).

Suggested fix
     expect(signInData.session).toBeNull()
     expect(signInError).toBeTruthy()
-    expect(signInError?.message ?? '').toContain('Email not confirmed')
+    expect(signInError?.code).toBe('email_not_confirmed')
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
expect(signInData.session).toBeNull()
expect(signInError).toBeTruthy()
expect(signInError?.message ?? '').toContain('Email not confirmed')
expect(signInData.session).toBeNull()
expect(signInError).toBeTruthy()
expect(signInError?.code).toBe('email_not_confirmed')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/delete-user-reauth.test.ts` around lines 248 - 251, Replace the fragile
string-match against the human-readable error message with an assertion on the
stable server error code: change the expectation that checks
signInError?.message to assert the Auth error's code (e.g.
expect(signInError?.code).toBe('email_not_confirmed')) and/or its status if
needed; locate the assertions around the signInData/signInError variables in
delete-user-reauth.test.ts and update the test to use signInError?.code (and
optionally signInError?.status) instead of the message text.

@sonarqubecloud
Copy link
Copy Markdown

@riderx riderx merged commit 8f610a3 into main Mar 16, 2026
15 checks passed
@riderx riderx deleted the riderx/block-unverified-2fa branch March 16, 2026 21:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant