feat: add user account restore during 30-day grace period#1657
feat: add user account restore during 30-day grace period#1657ToriChanIntegration wants to merge 4 commits intoCap-go:mainfrom
Conversation
Handle 429 (rate limit) and 500 (server error) with exponential backoff to fix CI failures in POST /device operations test
- Each test now uses randomUUID() for device IDs - No shared state between tests - POST test now verifies device was created (uncommented) - DELETE test creates its own device to delete - All concurrent tests use unique identifiers - Removes flaky retry logic
- Add restore_user() SQL function to allow users to cancel deletion - Add restore_account.vue page for users to restore their account - Add /restore_account route to main.ts - Add translations for restore flow
📝 WalkthroughWalkthroughAdds an account restoration flow: new UI component and page for restore, Supabase RPC function to restore users and API keys, i18n string updates, route whitelist update, and isolated device test refactors. Changes
Sequence DiagramsequenceDiagram
actor User
participant RestorePage as Restore Page (Vue)
participant Captcha as Turnstile Captcha
participant Auth as Supabase Auth
participant RPC as restore_user RPC
participant DB as Database
User->>RestorePage: Fill email & password
RestorePage->>Captcha: Render widget
User->>Captcha: Complete challenge
Captcha->>RestorePage: Return token
User->>RestorePage: Submit form
RestorePage->>Auth: Sign-in (email/password, captcha)
Auth->>RestorePage: Return session / claims
RestorePage->>User: Show confirmation dialog
User->>RestorePage: Confirm restore
RestorePage->>RPC: Call restore_user()
RPC->>DB: Lookup to_delete_accounts and restore API keys
DB->>RPC: Return success
RPC->>RestorePage: Return success
RestorePage->>Auth: Sign out
RestorePage->>User: Show success & redirect
Estimated code review effort🎯 4 (Complex) | ⏱️ ~55 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (3)
tests/device.test.ts (1)
71-103: Consider usingit.concurrent()for isolated tests.Lines 73 and 105 use
it()instead ofit.concurrent(). Since each test generates its own uniquedeviceIdviarandomUUID(), they are fully isolated and safe to run in parallel. As per coding guidelines, "it.concurrent()instead ofit()to run tests in parallel within the same test file."♻️ Proposed change
- it('link device', async () => { + it.concurrent('link device', async () => {Same for
unlink deviceat Line 135.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/device.test.ts` around lines 71 - 103, Change the tests that are safe to run in parallel from it(...) to it.concurrent(...): locate the '[POST] /device operations' tests named "link device" and "unlink device" (they generate unique IDs with randomUUID()), and replace their it(...) declarations with it.concurrent(...) so the tests run concurrently while still remaining isolated; ensure any other tests in the same describe that share state are not converted.src/pages/restore_account.vue (2)
67-72:getClaims()call anduserIdextraction are unnecessary.The
userIdis extracted but never passed to the RPC call on Line 75. The SQL functionrestore_user()internally usesauth.uid()to identify the caller, making this block dead code.♻️ Remove the unnecessary claims check
- const { data: claimsData, error: claimsError } = await supabase.auth.getClaims() - const userId = claimsData?.claims?.sub - if (claimsError || !userId) { - isLoading.value = false - return setErrors('restore-account', [t('something-went-wrong-try-again-later')], {}) - } - // Restore user using RPC function const { error: restoreError } = await supabase.rpc('restore_user')🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/restore_account.vue` around lines 67 - 72, Remove the unnecessary supabase.auth.getClaims() call and its userId extraction/check: the SQL function restore_user() uses auth.uid() so getClaims() and the guard that sets isLoading.value = false and calls setErrors('restore-account', ...) are dead code; delete the block that calls getClaims(), removes userId usage, and let the subsequent supabase.rpc('restore_user') call handle errors as before (ensure existing error handling around the rpc call remains intact).
148-151:restoreAccount()is not awaited — cleanup on dismiss may race.The
restoreAccount()call on Line 150 returns a promise (itawaitsdialogStore.onDialogDismiss()internally), but the result is discarded. If the dialog handler errors or the user navigates away quickly, thefinallyblock cleanup may not behave as expected.♻️ Await the call
- restoreAccount() + await restoreAccount()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pages/restore_account.vue` around lines 148 - 151, The call to restoreAccount() is not awaited causing potential race with cleanup; update the dismiss handler to await the promise returned by restoreAccount() (the function that internally awaits dialogStore.onDialogDismiss()) and propagate or handle errors (e.g., try/catch around await restoreAccount()) so the finally cleanup runs only after restoreAccount completes; locate the invocation near captchaComponent.value?.reset() and replace the fire-and-forget call with an awaited call and proper error handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/pages/restore_account.vue`:
- Around line 195-199: The FormKit password input has a malformed :placeholder
attribute (in the FormKit component with id="passwordInput") —
:placeholder="t('password') is missing the closing double-quote; fix by adding
the missing closing quote so the attribute reads :placeholder="t('password')"
and verify the rest of the props (name="password", :label="t('password')",
:prefix-icon="iconPassword", :disabled="isLoading", validation="required:trim",
enterkeyhint="send", autocomplete="current-password") remain on the same element
and are properly quoted.
- Around line 134-142: The error handling uses setErrors('login-account', ...)
but the actual form id is "restore-account", so error messages never show;
update the setErrors call in the error branch (where setErrors is invoked) to
use 'restore-account' instead of 'login-account' so submitted errors target the
correct form (leave the captcha reset logic in captchaComponent.value?.reset()
and the toast calls unchanged).
- Around line 38-39: The handler currently declares an unused local variable
const supabaseClient = useSupabase() inside the handler; remove this unused
declaration so the handler uses the existing outer supabase instance (the outer
supabase variable used throughout the handler) or, if you intended to use the
local client, replace uses of the outer supabase with supabaseClient
consistently; target the handler function and the useSupabase() call to either
delete the local assignment or update usages accordingly.
In `@supabase/migrations/20260223000000_add_restore_user_function.sql`:
- Around line 66-68: Remove the unnecessary privilege granted to the anonymous
role by deleting the GRANT EXECUTE line for "anon" in the migration that deals
with the restore_user function; keep GRANT EXECUTE for "authenticated" and
"service_role" only so restore_user (the SECURITY DEFINER function that checks
auth.uid()) cannot be invoked by unauthenticated callers.
- Around line 32-56: The FOR loop is treating apikey_record as if it were a
jsonb rather than the RECORD returned by jsonb_array_elements (its field is
value), and there are invalid casts: replace apikey_record->>'key' etc. with
apikey_record.value->>'key' and apikey_record.value->'field' where appropriate;
change the enum cast from
(apikey_record->>'mode')::"public"."apikeys"."mode"%TYPE to the actual enum type
(apikey_record.value->>'mode')::"public"."key_mode"; build arrays properly using
ARRAY(SELECT jsonb_array_elements_text(apikey_record.value->'limited_to_apps'))
for text[] and ARRAY(SELECT
jsonb_array_elements_text(apikey_record.value->'limited_to_orgs')::uuid) for
uuid[] to match the apikeys.limited_to_orgs column; and keep the expires_at cast
as (apikey_record.value->>'expires_at')::timestamptz while inserting into
"public"."apikeys" with user_id_fn and the other fields.
---
Nitpick comments:
In `@src/pages/restore_account.vue`:
- Around line 67-72: Remove the unnecessary supabase.auth.getClaims() call and
its userId extraction/check: the SQL function restore_user() uses auth.uid() so
getClaims() and the guard that sets isLoading.value = false and calls
setErrors('restore-account', ...) are dead code; delete the block that calls
getClaims(), removes userId usage, and let the subsequent
supabase.rpc('restore_user') call handle errors as before (ensure existing error
handling around the rpc call remains intact).
- Around line 148-151: The call to restoreAccount() is not awaited causing
potential race with cleanup; update the dismiss handler to await the promise
returned by restoreAccount() (the function that internally awaits
dialogStore.onDialogDismiss()) and propagate or handle errors (e.g., try/catch
around await restoreAccount()) so the finally cleanup runs only after
restoreAccount completes; locate the invocation near
captchaComponent.value?.reset() and replace the fire-and-forget call with an
awaited call and proper error handling.
In `@tests/device.test.ts`:
- Around line 71-103: Change the tests that are safe to run in parallel from
it(...) to it.concurrent(...): locate the '[POST] /device operations' tests
named "link device" and "unlink device" (they generate unique IDs with
randomUUID()), and replace their it(...) declarations with it.concurrent(...) so
the tests run concurrently while still remaining isolated; ensure any other
tests in the same describe that share state are not converted.
src/pages/restore_account.vue
Outdated
| if (error) { | ||
| console.error('error', error) | ||
| setErrors('login-account', [error.message], {}) | ||
| if (error.message.includes('captcha')) { | ||
| captchaComponent.value?.reset() | ||
| toast.error(t('captcha-fail')) | ||
| return | ||
| } | ||
| toast.error(t('invalid-auth')) |
There was a problem hiding this comment.
Wrong form ID in setErrors — errors won't display.
Line 136 calls setErrors('login-account', ...) but the form is id="restore-account" (Line 178). The error messages will target a non-existent form and silently disappear.
🐛 Fix the form ID
- setErrors('login-account', [error.message], {})
+ setErrors('restore-account', [error.message], {})📝 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.
| if (error) { | |
| console.error('error', error) | |
| setErrors('login-account', [error.message], {}) | |
| if (error.message.includes('captcha')) { | |
| captchaComponent.value?.reset() | |
| toast.error(t('captcha-fail')) | |
| return | |
| } | |
| toast.error(t('invalid-auth')) | |
| if (error) { | |
| console.error('error', error) | |
| setErrors('restore-account', [error.message], {}) | |
| if (error.message.includes('captcha')) { | |
| captchaComponent.value?.reset() | |
| toast.error(t('captcha-fail')) | |
| return | |
| } | |
| toast.error(t('invalid-auth')) |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/pages/restore_account.vue` around lines 134 - 142, The error handling
uses setErrors('login-account', ...) but the actual form id is
"restore-account", so error messages never show; update the setErrors call in
the error branch (where setErrors is invoked) to use 'restore-account' instead
of 'login-account' so submitted errors target the correct form (leave the
captcha reset logic in captchaComponent.value?.reset() and the toast calls
unchanged).
src/pages/restore_account.vue
Outdated
| <FormKit | ||
| id="passwordInput" type="password" :placeholder="t('password') | ||
| name="password" :label="t('password')" :prefix-icon="iconPassword" :disabled="isLoading" | ||
| validation="required:trim" enterkeyhint="send" autocomplete="current-password" | ||
| /> |
There was a problem hiding this comment.
Template parse error: missing closing quote on :placeholder.
Line 196 has :placeholder="t('password') without a closing ". This causes the ESLint parsing errors reported by static analysis and will break the component at build/render time.
🐛 Fix the missing closing quote
<FormKit
- id="passwordInput" type="password" :placeholder="t('password')
- name="password" :label="t('password')" :prefix-icon="iconPassword" :disabled="isLoading"
+ id="passwordInput" type="password" :placeholder="t('password')"
+ name="password" :label="t('password')" :prefix-icon="iconPassword" :disabled="isLoading"
validation="required:trim" enterkeyhint="send" autocomplete="current-password"
/>📝 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.
| <FormKit | |
| id="passwordInput" type="password" :placeholder="t('password') | |
| name="password" :label="t('password')" :prefix-icon="iconPassword" :disabled="isLoading" | |
| validation="required:trim" enterkeyhint="send" autocomplete="current-password" | |
| /> | |
| <FormKit | |
| id="passwordInput" type="password" :placeholder="t('password')" | |
| name="password" :label="t('password')" :prefix-icon="iconPassword" :disabled="isLoading" | |
| validation="required:trim" enterkeyhint="send" autocomplete="current-password" | |
| /> |
🧰 Tools
🪛 ESLint
[error] 197-197: Parsing error: ',' expected.
(vue/no-parsing-error)
[error] 197-197: Parsing error: missing-whitespace-between-attributes.
(vue/no-parsing-error)
[error] 197-197: Parsing error: unexpected-character-in-attribute-name.
(vue/no-parsing-error)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/pages/restore_account.vue` around lines 195 - 199, The FormKit password
input has a malformed :placeholder attribute (in the FormKit component with
id="passwordInput") — :placeholder="t('password') is missing the closing
double-quote; fix by adding the missing closing quote so the attribute reads
:placeholder="t('password')" and verify the rest of the props (name="password",
:label="t('password')", :prefix-icon="iconPassword", :disabled="isLoading",
validation="required:trim", enterkeyhint="send",
autocomplete="current-password") remain on the same element and are properly
quoted.
| FOR apikey_record IN SELECT * FROM jsonb_array_elements(apikeys_json) | ||
| LOOP | ||
| -- Check if this API key already exists (by key value) | ||
| IF NOT EXISTS ( | ||
| SELECT 1 FROM "public"."apikeys" WHERE "key" = apikey_record->>'key' | ||
| ) THEN | ||
| INSERT INTO "public"."apikeys" ( | ||
| "user_id", | ||
| "key", | ||
| "mode", | ||
| "name", | ||
| "limited_to_orgs", | ||
| "limited_to_apps", | ||
| "expires_at" | ||
| ) VALUES ( | ||
| user_id_fn, | ||
| apikey_record->>'key', | ||
| (apikey_record->>'mode')::"public"."apikeys"."mode"%TYPE, | ||
| apikey_record->>'name', | ||
| COALESCE((apikey_record->'limited_to_orgs')::text[], ARRAY[]::text[]), | ||
| COALESCE((apikey_record->'limited_to_apps')::text[], ARRAY[]::text[]), | ||
| (apikey_record->>'expires_at')::timestamptz | ||
| ); | ||
| END IF; | ||
| END LOOP; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# First, examine the migration file to understand context
echo "=== Examining the migration file ==="
cat -n supabase/migrations/20260223000000_add_restore_user_function.sql | head -100Repository: Cap-go/capgo
Length of output: 2779
🏁 Script executed:
#!/bin/bash
# Search for apikeys table definition to understand the mode column type
echo "=== Searching for apikeys table schema ==="
fd -e sql -x cat {} \; | grep -A 30 "CREATE TABLE.*apikeys" | head -50Repository: Cap-go/capgo
Length of output: 1850
🏁 Script executed:
#!/bin/bash
# Search for any enum definitions related to apikeys mode
echo "=== Searching for enum type definitions ==="
fd -e sql -x cat {} \; | grep -B2 -A5 "CREATE TYPE.*ENUM"Repository: Cap-go/capgo
Length of output: 4268
🏁 Script executed:
#!/bin/bash
# Check if there are any existing functions that use jsonb_array_elements with RECORD patterns
echo "=== Searching for similar jsonb_array_elements patterns in the codebase ==="
rg -A 10 "jsonb_array_elements" --type=sql | head -100Repository: Cap-go/capgo
Length of output: 7344
🏁 Script executed:
#!/bin/bash
# Verify PL/pgSQL RECORD behavior with jsonb_array_elements in a quick documentation check
# Check if there are any tests or real usage patterns in the migrations that might clarify the behavior
echo "=== Checking for PL/pgSQL loops with jsonb_array_elements ==="
rg -A 5 "FOR.*IN SELECT.*FROM jsonb_array_elements" --type=sqlRepository: Cap-go/capgo
Length of output: 763
🌐 Web query:
PostgreSQL PL/pgSQL FOR loop jsonb_array_elements RECORD field access syntax
💡 Result:
In PL/pgSQL, a FOR ... IN SELECT loop assigns each row of the query result to the loop target. So with jsonb_array_elements(), you usually want to name the returned column and then use dot notation on the RECORD.
Pattern 1 (loop variable is RECORD)
jsonb_array_elements(jsonb) returns a set with a single output column named value (type jsonb). (postgresql.org)
DO $$
DECLARE
r record;
j jsonb := '[{"id":1},{"id":2}]';
BEGIN
FOR r IN
SELECT value
FROM jsonb_array_elements(j) AS t(value)
LOOP
RAISE NOTICE 'id=%', (r.value ->> 'id'); -- record-field access: r.value
END LOOP;
END $$;Record field access is record_variable.field_name (dot notation). (pgtutorial.com)
Pattern 2 (loop variable is a scalar jsonb, no RECORD field)
If you don’t need a record, select the element directly into a jsonb loop variable:
DO $$
DECLARE
elem jsonb;
j jsonb := '[{"id":1},{"id":2}]';
BEGIN
FOR elem IN
SELECT jsonb_array_elements(j)
LOOP
RAISE NOTICE 'id=%', (elem ->> 'id'); -- elem is jsonb, no ".value"
END LOOP;
END $$;Most “record field access syntax” issues here come from forgetting that the RECORD doesn’t contain the JSON object itself—it contains a row with a column (commonly value) that holds the JSON. (postgresql.org)
Citations:
- 1: https://www.postgresql.org/docs/current/functions-json.html?utm_source=openai
- 2: https://www.pgtutorial.com/plpgsql/plpgsql-record/?utm_source=openai
- 3: https://www.postgresql.org/docs/current/functions-json.html?utm_source=openai
Critical: API key restoration loop has multiple syntax and type errors that will cause runtime failures.
When using FOR apikey_record IN SELECT * FROM jsonb_array_elements(apikeys_json), the loop variable is a RECORD with a single column named value. You must use apikey_record.value->>'key' instead of apikey_record->>'key' to access nested JSON fields (dot notation is required for RECORD field access).
Additionally:
- Line 49:
%TYPEis only valid inDECLAREblocks for variable declarations — it cannot be used in cast expressions. Use the actual enum type:"public"."key_mode". - Lines 51–52: Direct cast
(jsonb_value)::text[]does not work in PostgreSQL. UseARRAY(SELECT jsonb_array_elements_text(...))instead. - Line 51:
limited_to_orgsshould be cast touuid[], nottext[](per table schema).
🐛 Proposed fix for the API key restoration loop
FOR apikey_record IN SELECT * FROM jsonb_array_elements(apikeys_json)
LOOP
-- Check if this API key already exists (by key value)
IF NOT EXISTS (
- SELECT 1 FROM "public"."apikeys" WHERE "key" = apikey_record->>'key'
+ SELECT 1 FROM "public"."apikeys" WHERE "key" = apikey_record.value->>'key'
) THEN
INSERT INTO "public"."apikeys" (
"user_id",
"key",
"mode",
"name",
"limited_to_orgs",
"limited_to_apps",
"expires_at"
) VALUES (
user_id_fn,
- apikey_record->>'key',
- (apikey_record->>'mode')::"public"."apikeys"."mode"%TYPE,
- apikey_record->>'name',
- COALESCE((apikey_record->'limited_to_orgs')::text[], ARRAY[]::text[]),
- COALESCE((apikey_record->'limited_to_apps')::text[], ARRAY[]::text[]),
- (apikey_record->>'expires_at')::timestamptz
+ apikey_record.value->>'key',
+ (apikey_record.value->>'mode')::"public"."key_mode",
+ apikey_record.value->>'name',
+ COALESCE(ARRAY(SELECT jsonb_array_elements_text(apikey_record.value->'limited_to_orgs'))::uuid[], ARRAY[]::uuid[]),
+ COALESCE(ARRAY(SELECT jsonb_array_elements_text(apikey_record.value->'limited_to_apps')), ARRAY[]::character varying[]),
+ (apikey_record.value->>'expires_at')::timestamptz
);
END IF;
END LOOP;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@supabase/migrations/20260223000000_add_restore_user_function.sql` around
lines 32 - 56, The FOR loop is treating apikey_record as if it were a jsonb
rather than the RECORD returned by jsonb_array_elements (its field is value),
and there are invalid casts: replace apikey_record->>'key' etc. with
apikey_record.value->>'key' and apikey_record.value->'field' where appropriate;
change the enum cast from
(apikey_record->>'mode')::"public"."apikeys"."mode"%TYPE to the actual enum type
(apikey_record.value->>'mode')::"public"."key_mode"; build arrays properly using
ARRAY(SELECT jsonb_array_elements_text(apikey_record.value->'limited_to_apps'))
for text[] and ARRAY(SELECT
jsonb_array_elements_text(apikey_record.value->'limited_to_orgs')::uuid) for
uuid[] to match the apikeys.limited_to_orgs column; and keep the expires_at cast
as (apikey_record.value->>'expires_at')::timestamptz while inserting into
"public"."apikeys" with user_id_fn and the other fields.
| GRANT EXECUTE ON FUNCTION "public"."restore_user"() TO "authenticated"; | ||
| GRANT EXECUTE ON FUNCTION "public"."restore_user"() TO "anon"; | ||
| GRANT EXECUTE ON FUNCTION "public"."restore_user"() TO "service_role"; |
There was a problem hiding this comment.
Granting EXECUTE to anon is unnecessary and widens the attack surface.
The function checks auth.uid() on Line 14 and raises an exception if null, so anonymous callers can never succeed. However, granting to anon still allows unauthenticated users to invoke a SECURITY DEFINER function, which is an unnecessary privilege escalation surface.
🔒 Proposed fix: remove the anon grant
GRANT EXECUTE ON FUNCTION "public"."restore_user"() TO "authenticated";
-GRANT EXECUTE ON FUNCTION "public"."restore_user"() TO "anon";
GRANT EXECUTE ON FUNCTION "public"."restore_user"() TO "service_role";📝 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.
| GRANT EXECUTE ON FUNCTION "public"."restore_user"() TO "authenticated"; | |
| GRANT EXECUTE ON FUNCTION "public"."restore_user"() TO "anon"; | |
| GRANT EXECUTE ON FUNCTION "public"."restore_user"() TO "service_role"; | |
| GRANT EXECUTE ON FUNCTION "public"."restore_user"() TO "authenticated"; | |
| GRANT EXECUTE ON FUNCTION "public"."restore_user"() TO "service_role"; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@supabase/migrations/20260223000000_add_restore_user_function.sql` around
lines 66 - 68, Remove the unnecessary privilege granted to the anonymous role by
deleting the GRANT EXECUTE line for "anon" in the migration that deals with the
restore_user function; keep GRANT EXECUTE for "authenticated" and "service_role"
only so restore_user (the SECURITY DEFINER function that checks auth.uid())
cannot be invoked by unauthenticated callers.
- Create AccountAction.vue component for delete/restore account forms - Both pages now just wrap the component with mode prop - Reduces code from 552 lines to 156 lines - Fixes SonarQube 39.7% duplication issue
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (3)
src/components/AccountAction.vue (3)
46-46: Redundant Supabase client instantiation insidehandleAction.Line 46 creates
supabaseClientviauseSupabase(), butsupabase(line 19) is already available in the setup scope and is used everywhere else in the function.supabaseClientis only used on line 76 for the.from('users')query. Use the existingsupabasereference instead.♻️ Proposed fix
async function handleAction() { - const supabaseClient = useSupabase() isLoading.value = true ... - const { data: user } = await supabaseClient.from('users').select().eq('id', userId).single() + const { data: user } = await supabase.from('users').select().eq('id', userId).single()Also applies to: 76-76
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/AccountAction.vue` at line 46, The code unnecessarily calls useSupabase() to create supabaseClient inside handleAction; remove that redundant instantiation and replace its single usage (the .from('users') query) with the existing supabase variable declared in setup (supabase) so handleAction uses supabase.from('users') instead of supabaseClient.from('users'); ensure you delete the supabaseClient declaration (useSupabase()) and update any references within handleAction accordingly.
76-77: Unnecessary user fetch in delete path.Line 76 fetches the user record only to check it exists (line 77), but the data is never used. The subsequent
delete_userRPC (line 78) would fail if the user doesn't exist, making this pre-check redundant. Removing it eliminates a round-trip.♻️ Proposed simplification
if (props.mode === 'delete') { - const { data: user } = await supabaseClient.from('users').select().eq('id', userId).single() - if (!user) { isLoading.value = false; return setErrors(formId.value, [t('something-went-wrong-try-again-later')], {}) } const { error: deleteError } = await supabase.rpc('delete_user')🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/AccountAction.vue` around lines 76 - 77, Remove the unnecessary pre-fetch of the user record: eliminate the supabaseClient.from('users').select() call and the subsequent existence check of the user variable, and instead call the delete_user RPC directly (keep the isLoading management and error handling around the RPC). Locate the block referencing supabaseClient.from('users').select() and the local user variable, remove those lines, and ensure the RPC call to delete_user still handles failures by setting isLoading.value = false and calling setErrors(formId.value, [...], {}) when the RPC returns an error.
126-126: Consider using DaisyUId-btninstead of a raw<button>.Per coding guidelines, interactive elements should use DaisyUI components. This is likely inherited from the original page code, but since this is a new shared component, it's a good opportunity to align.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/AccountAction.vue` at line 126, Replace the raw <button> element used in the AccountAction component's template with the DaisyUI button variant: keep the same attributes (v-if="!isLoading", type="submit") and preserve binding to buttonClass and buttonText but change the element's classes to use the DaisyUI component class (use the project's DaisyUI variant such as "d-btn" or "btn" plus any modifier like "btn-primary") so the button follows the design system; ensure you only swap the CSS class portion and keep the dynamic bindings and behavior (isLoading, buttonClass, buttonText) intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/components/AccountAction.vue`:
- Around line 35-43: Computed properties like successMessage, dialogTitle, and
dialogButtonText already return translated strings (they call t(...) inside
their computed), but call sites wrap those values in t(...) again; update usages
to pass the computed values directly (e.g., replace t(successMessage.value) with
successMessage.value, and replace t(dialogTitle.value) and
t(dialogButtonText.value) with dialogTitle.value and dialogButtonText.value
respectively, including in the submit function) so you don't attempt to
re-translate already-translated text.
- Line 108: The heading in AccountAction.vue uses an invalid <p> inside <h1>
(the line rendering {{ pageTitle }} and the "Capgo" brand), so replace the
block-level <p class="inline font-prompt">Capgo</p> with an inline element
(e.g., <span class="inline font-prompt">Capgo</span>) and ensure
spacing/punctuation around the brand remains correct in the <h1> (check the
template that contains pageTitle and the brand span).
- Around line 45-100: Multiple statements are packed on single lines causing
ESLint style errors in handleAction, submit and onMounted; split condensed
statements (e.g., chained if returns and inline finally/ catch blocks) into
separate lines and proper block formatting for readability and to satisfy
style/brace and antfu/if-newline rules (focus around the single-line groups in
handleAction and submit where setErrors/return/isLoading assignments and captcha
resets are combined), reorder imports so computed appears before ref, then run
bun lint:fix to auto-fix remaining issues and re-run lint to verify no remaining
errors; key symbols to check: handleAction, submit, onMounted, formId,
pendingEmail, pendingPassword, confirmCaptchaToken, confirmCaptchaComponent,
captchaComponent, turnstileToken, dialogStore.
- Line 142: Remove the inline route block from the AccountAction.vue component
(the `<route lang="yaml">meta: layout: naked</route>` entry) because
unplugin-vue-router only processes route blocks in src/pages; then add an
equivalent route block to both consuming pages (src/pages/delete_account.vue and
src/pages/restore_account.vue) so each page includes the meta: layout: naked
route metadata for the pages that use the AccountAction component.
---
Nitpick comments:
In `@src/components/AccountAction.vue`:
- Line 46: The code unnecessarily calls useSupabase() to create supabaseClient
inside handleAction; remove that redundant instantiation and replace its single
usage (the .from('users') query) with the existing supabase variable declared in
setup (supabase) so handleAction uses supabase.from('users') instead of
supabaseClient.from('users'); ensure you delete the supabaseClient declaration
(useSupabase()) and update any references within handleAction accordingly.
- Around line 76-77: Remove the unnecessary pre-fetch of the user record:
eliminate the supabaseClient.from('users').select() call and the subsequent
existence check of the user variable, and instead call the delete_user RPC
directly (keep the isLoading management and error handling around the RPC).
Locate the block referencing supabaseClient.from('users').select() and the local
user variable, remove those lines, and ensure the RPC call to delete_user still
handles failures by setting isLoading.value = false and calling
setErrors(formId.value, [...], {}) when the RPC returns an error.
- Line 126: Replace the raw <button> element used in the AccountAction
component's template with the DaisyUI button variant: keep the same attributes
(v-if="!isLoading", type="submit") and preserve binding to buttonClass and
buttonText but change the element's classes to use the DaisyUI component class
(use the project's DaisyUI variant such as "d-btn" or "btn" plus any modifier
like "btn-primary") so the button follows the design system; ensure you only
swap the CSS class portion and keep the dynamic bindings and behavior
(isLoading, buttonClass, buttonText) intact.
| const pageTitle = computed(() => props.mode === 'delete' ? t('leaving') : t('welcome-back')) | ||
| const pageSubtitle = computed(() => props.mode === 'delete' ? t('delete-your-account') : t('restore-your-account')) | ||
| const buttonText = computed(() => props.mode === 'delete' ? t('delete-account-0') : t('restore-account')) | ||
| const buttonClass = computed(() => props.mode === 'delete' ? 'bg-muted-blue-700 hover:bg-blue-700 focus:bg-blue-700' : 'bg-green-500 hover:bg-green-600 focus:bg-green-600') | ||
| const dialogTitle = computed(() => props.mode === 'delete' ? t('are-u-sure') : t('are-you-sure-restore')) | ||
| const dialogButtonText = computed(() => props.mode === 'delete' ? t('button-remove') : t('button-restore')) | ||
| const dialogButtonRole = computed(() => props.mode === 'delete' ? 'danger' : 'primary') | ||
| const successMessage = computed(() => props.mode === 'delete' ? t('account-deleted-successfully') : t('account-restored-successfully')) | ||
| const formId = computed(() => props.mode === 'delete' ? 'delete-account' : 'restore-account') |
There was a problem hiding this comment.
Bug: Double t() translation on computed strings.
The computed properties on lines 35–43 already return translated strings (e.g., t('are-u-sure')). But the call sites wrap them in t() again:
- Line 85:
t(successMessage.value)→ translates an already-translated string. - Line 97:
t(dialogTitle.value)andt(dialogButtonText.value)→ same problem.
This will likely produce incorrect/missing text if vue-i18n doesn't find the translated string as a key, or it will silently return the already-translated value (depending on fallbackWarn / missingWarn settings).
🐛 Proposed fix — remove the outer `t()` at usage sites
- toast.success(t(successMessage.value))
+ toast.success(successMessage.value)And in the submit function (line 97), change:
- dialogStore.openDialog({ id: `${formId.value}-confirm`, title: t(dialogTitle.value), buttons: [{ text: t(dialogButtonText.value), role: dialogButtonRole.value, handler: handleAction }, ...] })
+ dialogStore.openDialog({ id: `${formId.value}-confirm`, title: dialogTitle.value, buttons: [{ text: dialogButtonText.value, role: dialogButtonRole.value, handler: handleAction }, ...] })Also applies to: 85-85, 97-97
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/components/AccountAction.vue` around lines 35 - 43, Computed properties
like successMessage, dialogTitle, and dialogButtonText already return translated
strings (they call t(...) inside their computed), but call sites wrap those
values in t(...) again; update usages to pass the computed values directly
(e.g., replace t(successMessage.value) with successMessage.value, and replace
t(dialogTitle.value) and t(dialogButtonText.value) with dialogTitle.value and
dialogButtonText.value respectively, including in the submit function) so you
don't attempt to re-translate already-translated text.
| async function handleAction() { | ||
| const supabaseClient = useSupabase() | ||
| isLoading.value = true | ||
| try { | ||
| if (!pendingEmail.value || !pendingPassword.value) { | ||
| isLoading.value = false | ||
| return setErrors(formId.value, [t('invalid-auth')], {}) | ||
| } | ||
| if (captchaKey.value && !confirmCaptchaToken.value) { | ||
| isLoading.value = false | ||
| return setErrors(formId.value, [t('captcha-required', 'Captcha verification is required')], {}) | ||
| } | ||
| const { error: reauthError } = await supabase.auth.signInWithPassword({ | ||
| email: pendingEmail.value, | ||
| password: pendingPassword.value, | ||
| options: captchaKey.value ? { captchaToken: confirmCaptchaToken.value } : undefined, | ||
| }) | ||
| if (reauthError) { | ||
| confirmCaptchaToken.value = '' | ||
| confirmCaptchaComponent.value?.reset() | ||
| isLoading.value = false | ||
| if (reauthError.message.includes('captcha')) toast.error(t('captcha-fail')) | ||
| return setErrors(formId.value, [t('invalid-auth')], {}) | ||
| } | ||
| const { data: claimsData, error: claimsError } = await supabase.auth.getClaims() | ||
| const userId = claimsData?.claims?.sub | ||
| if (claimsError || !userId) { | ||
| isLoading.value = false | ||
| return setErrors(formId.value, [t('something-went-wrong-try-again-later')], {}) | ||
| } | ||
| if (props.mode === 'delete') { | ||
| const { data: user } = await supabaseClient.from('users').select().eq('id', userId).single() | ||
| if (!user) { isLoading.value = false; return setErrors(formId.value, [t('something-went-wrong-try-again-later')], {}) } | ||
| const { error: deleteError } = await supabase.rpc('delete_user') | ||
| if (deleteError) { if (deleteError.message?.includes('reauth_required')) { isLoading.value = false; return setErrors(formId.value, [t('invalid-auth')], {}) }; isLoading.value = false; return setErrors(formId.value, [t('something-went-wrong-try-again-later')], {}) } | ||
| } else { | ||
| const { error: restoreError } = await supabase.rpc('restore_user') | ||
| if (restoreError) { isLoading.value = false; return setErrors(formId.value, [t('something-went-wrong-try-again-later')], {}) } | ||
| } | ||
| await supabase.auth.signOut() | ||
| toast.success(t(successMessage.value)) | ||
| router.replace('/login') | ||
| } catch { isLoading.value = false; return setErrors(formId.value, [t('something-went-wrong-try-again-later')], {}) } | ||
| finally { isLoading.value = false; pendingEmail.value = ''; pendingPassword.value = ''; confirmCaptchaToken.value = ''; confirmCaptchaComponent.value?.reset() } | ||
| } | ||
|
|
||
| async function submit(form: { email: string, password: string }) { | ||
| isLoading.value = true | ||
| if (captchaKey.value && !turnstileToken.value) { isLoading.value = false; setErrors(formId.value, [t('captcha-required', 'Captcha verification is required')], {}); return } | ||
| const { error } = await supabase.auth.signInWithPassword({ email: form.email, password: form.password, options: captchaKey.value ? { captchaToken: turnstileToken.value } : undefined }) | ||
| isLoading.value = false | ||
| if (error) { setErrors(formId.value, [error.message], {}); if (error.message.includes('captcha')) { captchaComponent.value?.reset(); toast.error(t('captcha-fail')); return }; toast.error(t('invalid-auth')) } | ||
| else { pendingEmail.value = form.email; pendingPassword.value = form.password; turnstileToken.value = ''; captchaComponent.value?.reset(); dialogStore.openDialog({ id: `${formId.value}-confirm`, title: t(dialogTitle.value), buttons: [{ text: t(dialogButtonText.value), role: dialogButtonRole.value, handler: handleAction }, { text: t('button-cancel'), role: 'cancel', handler: () => {} }] }); await dialogStore.onDialogDismiss() } | ||
| } | ||
|
|
||
| onMounted(() => { hideLoader() }) |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
ESLint errors must be resolved — linting is a commit gate.
Static analysis flags 15+ ESLint errors across handleAction, submit, and onMounted — primarily style/max-statements-per-line, style/brace-style, and antfu/if-newline violations. These are caused by cramming multiple statements onto single lines (e.g., lines 77, 79, 82, 87, 88, 93, 96, 97, 100).
Per coding guidelines: "Frontend ESLint must pass before commit; run bun lint:fix to auto-fix issues." Many of these will auto-fix, but the densely-packed lines (especially 79, 96–97) will likely need manual reformatting.
Also on line 4: import ordering (computed should come before ref).
♻️ Example: expand line 79 into readable multi-line form
- const { error: deleteError } = await supabase.rpc('delete_user')
- if (deleteError) { if (deleteError.message?.includes('reauth_required')) { isLoading.value = false; return setErrors(formId.value, [t('invalid-auth')], {}) }; isLoading.value = false; return setErrors(formId.value, [t('something-went-wrong-try-again-later')], {}) }
+ const { error: deleteError } = await supabase.rpc('delete_user')
+ if (deleteError) {
+ isLoading.value = false
+ if (deleteError.message?.includes('reauth_required'))
+ return setErrors(formId.value, [t('invalid-auth')], {})
+ return setErrors(formId.value, [t('something-went-wrong-try-again-later')], {})
+ }As per coding guidelines: "Frontend ESLint must pass before commit; run bun lint:fix to auto-fix issues in frontend files."
🧰 Tools
🪛 ESLint
[error] 66-66: Expect newline after if
(antfu/if-newline)
[error] 77-77: This line has 3 statements. Maximum allowed is 1.
(style/max-statements-per-line)
[error] 79-79: This line has 6 statements. Maximum allowed is 1.
(style/max-statements-per-line)
[error] 80-80: Closing curly brace appears on the same line as the subsequent block.
(style/brace-style)
[error] 82-82: This line has 3 statements. Maximum allowed is 1.
(style/max-statements-per-line)
[error] 87-87: Closing curly brace appears on the same line as the subsequent block.
(style/brace-style)
[error] 87-87: This line has 2 statements. Maximum allowed is 1.
(style/max-statements-per-line)
[error] 88-88: This line has 5 statements. Maximum allowed is 1.
(style/max-statements-per-line)
[error] 93-93: This line has 4 statements. Maximum allowed is 1.
(style/max-statements-per-line)
[error] 96-96: This line has 7 statements. Maximum allowed is 1.
(style/max-statements-per-line)
[error] 97-97: This line has 6 statements. Maximum allowed is 1.
(style/max-statements-per-line)
[error] 100-100: This line has 2 statements. Maximum allowed is 1.
(style/max-statements-per-line)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/components/AccountAction.vue` around lines 45 - 100, Multiple statements
are packed on single lines causing ESLint style errors in handleAction, submit
and onMounted; split condensed statements (e.g., chained if returns and inline
finally/ catch blocks) into separate lines and proper block formatting for
readability and to satisfy style/brace and antfu/if-newline rules (focus around
the single-line groups in handleAction and submit where
setErrors/return/isLoading assignments and captcha resets are combined), reorder
imports so computed appears before ref, then run bun lint:fix to auto-fix
remaining issues and re-run lint to verify no remaining errors; key symbols to
check: handleAction, submit, onMounted, formId, pendingEmail, pendingPassword,
confirmCaptchaToken, confirmCaptchaComponent, captchaComponent, turnstileToken,
dialogStore.
| <div class="px-4 my-auto mx-auto max-w-7xl sm:px-6 lg:px-8"> | ||
| <div class="mx-auto max-w-2xl text-center"> | ||
| <img src="/capgo.webp" alt="logo" class="mx-auto mb-6 w-1/6 rounded-sm invert dark:invert-0"> | ||
| <h1 class="text-3xl font-bold leading-tight text-black sm:text-4xl lg:text-5xl dark:text-white">{{ pageTitle }} <p class="inline font-prompt">Capgo</p> ?</h1> |
There was a problem hiding this comment.
Invalid HTML: <p> nested inside <h1>.
A <p> (block-level) element inside an <h1> is invalid per the HTML spec. Use <span> instead to keep the inline styling for the "Capgo" brand name.
Proposed fix
- <h1 class="text-3xl font-bold leading-tight text-black sm:text-4xl lg:text-5xl dark:text-white">{{ pageTitle }} <p class="inline font-prompt">Capgo</p> ?</h1>
+ <h1 class="text-3xl font-bold leading-tight text-black sm:text-4xl lg:text-5xl dark:text-white">{{ pageTitle }} <span class="font-prompt">Capgo</span> ?</h1>📝 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.
| <h1 class="text-3xl font-bold leading-tight text-black sm:text-4xl lg:text-5xl dark:text-white">{{ pageTitle }} <p class="inline font-prompt">Capgo</p> ?</h1> | |
| <h1 class="text-3xl font-bold leading-tight text-black sm:text-4xl lg:text-5xl dark:text-white">{{ pageTitle }} <span class="font-prompt">Capgo</span> ?</h1> |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/components/AccountAction.vue` at line 108, The heading in
AccountAction.vue uses an invalid <p> inside <h1> (the line rendering {{
pageTitle }} and the "Capgo" brand), so replace the block-level <p class="inline
font-prompt">Capgo</p> with an inline element (e.g., <span class="inline
font-prompt">Capgo</span>) and ensure spacing/punctuation around the brand
remains correct in the <h1> (check the template that contains pageTitle and the
brand span).
| </section> | ||
| </template> | ||
|
|
||
| <route lang="yaml">meta: layout: naked</route> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# First, check AccountAction.vue for the <route> block
echo "=== AccountAction.vue (checking for <route> block) ==="
if [ -f "src/components/AccountAction.vue" ]; then
wc -l src/components/AccountAction.vue
echo ""
tail -20 src/components/AccountAction.vue
else
echo "File not found"
fi
echo ""
echo "=== Searching for <route> blocks in AccountAction.vue ==="
rg -n "<route" src/components/AccountAction.vue || echo "No <route> found"Repository: Cap-go/capgo
Length of output: 2278
🏁 Script executed:
#!/bin/bash
# Check if delete_account.vue and restore_account.vue exist and their content
echo "=== delete_account.vue ==="
if [ -f "src/pages/delete_account.vue" ]; then
wc -l src/pages/delete_account.vue
echo "--- First 30 lines ---"
head -30 src/pages/delete_account.vue
echo "--- Last 10 lines ---"
tail -10 src/pages/delete_account.vue
else
echo "File not found"
fi
echo ""
echo "=== restore_account.vue ==="
if [ -f "src/pages/restore_account.vue" ]; then
wc -l src/pages/restore_account.vue
echo "--- First 30 lines ---"
head -30 src/pages/restore_account.vue
echo "--- Last 10 lines ---"
tail -10 src/pages/restore_account.vue
else
echo "File not found"
fiRepository: Cap-go/capgo
Length of output: 874
🏁 Script executed:
#!/bin/bash
# Check unplugin-vue-router configuration
echo "=== Looking for unplugin-vue-router config ==="
find . -maxdepth 3 -type f \( -name "vite.config.*" -o -name "vitest.config.*" -o -name "vue.config.*" -o -name "tsconfig.json" \) | head -10
echo ""
echo "=== Searching for unplugin-vue-router in config files ==="
rg -l "unplugin-vue-router\|@unplugin/vue-router" --type ts --type js | head -5
echo ""
echo "=== Checking vite.config files ==="
fd -e "vite.config.*" -x cat {}Repository: Cap-go/capgo
Length of output: 317
🏁 Script executed:
#!/bin/bash
# Search for route blocks across the codebase to understand usage
echo "=== All <route> blocks in the codebase ==="
rg -n "<route" --type vue | head -20Repository: Cap-go/capgo
Length of output: 143
🏁 Script executed:
#!/bin/bash
echo "=== vite.config.mts content ==="
cat vite.config.mtsRepository: Cap-go/capgo
Length of output: 4781
🏁 Script executed:
#!/bin/bash
echo "=== Searching for <route> blocks in src/pages/ ==="
find src/pages -name "*.vue" -exec grep -l "<route" {} \;
echo ""
echo "=== All <route> blocks in pages directory ==="
find src/pages -name "*.vue" -exec grep -n "<route" {} +Repository: Cap-go/capgo
Length of output: 6130
🏁 Script executed:
#!/bin/bash
echo "=== Checking package.json for unplugin-vue-router ==="
rg "unplugin-vue-router\|@unplugin/vue-router" package.json
echo ""
echo "=== Checking if there are any other <route> blocks in components ==="
find src/components -name "*.vue" -exec grep -n "<route" {} +Repository: Cap-go/capgo
Length of output: 1136
Move <route> block from component to consuming pages — unplugin-vue-router only processes routes in src/pages/.
The <route lang="yaml">meta: layout: naked</route> block in src/components/AccountAction.vue is silently ignored because unplugin-vue-router only processes files in src/pages/ by default. This configuration must be added to the pages that consume this component instead.
🔧 Fix — add route block to both page files
Remove line 142 from src/components/AccountAction.vue.
Then add this to both src/pages/delete_account.vue and src/pages/restore_account.vue:
<route lang="yaml">
meta:
layout: naked
</route>🧰 Tools
🪛 ESLint
[error] 142-142: A line break is required after ''.
(vue/block-tag-newline)
[error] 142-142: A line break is required before ''.
(vue/block-tag-newline)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/components/AccountAction.vue` at line 142, Remove the inline route block
from the AccountAction.vue component (the `<route lang="yaml">meta: layout:
naked</route>` entry) because unplugin-vue-router only processes route blocks in
src/pages; then add an equivalent route block to both consuming pages
(src/pages/delete_account.vue and src/pages/restore_account.vue) so each page
includes the meta: layout: naked route metadata for the pages that use the
AccountAction component.
|



Summary
Allows users to restore their account during the 30-day deletion grace period.
Changes
SQL Function (
supabase/migrations/20260223000000_add_restore_user_function.sql):restore_user()RPC functionFrontend (
src/pages/restore_account.vue):Routing (
src/main.ts):Translations (
messages/en.json):Test Plan
Manual Testing
Test account deletion:
Test account restoration:
Automated Testing
The existing delete-user-reauth.test.ts can be extended to test the restore flow by:
Usage
Users who requested deletion receive instructions about the 30-day grace period. During this time, they can visit /restore_account to cancel the deletion and restore their account.
Summary by CodeRabbit
New Features
Improvements