Better account delete#1191
Conversation
WalkthroughAdds a 30-day staged account-deletion workflow: DB migration, RPCs and types, cron cleanup and tests; client-side guard, account-disabled page with live countdown, org-aware deletion UX and Supabase helpers; many i18n key additions; table rendering refactor (VNode renderFunction); ESLint and dependency i18n swap; two global components declared. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant U as User
participant Guard as Router Guard
participant RPC as Supabase RPC
participant Page as /accountDisabled
U->>Guard: Navigate (authenticated)
Guard->>RPC: is_account_disabled(user_id)
RPC-->>Guard: true / false
alt account disabled
Guard-->>U: redirect /accountDisabled
U->>Page: mount
Page->>RPC: get_account_removal_date(user_id)
RPC-->>Page: removal_date
Page-->>U: render countdown & restore link
else active
Guard-->>U: continue navigation
end
sequenceDiagram
autonumber
participant User as Settings User
participant UI as Account.vue
participant RPC as Supabase RPC
participant Cron as Scheduler
User->>UI: Click "Delete account"
UI->>RPC: get_org_members(gid) / isPayingOrg(gid) / getCurrentPlanNameOrg(gid)
RPC-->>UI: members, paying flag, plan name
UI-->>User: show org & paid-subscription warnings
User->>UI: Confirm ("i-am-sure")
UI->>RPC: delete_user() (inserts to_delete_accounts with removal_date = now + 30d)
Note right of UI: account marked for deletion (30 days pending)
Cron->>RPC: delete_accounts_marked_for_deletion() (periodic)
RPC-->>Cron: permanently deletes ready accounts
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Pre-merge checks (1 warning, 2 inconclusive)❌ Failed checks (1 warning, 2 inconclusive)
Poem
✨ Finishing Touches
🧪 Generate unit tests
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: 26
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
supabase/functions/_backend/triggers/on_user_delete.ts (2)
63-68: Parallelize and harden cancelSubscription calls.If one Stripe call throws, the whole job aborts; also, calls run sequentially. Make them concurrent and resilient.
Apply:
- for (const org of orgs) { - // Cancel org subscriptions if they exist - if (org.customer_id) { - await cancelSubscription(c, org.customer_id) - } - } + await Promise.all( + orgs.map(async (org) => { + if (!org.customer_id) return + try { + await cancelSubscription(c, org.customer_id) + } + catch (err) { + cloudlog({ + requestId: c.get('requestId'), + level: 'error', + message: 'cancelSubscription failed', + org_id: org.id, + customer_id: org.customer_id, + error: String(err), + }) + } + }), + )
19-24: Handle Supabase errors explicitly (avoid silent success paths).Current code ignores
errorand proceeds. Fail fast with logs to avoid partial work and confusing “success.”Apply:
- const { data: userSuperAdminOrgs } = await supabaseAdmin(c) + const { data: userSuperAdminOrgs, error: userOrgsError } = await supabaseAdmin(c) .from('org_users') .select('org_id') .eq('user_id', record.id) .eq('user_right', 'super_admin') - if (!userSuperAdminOrgs?.length) { + if (userOrgsError) { + cloudlog({ requestId: c.get('requestId'), level: 'error', message: 'fetch user super admin orgs failed', error: userOrgsError.message }) + return c.json(BRES) + } + if (!userSuperAdminOrgs?.length) { return c.json(BRES) } // For each org where user is super admin, check if they are the only one const orgIds = userSuperAdminOrgs.map(org => org.org_id) - const { data: superAdminCounts } = await supabaseAdmin(c) + const { data: superAdminCounts, error: countsError } = await supabaseAdmin(c) .from('org_users') .select('org_id') .in('org_id', orgIds) .eq('user_right', 'super_admin') - if (!superAdminCounts) { + if (countsError) { + cloudlog({ requestId: c.get('requestId'), level: 'error', message: 'fetch super admin counts failed', error: countsError.message }) + return c.json(BRES) + } // Count super admins per org - const { data: orgs } = await supabaseAdmin(c) + const { data: orgs, error: orgsError } = await supabaseAdmin(c) .from('orgs') .select('id, customer_id') .in('id', singleSuperAdminOrgs) - if (orgs && orgs.length > 0) { + if (orgsError) { + cloudlog({ requestId: c.get('requestId'), level: 'error', message: 'fetch orgs failed', error: orgsError.message }) + return c.json(BRES) + } + if (orgs && orgs.length > 0) {Also applies to: 31-37, 55-60
messages/ko.json (1)
2-2: $schema must be a URL onlyThe $schema value includes explanatory Korean text, which will break tooling (inlang schema expects a plain URL).
Apply this diff:
-"$schema": "https://inlang.com/schema/inlang-message-format\n\n위의 웹사이트 주소는 언어에 관계없이 동일하게 유지됩니다.", +"$schema": "https://inlang.com/schema/inlang-message-format",
🧹 Nitpick comments (40)
src/components.d.ts (1)
39-39: LGTM: new StoragePage declarationConsistent naming and path. If this is a route view rather than a reusable component, consider housing it under a pages directory for organization; types here are unaffected either way.
messages/it.json (2)
22-23: Wording: differentiate “timer” vs “timer-in”.Add “tra” for the “-in” variant to read naturally in Italian.
- "account-deletion-timer-in": "Il tuo account cesserà di esistere", + "account-deletion-timer-in": "Il tuo account cesserà di esistere tra",
520-520: Minor phrasing improvement.Make it explicit they’re paid subscriptions.
- "paid-subscriptions-to-cancel": "Abbonamenti da cancellare", + "paid-subscriptions-to-cancel": "Abbonamenti a pagamento da annullare",messages/de.json (5)
21-23: Timer strings likely insufficient for “in …” usage.
account-deletion-timervsaccount-deletion-timer-indiffer only by “in”. If UI injects a relative time, switch to ICU with a placeholder, e.g.,Ihr Konto wird in {time} gelöscht. Please verify the consuming code.
155-155: German phrasing nit.“Abonnements abbrechen und fahren Sie fort” → more idiomatic: “Abonnements kündigen und fortfahren”.
-"cancel-subscriptions-and-continue": "Abonnements abbrechen und fahren Sie fort", +"cancel-subscriptions-and-continue": "Abonnements kündigen und fortfahren",
370-370: Style consistency nit.Capitalize start: “Ich bin mir sicher”.
-"i-am-sure": "ich bin mir sicher", +"i-am-sure": "Ich bin mir sicher",
516-521: Wording nit.“Abonnements zu stornieren” → “Zu kündigende Abonnements”.
-"paid-subscriptions-to-cancel": "Abonnements zu stornieren", +"paid-subscriptions-to-cancel": "Zu kündigende Abonnements",
730-734: Add rel on external links (policy).If any warning body renders anchor tags, ensure
rel="noopener noreferrer"ontarget="_blank"links globally.messages/tr.json (3)
21-22: Timer copy needs placeholder.Both keys read the same. Use ICU with
{time}(e.g., “Hesabınız {time} sonra silinecek”). Verify UI.
155-155: Minor Turkish phrasing nit.“... devam et” → “... devam edin” (formal) to match tone elsewhere.
-"cancel-subscriptions-and-continue": "Abonelikleri iptal edin ve devam et", +"cancel-subscriptions-and-continue": "Abonelikleri iptal edin ve devam edin",
370-370: Capitalization nit.“Eminim” should be capitalized.
-"i-am-sure": "eminim", +"i-am-sure": "Eminim",messages/ru.json (2)
21-22: Timer keys: add placeholder.Use
{time}(e.g., “Ваша учетная запись перестанет существовать через {time}”). Verify consumer.
370-370: Capitalization nit.Start with uppercase: “Я уверен”.
-"i-am-sure": "я уверен", +"i-am-sure": "Я уверен",messages/fr.json (1)
21-22: Timer copy: add{time}placeholder.Currently identical strings; prefer “Votre compte cessera d’exister dans {time}”. Verify usage.
messages/ja.json (1)
21-22: Timer keys: needs placeholder.Use
{time}: “あなたのアカウントは {time} 後に存在しなくなります”. Verify consumer.messages/pt-br.json (2)
370-370: Capitalization nit.“Estou certo” capitalized at start.
-"i-am-sure": "estou certo", +"i-am-sure": "Estou certo",
21-22: Timer copy: add{time}placeholder.Prefer “Sua conta deixará de existir em {time}”. Verify usage.
messages/id.json (1)
21-22: Timer copy: add{time}placeholder.Prefer “Akun Anda akan berhenti ada dalam {time}”. Verify consumer.
messages/vi.json (3)
516-516: Clarify meaning (“to be deleted”).-"organizations-to-be-deleted": "Các tổ chức bị xóa", +"organizations-to-be-deleted": "Các tổ chức sẽ bị xóa",
520-520: Improve phrasing for list header.-"paid-subscriptions-to-cancel": "Đăng ký bị hủy", +"paid-subscriptions-to-cancel": "Các đăng ký cần hủy",
730-734: Polish subscription warning wording.-"warning-paid-subscriptions": "Cảnh báo: Đăng ký hoạt động", -"warning-paid-subscriptions-message": "Các tổ chức sau đây có đăng ký trả tiền tích cực. \nCác đăng ký này sẽ bị hủy ngay lập tức:", +"warning-paid-subscriptions": "Cảnh báo: Đăng ký đang hoạt động", +"warning-paid-subscriptions-message": "Các tổ chức sau đây có đăng ký trả phí đang hoạt động.\nCác đăng ký này sẽ bị hủy ngay lập tức:",messages/zh-cn.json (3)
370-370: Use idiomatic confirmation.-"i-am-sure": "我相信", +"i-am-sure": "我确定",
731-734: Refine “active subscriptions” wording.-"warning-paid-subscriptions": "警告:主动订阅", -"warning-paid-subscriptions-message": "以下组织具有积极的付费订阅。\n这些订阅将立即取消:", +"warning-paid-subscriptions": "警告:正在生效的订阅", +"warning-paid-subscriptions-message": "以下组织有正在生效的付费订阅。\n这些订阅将立即取消:",
516-516: Smoother header phrasing.-"organizations-to-be-deleted": "要删除的组织", +"organizations-to-be-deleted": "待删除的组织",src/types/supabase.types.ts (1)
1233-1264: New to_delete_accounts table: add ops safeguards.Recommend a unique constraint on account_id and an index on removal_date to support scheduled cleanup. Confirm RLS policies restrict visibility to the owning user/admin jobs.
supabase/functions/_backend/triggers/on_user_delete.ts (1)
15-17: Implement the “timeout protection” mentioned in the comment.Add a small timeout wrapper for external calls (e.g., Stripe) so the function doesn’t hang.
Example helper:
function withTimeout<T>(p: Promise<T>, ms = 15000): Promise<T> { return Promise.race([ p, new Promise<never>((_, r) => setTimeout(() => r(new Error(`timeout ${ms}ms`)), ms)), ]) }Then wrap:
await withTimeout(cancelSubscription(c, org.customer_id)).src/modules/auth.ts (2)
71-87: DRY the “is_account_disabled” RPC check; consider a short TTL cache.Extract a helper (returns boolean and handles logging/redirect) and reuse in both branches; optionally cache the result for ~30–60s to avoid an RPC on every route change while still catching mid-session disables.
Example:
async function redirectIfDisabled(supabase: SupabaseClient, userId: string, next: NavigationGuardNext): Promise<boolean> { try { const { data: isDisabled, error } = await supabase.rpc('is_account_disabled', { user_id: userId }) if (error) console.error('is_account_disabled failed', error) if (isDisabled) { hideLoader(); next('/accountDisabled'); return true } } catch (e) { console.error('is_account_disabled error', e) } return false }Also applies to: 120-142
116-119: Nit: path comparison should include the leading slash.Current check likely never matches.
Apply:
- else if (from.path !== 'login' && !auth.user) { + else if (from.path !== '/login' && !auth.user) {src/pages/accountDisabled.vue (2)
51-55: Start the interval only when countdown is available.Avoid work when there’s an error or no date.
Apply:
- // Update current time every second - intervalId = setInterval(() => { - currentTime.value = new Date() - }, 1000) + // Update current time every second (only when we have a target date) + if (!error.value && deletionDate.value) { + intervalId = setInterval(() => { + currentTime.value = new Date() + }, 1000) + }
85-96: Localize the countdown units.Hardcoded English strings bypass i18n and pluralization.
Example formatter:
function fmt(n: number, key: string) { return t(key, { count: n }) } // use i18n plural rules // e.g., keys: day(s), hour(s), minute(s), second(s)Then build the string from localized segments.
messages/pl.json (1)
520-520: Clarify that subscriptions are paidAdd “Płatne” to avoid ambiguity.
Apply this diff:
-"paid-subscriptions-to-cancel": "Subskrypcje do anulowania", +"paid-subscriptions-to-cancel": "Płatne subskrypcje do anulowania",messages/ko.json (6)
155-155: CTA phrasing: make it naturalMinor polish for a button label.
Apply this diff:
-"cancel-subscriptions-and-continue": "구독을 취소하고 계속하십시오", +"cancel-subscriptions-and-continue": "구독을 취소하고 계속하기",
370-370: Make confirmation CTA natural KoreanCurrent text is stilted.
Apply this diff:
-"i-am-sure": "나는 확실하다", +"i-am-sure": "확실합니다",
516-516: Grammar fixSpacing.
Apply this diff:
-"organizations-to-be-deleted": "삭제 될 조직", +"organizations-to-be-deleted": "삭제될 조직",
520-520: Clarify “paid subscriptions” and naturalize“가입” is off here; use “유료 구독”.
Apply this diff:
-"paid-subscriptions-to-cancel": "취소 할 가입", +"paid-subscriptions-to-cancel": "취소해야 할 유료 구독",
686-686: Naturalize proceed CTAApply this diff:
-"understand-and-continue": "나는 이해하고 계속하고 싶다", +"understand-and-continue": "이해했으며 계속합니다",
730-734: Polish titles and punctuationKorean style omits spaces before “:”.
Apply this diff:
-"warning-organizations-will-be-deleted": "경고 : 조직이 삭제됩니다", -"warning-organizations-will-be-deleted-message": "귀하는 다음 조직에서 유일한 슈퍼 관리자입니다. \n계정이 제거되면 이러한 조직은 영구적으로 삭제됩니다.", -"warning-paid-subscriptions": "경고 : 활성 구독", -"warning-paid-subscriptions-message": "다음 조직에는 활발한 유료 구독이 있습니다. \n이 구독은 즉시 취소됩니다.", +"warning-organizations-will-be-deleted": "경고: 조직이 삭제됩니다", +"warning-organizations-will-be-deleted-message": "다음 조직의 유일한 슈퍼 관리자는 회원님입니다.\n계정을 삭제하면 이 조직들이 영구적으로 삭제됩니다.", +"warning-paid-subscriptions": "경고: 활성 유료 구독", +"warning-paid-subscriptions-message": "다음 조직에는 활성 유료 구독이 있습니다.\n이 구독들은 즉시 취소됩니다.",src/pages/settings/Account.vue (1)
144-178: Subscription cancellation not implementedThere’s a TODO for canceling paid subscriptions. Suggest wiring a backend endpoint or Stripe webhook flow and blocking deletion until cancellations succeed.
I can draft a minimal cancellation flow (Supabase RPC + Stripe call) and optimistic UI with retries. Want me to open a follow-up issue and provide a PoC?
supabase/migrations/20250906094709_better_account_delete.sql (2)
106-157: Improve cron deletion for concurrency and large batches
- Use FOR UPDATE SKIP LOCKED to avoid double-processing when overlapping runs occur.
- Optionally limit batch size to keep each minute’s job bounded.
- FOR account_record IN - SELECT account_id, removal_date, removed_data - FROM public.to_delete_accounts - WHERE removal_date < NOW() + FOR account_record IN + SELECT account_id, removal_date, removed_data + FROM public.to_delete_accounts + WHERE removal_date < NOW() + FOR UPDATE SKIP LOCKED LOOPOptional batch cap:
-- BEFORE the loop: -- PERFORM 1; -- placeholder -- Add: WITH cte AS (SELECT account_id FROM ... ORDER BY removal_date LIMIT 500 FOR UPDATE SKIP LOCKED) SELECT ... FROM to_delete_accounts JOIN cte USING(account_id)
166-172: Make cron.schedule idempotent and verify pg_cron availabilityIf this migration re-runs, the job name will collide. Wrap in a DO block to ignore existing-job errors and ensure the extension exists.
-SELECT cron.schedule( - 'delete-expired-accounts', -- job name - '* * * * *', -- cron expression (every minute) - 'SELECT public.delete_accounts_marked_for_deletion();' -- SQL command -); +DO $$ +BEGIN + PERFORM extversion FROM pg_extension WHERE extname = 'pg_cron'; + IF NOT FOUND THEN + RAISE EXCEPTION 'pg_cron extension is not installed'; + END IF; + BEGIN + PERFORM cron.schedule( + 'delete-expired-accounts', + '* * * * *', + 'SELECT public.delete_accounts_marked_for_deletion();' + ); + EXCEPTION WHEN unique_violation THEN + -- job already exists; optionally adjust schedule/command via cron.alter_job + NULL; + END; +END$$;
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (22)
messages/de.json(6 hunks)messages/en.json(4 hunks)messages/es.json(6 hunks)messages/fr.json(6 hunks)messages/id.json(6 hunks)messages/it.json(6 hunks)messages/ja.json(6 hunks)messages/ko.json(6 hunks)messages/pl.json(6 hunks)messages/pt-br.json(6 hunks)messages/ru.json(6 hunks)messages/tr.json(6 hunks)messages/vi.json(6 hunks)messages/zh-cn.json(6 hunks)src/components.d.ts(2 hunks)src/modules/auth.ts(2 hunks)src/pages/accountDisabled.vue(1 hunks)src/pages/settings/Account.vue(3 hunks)src/types/supabase.types.ts(33 hunks)supabase/functions/_backend/triggers/on_user_delete.ts(1 hunks)supabase/migrations/20250530233128_base.sql(1 hunks)supabase/migrations/20250906094709_better_account_delete.sql(1 hunks)
🔇 Additional comments (35)
supabase/migrations/20250530233128_base.sql (1)
184-185: Verified enum value casing—no mismatches All references use “InvalidIp” exactly; no callers use a different casing.messages/vi.json (3)
155-155: LGTM: cancel-subscriptions-and-continue.
370-370: LGTM: i-am-sure.
686-686: LGTM: understand-and-continue.messages/zh-cn.json (2)
155-155: LGTM: cancel-subscriptions-and-continue.
686-686: LGTM: understand-and-continue.messages/en.json (1)
23-29: LGTM: new account deletion strings.src/types/supabase.types.ts (7)
10-34: LGTM: graphql_public surface added.
1475-1478: RPCs wired into auth guards and disabled page
Verifiedget_account_removal_dateinsrc/pages/accountDisabled.vueandis_account_disabledinsrc/modules/auth.ts.
1939-1945: read_ RPC callers use correct named args – no action needed.*All calls to
read_device_usage,read_bandwidth_usage,read_storage_usage, andread_version_usagealready pass{ p_app_id, p_period_start, p_period_end }per the updated types.
1011-1011: plans.version added: verify downstream usage
Check for any code assuming plan rows withoutversion(e.g. object destructuring, serializers) and update accordingly. Applies to src/types/supabase.types.ts lines 1011, 1034, 1057. To locate potential issues, run:#!/bin/bash # Search for direct usage of plans.version rg -nP "plans?\.version" -n -C2 -g "*.{ts,vue}" # Search for plan object destructuring that might omit version rg -nP "(?:const|let|var)\s+\{[^}]*\}\s*=\s*plan" -n -C2 -g "*.{ts,vue}"
1601-1610: get_org_members now returns uid and is_tmpUpdate call sites to use the new fields:
- src/pages/settings/Account.vue (line 61): switch from
member.emailtomember.uidfor unique keys/identifiers and handlemember.is_tmp(e.g., show temporary-invite state).- src/stores/organization.ts (line 150): use
member.uidinstead ofmember.emailand account formember.is_tmp.
1460-1463: Remove audit for exist_app_versions/get_app_versions – no TS call sites to update.Likely an incorrect or invalid review comment.
581-583: No TS insert/upsert into deleted_account; SQL migrations/seeds include email—no action needed.supabase/functions/_backend/triggers/on_user_delete.ts (1)
12-13: Confirm trigger timing vs event type (avoid double-cancellation).triggerValidator('users','DELETE')picks up the DB’s AFTER DELETE trigger on public.users (in addition to the PGMQ send at mark-for-deletion), so your cancel-subscriptions logic will run on final delete as well. Verify you’re only firing this endpoint once per user (e.g. use a distinct event type or drop the default trigger).src/pages/accountDisabled.vue (1)
113-113: v-html needs trusted content only.Ensure
account-deletion-restoreis maintained by us and contains no user input to avoid XSS.messages/pl.json (6)
18-19: Account-deletion warnings: translation looks goodThe Polish phrasing is clear and correctly conveys irreversibility.
155-155: CTA looks fineWording is clear and consistent with the flow.
370-370: Confirmation CTA is appropriateShort and natural for a destructive confirm.
516-516: Label OKNatural listing heading.
686-686: Proceed CTA looks goodNatural Polish phrasing.
730-734: Warning titles and message are clearGood tone and structure for a destructive action.
messages/ko.json (1)
18-18: Final-warning text OKConveys irreversibility clearly.
src/pages/settings/Account.vue (9)
29-29: Store injection LGTMPattern consistent with other stores.
36-38: State for warnings LGTMAppropriate shapes for teleport rendering.
110-114: Guard now works after refactorOnce canProceed reflects errors, this early-exit is useful.
115-143: Organization deletion warning dialog: good UXUse of teleport to list orgs is clear.
695-709: Teleport org warning block LGTMGood visual hierarchy and accessibility.
710-729: Teleport paid subscriptions warning block LGTMClear list with plan badges.
16-16: Supabase helpers verified and correctly exported
Both getCurrentPlanNameOrg and isPayingOrg are implemented and exported with proper typings in src/services/supabase.ts (lines 377–379 and 421–423).
20-20: Organization store API verifieduseOrganizationStore (src/stores/organization.ts) exposes awaitInitialLoad() and returns organizations — Organization objects include gid, role and name.
200-249: Tighten special-case email check; confirmed isNativePlatform() method
- Change to strict equality:
if (user.email === 'review@capgo.app' && Capacitor.isNativePlatform())- Verified
Capacitor.isNativePlatform()is a function in @capacitor/core v7.4.2 (capacitorjs.com, jsdocs.io)supabase/migrations/20250906094709_better_account_delete.sql (3)
11-19: RLS deny-all is good; keep it as the default postureDeny-all policy + SECURITY DEFINER functions is the right pattern here.
21-23: Confirm service_role bypasses RLS in your environmentGranting ALL to service_role assumes BYPASSRLS (Supabase default). If not, the SECURITY DEFINER function owner must be a role with access.
159-165: Good: lock down the scheduled deletion function to privileged rolesRevoke/Grant here is correct.
| "account-password-heading": "Change my password", | ||
| "account-updated-succ": "Account updated successfully", | ||
| "account-deletion-requested": "You have requested your account to be deleted", | ||
| "account-deletion-restore": "Please contact <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a> if you would like to restore it", |
There was a problem hiding this comment.
🛠️ Refactor suggestion
Harden external links opened in new tabs.
Add rel="noopener noreferrer" to avoid reverse‑tabnabbing; keep HTML in i18n minimal/safe.
-"account-deletion-restore": "Please contact <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a> if you would like to restore it",
+"account-deletion-restore": "Please contact <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a> if you would like to restore it",
-"cannot-find-unknown-version": "Cannot find unknown version, please contact <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>capgo support</a> and check browser logs",
+"cannot-find-unknown-version": "Cannot find unknown version, please contact <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>capgo support</a> and check browser logs",
-"cannot-get-apps": "Cannot get apps. Please check the browser console and contact <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>",
+"cannot-get-apps": "Cannot get apps. Please check the browser console and contact <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>",
-"cannot-invite-user": "Cannot invite user to organization. Please contact <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>",
+"cannot-invite-user": "Cannot invite user to organization. Please contact <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>",
-"cannot-rescind-invitation": "Cannot rescind invitation, please look in the browser console and report this error to the <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Capgo support team</a>",
+"cannot-rescind-invitation": "Cannot rescind invitation, please look in the browser console and report this error to the <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>Capgo support team</a>",
-"cannot-transfer-app": "Cannot transfer organization. Please check the browser console and report the error to <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>.",
+"cannot-transfer-app": "Cannot transfer organization. Please check the browser console and report the error to <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>.",
-"error-message-invitation": "Error happened. Please contact <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>",
+"error-message-invitation": "Error happened. Please contact <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>",Also applies to: 190-191, 194-194, 197-197, 201-201, 349-349
🤖 Prompt for AI Agents
In messages/en.json around line 24 (and also apply to lines 190-191, 194, 197,
201, 349), the i18n strings include anchor tags that open links in a new tab but
lack rel="noopener noreferrer"; update each external <a ... target="_blank">
anchor to include rel="noopener noreferrer" to prevent reverse-tabnabbing and
ensure HTML remains minimal/safe (do this for the listed lines and any other
external-target="_blank" anchors in the file).
| // Show final confirmation | ||
| dialogStore.openDialog({ | ||
| title: t('are-u-sure'), | ||
| description: '', // We'll use Teleport for custom content | ||
| buttons: [ | ||
| { | ||
| text: t('button-cancel'), | ||
| role: 'cancel', | ||
| handler: () => { | ||
| console.log('Cancel clicked') | ||
| }, | ||
| }, | ||
| { | ||
| text: t('button-remove'), | ||
| text: t('i-am-sure'), | ||
| role: 'danger', | ||
| handler: async () => { | ||
| if (!main.auth || main.auth?.email == null) | ||
| return | ||
| const supabaseClient = useSupabase() | ||
|
|
||
| const authUser = await supabase.auth.getUser() | ||
| if (authUser.error) | ||
| return setErrors('update-account', [t('something-went-wrong-try-again-later')], {}) | ||
|
|
||
| try { | ||
| const { data: user } = await supabaseClient | ||
| .from('users') | ||
| .select() | ||
| .eq('id', authUser.data.user.id) | ||
| .single() | ||
| if (!user) | ||
| return setErrors('update-account', [t('something-went-wrong-try-again-later')], {}) | ||
|
|
||
| if (user.email.endsWith('review@capgo.app') && Capacitor.isNativePlatform()) { | ||
| const { error: banErr } = await supabase | ||
| .from('users') | ||
| .update({ ban_time: dayjs().add(5, 'minutes').toDate().toISOString() }) | ||
| .eq('id', user.id) | ||
|
|
||
| if (banErr) { | ||
| console.error('Cannot set ban duration', banErr) | ||
| return setErrors('update-account', [t('something-went-wrong-try-again-later')], {}) | ||
| } | ||
|
|
||
| await main.logout() | ||
| router.replace('/login') | ||
| return | ||
| } | ||
|
|
||
| // Delete user using RPC function | ||
| const { error: deleteError } = await supabase.rpc('delete_user') | ||
|
|
||
| if (deleteError) { | ||
| console.error('Delete error:', deleteError) | ||
| return setErrors('update-account', [t('something-went-wrong-try-again-later')], {}) | ||
| } | ||
|
|
||
| // Delete auth user | ||
| const { error: authError } = await supabase.auth.admin.deleteUser(authUser.data.user.id) | ||
| if (authError) { | ||
| console.error('Auth delete error:', authError) | ||
| return setErrors('update-account', [t('something-went-wrong-try-again-later')], {}) | ||
| } | ||
|
|
||
| await main.logout() | ||
| router.replace('/login') | ||
| } | ||
| catch (error) { | ||
| console.error(error) | ||
| return setErrors('update-account', [t('something-went-wrong-try-again-later')], {}) | ||
| } | ||
| await performAccountDeletion() | ||
| }, | ||
| }, | ||
| ], | ||
| }) | ||
| return dialogStore.onDialogDismiss() | ||
| } | ||
|
|
There was a problem hiding this comment.
🛠️ Refactor suggestion
Localize the final confirmation content and reuse the new i18n keys
Hardcoded English text in the final warning breaks localization and duplicates new keys.
Apply this diff:
dialogStore.openDialog({
title: t('are-u-sure'),
- description: '', // We'll use Teleport for custom content
+ description: '', // Teleport content below uses i18n keys
@@
- text: t('i-am-sure'),
+ text: t('i-am-sure'),
role: 'danger',
handler: async () => {
await performAccountDeletion()
},And update the Teleport content:
- <Teleport v-if="dialogStore.showDialog && dialogStore.dialogOptions?.title === t('are-u-sure')" to="#dialog-v2-content" defer>
- <div class="text-base text-gray-500 dark:text-gray-400">
- <p class="mb-4">
- This action cannot be undone. Your account and all associated data will be permanently deleted.
- </p>
- <p class="font-medium text-gray-700 dark:text-gray-300">
- Your account will be deleted after 30 days
- </p>
- </div>
- </Teleport>
+ <Teleport v-if="dialogStore.showDialog && dialogStore.dialogOptions?.title === t('are-u-sure')" to="#dialog-v2-content" defer>
+ <div class="text-base text-gray-500 dark:text-gray-400">
+ <p class="mb-4" v-html="t('account-deletion-final-warning')" />
+ <p class="font-medium text-gray-700 dark:text-gray-300">
+ {{ t('account-deletion-timer') }} {{ t('account-deletion-timer-in') }} 30 days
+ </p>
+ </div>
+ </Teleport>Note: if the backend exposes get_account_removal_date, consider rendering the exact date instead of “30 days”.
📝 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.
| // Show final confirmation | |
| dialogStore.openDialog({ | |
| title: t('are-u-sure'), | |
| description: '', // We'll use Teleport for custom content | |
| buttons: [ | |
| { | |
| text: t('button-cancel'), | |
| role: 'cancel', | |
| handler: () => { | |
| console.log('Cancel clicked') | |
| }, | |
| }, | |
| { | |
| text: t('button-remove'), | |
| text: t('i-am-sure'), | |
| role: 'danger', | |
| handler: async () => { | |
| if (!main.auth || main.auth?.email == null) | |
| return | |
| const supabaseClient = useSupabase() | |
| const authUser = await supabase.auth.getUser() | |
| if (authUser.error) | |
| return setErrors('update-account', [t('something-went-wrong-try-again-later')], {}) | |
| try { | |
| const { data: user } = await supabaseClient | |
| .from('users') | |
| .select() | |
| .eq('id', authUser.data.user.id) | |
| .single() | |
| if (!user) | |
| return setErrors('update-account', [t('something-went-wrong-try-again-later')], {}) | |
| if (user.email.endsWith('review@capgo.app') && Capacitor.isNativePlatform()) { | |
| const { error: banErr } = await supabase | |
| .from('users') | |
| .update({ ban_time: dayjs().add(5, 'minutes').toDate().toISOString() }) | |
| .eq('id', user.id) | |
| if (banErr) { | |
| console.error('Cannot set ban duration', banErr) | |
| return setErrors('update-account', [t('something-went-wrong-try-again-later')], {}) | |
| } | |
| await main.logout() | |
| router.replace('/login') | |
| return | |
| } | |
| // Delete user using RPC function | |
| const { error: deleteError } = await supabase.rpc('delete_user') | |
| if (deleteError) { | |
| console.error('Delete error:', deleteError) | |
| return setErrors('update-account', [t('something-went-wrong-try-again-later')], {}) | |
| } | |
| // Delete auth user | |
| const { error: authError } = await supabase.auth.admin.deleteUser(authUser.data.user.id) | |
| if (authError) { | |
| console.error('Auth delete error:', authError) | |
| return setErrors('update-account', [t('something-went-wrong-try-again-later')], {}) | |
| } | |
| await main.logout() | |
| router.replace('/login') | |
| } | |
| catch (error) { | |
| console.error(error) | |
| return setErrors('update-account', [t('something-went-wrong-try-again-later')], {}) | |
| } | |
| await performAccountDeletion() | |
| }, | |
| }, | |
| ], | |
| }) | |
| return dialogStore.onDialogDismiss() | |
| } | |
| // Show final confirmation | |
| dialogStore.openDialog({ | |
| title: t('are-u-sure'), | |
| description: '', // Teleport content below uses i18n keys | |
| buttons: [ | |
| { | |
| text: t('button-cancel'), | |
| role: 'cancel', | |
| }, | |
| { | |
| text: t('i-am-sure'), | |
| role: 'danger', | |
| handler: async () => { | |
| await performAccountDeletion() | |
| }, | |
| }, | |
| ], | |
| }) | |
| return dialogStore.onDialogDismiss() | |
| } | |
| <template> | |
| <Teleport | |
| v-if="dialogStore.showDialog && dialogStore.dialogOptions?.title === t('are-u-sure')" | |
| to="#dialog-v2-content" | |
| defer | |
| > | |
| <div class="text-base text-gray-500 dark:text-gray-400"> | |
| <p class="mb-4" v-html="t('account-deletion-final-warning')" /> | |
| <p class="font-medium text-gray-700 dark:text-gray-300"> | |
| {{ t('account-deletion-timer') }} {{ t('account-deletion-timer-in') }} 30 days | |
| </p> | |
| </div> | |
| </Teleport> | |
| </template> |
🤖 Prompt for AI Agents
In src/pages/settings/Account.vue around lines 179 to 199, the final
confirmation dialog uses hardcoded English text and duplicates new i18n keys;
replace the empty description/Teleport fallback and any literal strings with the
existing i18n keys (e.g. use t('account-removal-title'),
t('account-removal-description') or the provided new keys) for title,
description and button labels, and remove duplicated literals; update the
Teleport-provided custom content to render the localized description (and use
the backend get_account_removal_date if available to show the exact date instead
of a hardcoded "30 days"); ensure the dialogStore.openDialog call passes the
localized description (or a localized placeholder) and the Teleport content
reads those same i18n keys so all text is localized and no English literals
remain.
| CREATE OR REPLACE FUNCTION public.is_account_disabled(user_id UUID) | ||
| RETURNS BOOLEAN | ||
| LANGUAGE plpgsql | ||
| SECURITY DEFINER | ||
| SET search_path = public | ||
| AS $$ | ||
| BEGIN | ||
| -- Check if the user_id exists in the to_delete_accounts table | ||
| RETURN EXISTS ( | ||
| SELECT 1 | ||
| FROM public.to_delete_accounts | ||
| WHERE account_id = user_id | ||
| ); | ||
| END; | ||
| $$; | ||
|
|
There was a problem hiding this comment.
Prevent cross-user probing; restrict is_account_disabled to the caller (or service_role)
As written, any caller can check any user_id, leaking state. Gate by auth.uid() (except for service_role) and include auth in search_path.
-CREATE OR REPLACE FUNCTION public.is_account_disabled(user_id UUID)
-RETURNS BOOLEAN
-LANGUAGE plpgsql
-SECURITY DEFINER
-SET search_path = public
+CREATE OR REPLACE FUNCTION public.is_account_disabled(user_id UUID)
+RETURNS BOOLEAN
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public, auth
AS $$
BEGIN
- -- Check if the user_id exists in the to_delete_accounts table
+ -- Allow service_role to query any user; others only themselves
+ IF current_user <> 'service_role' AND user_id IS DISTINCT FROM auth.uid() THEN
+ RAISE EXCEPTION 'forbidden' USING ERRCODE = '42501';
+ END IF;
RETURN EXISTS (
SELECT 1
FROM public.to_delete_accounts
WHERE account_id = user_id
);
END;
$$;Also lock down privileges:
REVOKE ALL ON FUNCTION public.is_account_disabled(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.is_account_disabled(uuid) TO authenticated, service_role;🤖 Prompt for AI Agents
In supabase/migrations/20250906094709_better_account_delete.sql around lines
25-40, the is_account_disabled function currently allows any caller to probe
arbitrary user IDs; change it so only the authenticated user (auth.uid()) or the
service_role can check a given account and ensure auth is resolvable by adding
auth to the function's search_path. Concretely: update the function body to
validate that either the current caller is the service_role OR the provided
user_id equals auth.uid() (deny otherwise), and set SET search_path = auth,
public; then revoke public EXECUTE on the function and GRANT EXECUTE only to
authenticated and service_role roles as shown in the review comment.
| CREATE OR REPLACE FUNCTION public.get_account_removal_date(user_id UUID) | ||
| RETURNS TIMESTAMPTZ | ||
| LANGUAGE plpgsql | ||
| SECURITY DEFINER | ||
| SET search_path = public | ||
| AS $$ | ||
| DECLARE | ||
| removal_date TIMESTAMPTZ; | ||
| BEGIN | ||
| -- Get the removal_date for the user_id | ||
| SELECT to_delete_accounts.removal_date INTO removal_date | ||
| FROM public.to_delete_accounts | ||
| WHERE account_id = user_id; | ||
|
|
||
| -- Throw exception if account is not in the table | ||
| IF removal_date IS NULL THEN | ||
| RAISE EXCEPTION 'Account with ID % is not marked for deletion', user_id; | ||
| END IF; | ||
|
|
||
| RETURN removal_date; | ||
| END; | ||
| $$; | ||
|
|
There was a problem hiding this comment.
Same privacy gate for get_account_removal_date
Anyone can read any user’s removal_date. Restrict to self or service_role; add clearer error for non-owners.
-CREATE OR REPLACE FUNCTION public.get_account_removal_date(user_id UUID)
-RETURNS TIMESTAMPTZ
-LANGUAGE plpgsql
-SECURITY DEFINER
-SET search_path = public
+CREATE OR REPLACE FUNCTION public.get_account_removal_date(user_id UUID)
+RETURNS TIMESTAMPTZ
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public, auth
AS $$
DECLARE
removal_date TIMESTAMPTZ;
BEGIN
+ IF current_user <> 'service_role' AND user_id IS DISTINCT FROM auth.uid() THEN
+ RAISE EXCEPTION 'forbidden' USING ERRCODE = '42501';
+ END IF;
SELECT to_delete_accounts.removal_date INTO removal_date
FROM public.to_delete_accounts
WHERE account_id = user_id;
IF removal_date IS NULL THEN
RAISE EXCEPTION 'Account with ID % is not marked for deletion', user_id;
END IF;
RETURN removal_date;
END;
$$;And restrict execution:
REVOKE ALL ON FUNCTION public.get_account_removal_date(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.get_account_removal_date(uuid) TO authenticated, service_role;🤖 Prompt for AI Agents
In supabase/migrations/20250906094709_better_account_delete.sql around lines
42–64, update the get_account_removal_date function to enforce a privacy gate:
inside the function check the caller identity and allow access only if the
caller is the same user (compare the JWT sub/user id from request context) or if
the caller is the service_role; if neither, RAISE a clear permission error
(e.g., 'Permission denied: cannot view removal_date for another user'). Keep
SECURITY DEFINER but read caller identity using the request JWT claim retrieval
used in your codebase (e.g., request.jwt.claims or current_setting pattern) and
perform the ownership check before selecting/removal_date. After the function
definition add SQL to revoke public execute and grant execute only to
authenticated and service_role (REVOKE ALL ON FUNCTION ... FROM PUBLIC; GRANT
EXECUTE ON FUNCTION ... TO authenticated, service_role).
| CREATE OR REPLACE FUNCTION "public"."delete_user" () RETURNS "void" LANGUAGE "plpgsql" | ||
| SET | ||
| search_path = '' SECURITY DEFINER AS $$ | ||
| DECLARE | ||
| user_id_fn uuid; | ||
| user_email text; | ||
| hashed_email text; | ||
| BEGIN | ||
| -- Get the current user ID and email | ||
| SELECT auth.uid() INTO user_id_fn; | ||
| SELECT email INTO user_email FROM auth.users WHERE id = user_id_fn; | ||
|
|
||
| -- Trigger the queue-based deletion process | ||
| -- This cancels the subscriptions of the user's organizations | ||
| PERFORM pgmq.send( | ||
| 'on_user_delete'::text, | ||
| jsonb_build_object( | ||
| 'user_id', user_id_fn, | ||
| 'email', user_email | ||
| ) | ||
| ); | ||
|
|
||
| -- Mark the user for deletion | ||
| INSERT INTO public.to_delete_accounts ( | ||
| account_id, | ||
| removal_date, | ||
| removed_data | ||
| ) VALUES | ||
| ( | ||
| user_id_fn, | ||
| NOW() + INTERVAL '30 days', | ||
| jsonb_build_object('email', user_email, 'apikeys', (SELECT jsonb_agg(to_jsonb(a.*)) FROM public.apikeys a WHERE a.user_id = user_id_fn)) | ||
| ); | ||
|
|
||
| -- Delete the API keys | ||
| DELETE FROM public.apikeys WHERE public.apikeys.user_id = user_id_fn; | ||
| END; | ||
| $$; | ||
|
|
There was a problem hiding this comment.
🛠️ Refactor suggestion
Make delete_user authenticated-only, idempotent, and remove unused var; avoid storing sensitive key data
- Add guard for unauthenticated calls.
- Use ON CONFLICT with the (proposed) unique index to prevent duplicates and optionally extend removal_date.
- Remove unused hashed_email.
- Consider not embedding API key rows into removed_data; store minimal metadata only.
CREATE OR REPLACE FUNCTION "public"."delete_user" () RETURNS "void" LANGUAGE "plpgsql"
SET
search_path = '' SECURITY DEFINER AS $$
DECLARE
user_id_fn uuid;
user_email text;
- hashed_email text;
BEGIN
-- Get the current user ID and email
SELECT auth.uid() INTO user_id_fn;
+ IF user_id_fn IS NULL THEN
+ RAISE EXCEPTION 'authentication required' USING ERRCODE = '28000';
+ END IF;
SELECT email INTO user_email FROM auth.users WHERE id = user_id_fn;
-- Trigger the queue-based deletion process
-- This cancels the subscriptions of the user's organizations
PERFORM pgmq.send(
'on_user_delete'::text,
jsonb_build_object(
'user_id', user_id_fn,
'email', user_email
)
);
-- Mark the user for deletion
INSERT INTO public.to_delete_accounts (
account_id,
removal_date,
removed_data
) VALUES
(
user_id_fn,
NOW() + INTERVAL '30 days',
- jsonb_build_object('email', user_email, 'apikeys', (SELECT jsonb_agg(to_jsonb(a.*)) FROM public.apikeys a WHERE a.user_id = user_id_fn))
+ jsonb_build_object(
+ 'email', user_email
+ -- Consider only lightweight metadata if needed: 'apikey_count', (SELECT count(*) FROM public.apikeys a WHERE a.user_id = user_id_fn)
+ )
- );
+ )
+ ON CONFLICT (account_id) DO UPDATE
+ SET removal_date = GREATEST(EXCLUDED.removal_date, public.to_delete_accounts.removal_date);
-- Delete the API keys
DELETE FROM public.apikeys WHERE public.apikeys.user_id = user_id_fn;
END;
$$;Lock execution to authenticated users:
REVOKE ALL ON FUNCTION public.delete_user() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.delete_user() TO authenticated, service_role;There was a problem hiding this comment.
Actionable comments posted: 0
♻️ Duplicate comments (7)
messages/es.json (7)
18-23: Fill missing Spanish translations for account-deletion UI keys.Empty values will render blank UI. Add clear translations and keep placeholders/HTML intact.
- "account-deletion-final-warning": "", - "account-deletion-requested": "", + "account-deletion-final-warning": "Esta acción no se puede deshacer.\nTu cuenta y todos los datos asociados se eliminarán permanentemente.", + "account-deletion-requested": "Has solicitado la eliminación de tu cuenta", "account-deletion-restore": "Por favor contacta <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Soporte de Capgo</a> si deseas restaurarla", - "account-deletion-timer": "", - "account-deletion-timer-in": "", - "account-deletion-very-soon": "", + "account-deletion-timer": "Tu cuenta dejará de existir", + "account-deletion-timer-in": "Tu cuenta dejará de existir en", + "account-deletion-very-soon": "Muy pronto",
155-155: Fill translation: cancel-subscriptions-and-continue.- "cancel-subscriptions-and-continue": "", + "cancel-subscriptions-and-continue": "Cancelar suscripciones y continuar",
370-370: Fill translation: i-am-sure.- "i-am-sure": "", + "i-am-sure": "Estoy seguro",
516-516: Fill translation: organizations-to-be-deleted.- "organizations-to-be-deleted": "", + "organizations-to-be-deleted": "Organizaciones que se eliminarán",
520-520: Fill translation: paid-subscriptions-to-cancel.- "paid-subscriptions-to-cancel": "", + "paid-subscriptions-to-cancel": "Suscripciones de pago a cancelar",
686-686: Fill translation: understand-and-continue.- "understand-and-continue": "", + "understand-and-continue": "Entiendo y deseo continuar",
730-733: Fill translations for warning keys (organizations and paid subscriptions).Keep tone consistent with other warnings.
- "warning-organizations-will-be-deleted": "", - "warning-organizations-will-be-deleted-message": "", - "warning-paid-subscriptions": "", - "warning-paid-subscriptions-message": "", + "warning-organizations-will-be-deleted": "ADVERTENCIA: se eliminarán organizaciones", + "warning-organizations-will-be-deleted-message": "Eres el único super administrador en las siguientes organizaciones.\nEstas organizaciones se eliminarán permanentemente cuando se elimine tu cuenta:", + "warning-paid-subscriptions": "ADVERTENCIA: suscripciones activas", + "warning-paid-subscriptions-message": "Las siguientes organizaciones tienen suscripciones de pago activas.\nEstas suscripciones se cancelarán inmediatamente:",
🧹 Nitpick comments (1)
messages/es.json (1)
20-20: Polish grammar/casing in account-deletion-restore.Minor Spanish fix; match file style (lowercase “soporte”) and add article.
- "account-deletion-restore": "Por favor contacta <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Soporte de Capgo</a> si deseas restaurarla", + "account-deletion-restore": "Contacta al <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>soporte de Capgo</a> si deseas restaurarla",
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
messages/es.json(7 hunks)messages/fr.json(7 hunks)supabase/migrations/20250906094709_better_account_delete.sql(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
- messages/fr.json
- supabase/migrations/20250906094709_better_account_delete.sql
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (4)
supabase/migrations/20250906094709_better_account_delete.sql (4)
10-14: LGTM: uniqueness + scheduling index addedPrevents duplicate rows per account and makes sweeps efficient. Nicely done.
30-46: Restrict is_account_disabled to self/service_role; prevent cross‑user probingAnyone can probe any user’s status. Gate by auth.uid() (except service_role), include auth on search_path, and lock down EXECUTE.
Apply:
CREATE OR REPLACE FUNCTION public.is_account_disabled(user_id UUID) RETURNS BOOLEAN LANGUAGE plpgsql SECURITY DEFINER -SET search_path = public +SET search_path = public, auth AS $$ BEGIN - -- Check if the user_id exists in the to_delete_accounts table + -- Allow service_role to query any user; others only themselves + IF current_user <> 'service_role' AND user_id IS DISTINCT FROM auth.uid() THEN + RAISE EXCEPTION 'forbidden' USING ERRCODE = '42501'; + END IF; RETURN EXISTS ( SELECT 1 FROM public.to_delete_accounts WHERE account_id = user_id ); END; $$; + +-- Restrict who can call it +REVOKE ALL ON FUNCTION public.is_account_disabled(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.is_account_disabled(uuid) TO authenticated, service_role;
47-70: Apply same privacy gate to get_account_removal_date and restrict EXECUTEOtherwise any caller can read arbitrary users’ removal_date.
CREATE OR REPLACE FUNCTION public.get_account_removal_date(user_id UUID) RETURNS TIMESTAMPTZ LANGUAGE plpgsql SECURITY DEFINER -SET search_path = public +SET search_path = public, auth AS $$ DECLARE removal_date TIMESTAMPTZ; BEGIN + IF current_user <> 'service_role' AND user_id IS DISTINCT FROM auth.uid() THEN + RAISE EXCEPTION 'forbidden' USING ERRCODE = '42501'; + END IF; SELECT to_delete_accounts.removal_date INTO removal_date FROM public.to_delete_accounts WHERE account_id = user_id; IF removal_date IS NULL THEN RAISE EXCEPTION 'Account with ID % is not marked for deletion', user_id; END IF; RETURN removal_date; END; $$; + +REVOKE ALL ON FUNCTION public.get_account_removal_date(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.get_account_removal_date(uuid) TO authenticated, service_role;
72-108: Make delete_user authenticated‑only, idempotent, and avoid storing API keys
- Guard unauthenticated calls.
- Use ON CONFLICT for idempotency and to extend the scheduled date safely.
- Don’t persist raw API keys in removed_data.
- Add search_path and restrict EXECUTE.
-CREATE OR REPLACE FUNCTION "public"."delete_user" () RETURNS "void" LANGUAGE "plpgsql" -SECURITY DEFINER AS $$ +CREATE OR REPLACE FUNCTION public.delete_user() RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, auth +AS $$ DECLARE user_id_fn uuid; user_email text; BEGIN -- Get the current user ID and email - SELECT "auth"."uid"() INTO user_id_fn; - SELECT "email" INTO user_email FROM "auth"."users" WHERE "id" = user_id_fn; + SELECT auth.uid() INTO user_id_fn; + IF user_id_fn IS NULL THEN + RAISE EXCEPTION 'authentication required' USING ERRCODE = '28000'; + END IF; + SELECT email INTO user_email FROM auth.users WHERE id = user_id_fn; -- Trigger the queue-based deletion process -- This cancels the subscriptions of the user's organizations - PERFORM "pgmq"."send"( + PERFORM pgmq.send( 'on_user_delete'::text, - "jsonb_build_object"( + jsonb_build_object( 'user_id', user_id_fn, 'email', user_email ) ); -- Mark the user for deletion - INSERT INTO "public"."to_delete_accounts" ( - "account_id", - "removal_date", - "removed_data" - ) VALUES - ( - user_id_fn, - NOW() + INTERVAL '30 days', - "jsonb_build_object"('email', user_email, 'apikeys', (SELECT "jsonb_agg"("to_jsonb"(a.*)) FROM "public"."apikeys" a WHERE a."user_id" = user_id_fn)) - ); + INSERT INTO public.to_delete_accounts (account_id, removal_date, removed_data) + VALUES ( + user_id_fn, + NOW() + INTERVAL '30 days', + jsonb_build_object('email', user_email) + ) + ON CONFLICT (account_id) DO UPDATE + SET removal_date = GREATEST(EXCLUDED.removal_date, public.to_delete_accounts.removal_date); -- Delete the API keys - DELETE FROM "public"."apikeys" WHERE "public"."apikeys"."user_id" = user_id_fn; + DELETE FROM public.apikeys WHERE public.apikeys.user_id = user_id_fn; END; $$; + +REVOKE ALL ON FUNCTION public.delete_user() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.delete_user() TO authenticated, service_role;
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
src/modules/auth.ts(2 hunks)src/types/supabase.types.ts(32 hunks)supabase/migrations/20250906094709_better_account_delete.sql(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- src/modules/auth.ts
🧰 Additional context used
📓 Path-based instructions (1)
{src,supabase/functions/_backend}/**/*.{ts,tsx,js,jsx,vue}
📄 CodeRabbit inference engine (CLAUDE.md)
Enforce code style per @antfu/eslint-config: single quotes and no semicolons
Files:
src/types/supabase.types.ts
🧬 Code graph analysis (1)
src/types/supabase.types.ts (1)
supabase/functions/_backend/utils/supabase.types.ts (2)
Json(1-7)Database(9-2080)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Run tests
🔇 Additional comments (5)
supabase/migrations/20250906094709_better_account_delete.sql (2)
16-24: LGTM: deny‑all RLS baselineGood default posture for a sensitive queue table. Service role can bypass via role privileges.
121-151: Cannot confirm FK cascades; verify before simplifying deletionNo
FOREIGN KEYorREFERENCES … ON DELETE CASCADEclauses forpublic.users(id)orto_delete_accounts(account_id)were found in your migrations. Confirm thatauth.users(id)→public.users(id)andto_delete_accounts(account_id)→public.users(id)both declareON DELETE CASCADEbefore removing the individualDELETEstatements in the cleanup loop.src/types/supabase.types.ts (3)
1236-1267: Types for to_delete_accounts match SQLShapes align with the migration (FK to users, created_at non-null, removal_date required). Good.
1486-1488: RPC surface added for get_account_removal_dateSignature and return type look correct; will work once the SQL privacy gate is in place.
Ensure the SQL migration updates per earlier comments so this RPC can be safely exposed to clients.
1782-1785: RPC surface for is_account_disabledType shape is fine. Security relies on the SQL function; please apply the privacy gate there.
| -- Create a cron job to run the account deletion function every minute | ||
| -- This will process and permanently delete accounts that have passed their removal_date | ||
| SELECT "cron"."schedule"( | ||
| 'delete-expired-accounts', -- job name | ||
| '* * * * *', -- cron expression (every minute) | ||
| 'SELECT "public"."delete_accounts_marked_for_deletion"();' -- SQL command | ||
| ); No newline at end of file |
There was a problem hiding this comment.
🛠️ Refactor suggestion
Make pg_cron job creation idempotent
Repeated deploys may create duplicate jobs or fail. Guard schedule.
- SELECT "cron"."schedule"(
- 'delete-expired-accounts', -- job name
- '* * * * *', -- cron expression (every minute)
- 'SELECT "public"."delete_accounts_marked_for_deletion"();' -- SQL command
- );
+DO $$
+BEGIN
+ -- Try to schedule; ignore if already present
+ PERFORM cron.schedule(
+ 'delete-expired-accounts',
+ '* * * * *',
+ 'SELECT public.delete_accounts_marked_for_deletion();'
+ );
+EXCEPTION
+ WHEN others THEN
+ -- Best-effort idempotency; optionally check cron.job for an existing entry
+ NULL;
+END $$;🤖 Prompt for AI Agents
In supabase/migrations/20250906094709_better_account_delete.sql around lines 169
to 175, the pg_cron job is created unconditionally which can produce duplicates
on repeated deploys; wrap the cron.schedule call in a DO block that checks
cron.job (e.g., WHERE jobname = 'delete-expired-accounts') and only calls
cron.schedule (via PERFORM) if no existing job with that name exists so the job
creation is idempotent.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (4)
messages/es.json (4)
370-370: Optional: gender-inclusive variant.Consider “Estoy seguro/a” if your UX guideline prefers inclusive forms. Otherwise fine.
516-516: Natural phrasing in Spanish.Use “que se eliminarán” instead of “a ser eliminadas”.
- "organizations-to-be-deleted": "Organizaciones a ser eliminadas", + "organizations-to-be-deleted": "Organizaciones que se eliminarán",
520-520: Preserve “paid” semantics.Original key implies paid subscriptions; reflect as “de pago”.
- "paid-subscriptions-to-cancel": "Suscripciones a cancelar", + "paid-subscriptions-to-cancel": "Suscripciones de pago a cancelar",
730-733: Improve headings and terminology (accent in “súper”, “de pago”).Align casing with Spanish sentence case and fix terminology.
- "warning-organizations-will-be-deleted": "Advertencia: Las Organizaciones Serán Eliminadas", - "warning-organizations-will-be-deleted-message": "Eres el único super administrador en las siguientes organizaciones. Estas organizaciones se eliminarán permanentemente cuando se elimine tu cuenta:", - "warning-paid-subscriptions": "Advertencia: Suscripciones Activas", - "warning-paid-subscriptions-message": "Las siguientes organizaciones tienen suscripciones pagadas activas. Estas suscripciones se cancelarán inmediatamente:", + "warning-organizations-will-be-deleted": "Advertencia: se eliminarán organizaciones", + "warning-organizations-will-be-deleted-message": "Eres el único súper administrador en las siguientes organizaciones. Estas organizaciones se eliminarán permanentemente cuando se elimine tu cuenta:", + "warning-paid-subscriptions": "Advertencia: suscripciones de pago activas", + "warning-paid-subscriptions-message": "Las siguientes organizaciones tienen suscripciones de pago activas. Estas suscripciones se cancelarán inmediatamente:",
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
messages/es.json(6 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Run tests
🔇 Additional comments (2)
messages/es.json (2)
155-155: LGTM: clear CTA.
686-686: LGTM.
| "account-deletion-final-warning": "Esta acción no puede deshacerse. Su cuenta y todos los datos asociados serán eliminados permanentemente.", | ||
| "account-deletion-requested": "Has solicitado que se elimine tu cuenta.", | ||
| "account-deletion-restore": "Por favor contacta <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Soporte de Capgo</a> si deseas restaurarla", | ||
| "account-deletion-timer": "Tu cuenta dejará de existir", | ||
| "account-deletion-timer-in": "Tu cuenta dejará de existir en", | ||
| "account-deletion-very-soon": "Muy pronto", |
There was a problem hiding this comment.
Fix Spanish consistency and grammar (informal “tú”, missing preposition).
- Keep consistent “tú” form used elsewhere (“Tu cuenta…”, not “Su”).
- Add missing “al” before “soporte”.
Apply this diff:
- "account-deletion-final-warning": "Esta acción no puede deshacerse. Su cuenta y todos los datos asociados serán eliminados permanentemente.",
- "account-deletion-requested": "Has solicitado que se elimine tu cuenta.",
- "account-deletion-restore": "Por favor contacta <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Soporte de Capgo</a> si deseas restaurarla",
+ "account-deletion-final-warning": "Esta acción no se puede deshacer. Tu cuenta y todos los datos asociados se eliminarán permanentemente.",
+ "account-deletion-requested": "Has solicitado la eliminación de tu cuenta.",
+ "account-deletion-restore": "Contacta al <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>soporte de Capgo</a> si deseas restaurarla",📝 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.
| "account-deletion-final-warning": "Esta acción no puede deshacerse. Su cuenta y todos los datos asociados serán eliminados permanentemente.", | |
| "account-deletion-requested": "Has solicitado que se elimine tu cuenta.", | |
| "account-deletion-restore": "Por favor contacta <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Soporte de Capgo</a> si deseas restaurarla", | |
| "account-deletion-timer": "Tu cuenta dejará de existir", | |
| "account-deletion-timer-in": "Tu cuenta dejará de existir en", | |
| "account-deletion-very-soon": "Muy pronto", | |
| "account-deletion-final-warning": "Esta acción no se puede deshacer. Tu cuenta y todos los datos asociados se eliminarán permanentemente.", | |
| "account-deletion-requested": "Has solicitado la eliminación de tu cuenta.", | |
| "account-deletion-restore": "Contacta al <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>soporte de Capgo</a> si deseas restaurarla", | |
| "account-deletion-timer": "Tu cuenta dejará de existir", | |
| "account-deletion-timer-in": "Tu cuenta dejará de existir en", | |
| "account-deletion-very-soon": "Muy pronto", |
🤖 Prompt for AI Agents
In messages/es.json around lines 18 to 23, the Spanish text uses inconsistent
pronouns and misses a preposition: change "Su cuenta..." to the informal "Tu
cuenta..." to match the rest of the file, and insert the missing preposition
"al" before "Soporte de Capgo" in the account-deletion-restore value (keeping
the existing anchor tag and attributes intact).
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
eslint.config.js (1)
30-39: Tighten and simplify exceptions.Keep exceptions minimal and document the sanitization used in these components. You can also fold them into the prior override with per-override ignores for a single source of truth.
Apply this diff to consolidate:
{ // Vue-specific overrides files: ['**/*.vue'], + // Exceptions: allow v-html in vetted components (kept out of this rule) + ignores: [ + 'src/components/Table.vue', + 'src/components/TableLog.vue', + ], rules: { // Globally disallow v-html 'vue/no-v-html': 'error', }, }, - { - // Exceptions: allow v-html in vetted components - files: [ - 'src/components/Table.vue', - 'src/components/TableLog.vue', - ], - rules: { - 'vue/no-v-html': 'off', - }, - },Alternatively, drop the config-level exception and add inline disables at the exact use sites to keep the blast radius minimal.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
eslint.config.js(2 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-08T17:29:59.365Z
Learnt from: CR
PR: Cap-go/capgo#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-08T17:29:59.365Z
Learning: Applies to {src,supabase/functions/_backend}/**/*.{ts,tsx,js,jsx,vue} : Enforce code style per antfu/eslint-config: single quotes and no semicolons
Applied to files:
eslint.config.js
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Run tests
🔇 Additional comments (1)
eslint.config.js (1)
6-6: Enables Vue rules via antfu — good.This turns on Vue plugin support and aligns with the repo’s single-quote/no-semicolon style. No issues.
| { | ||
| // Vue-specific overrides | ||
| files: ['**/*.vue'], | ||
| rules: { | ||
| // Globally disallow v-html | ||
| 'vue/no-v-html': 'error', | ||
| }, | ||
| }, |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
Block v-html globally: good default; verify no unintended violations.
Great for XSS hardening. Please confirm new pages (e.g., src/pages/accountDisabled.vue, src/pages/settings/Account.vue) and recent i18n strings don’t rely on v-html.
Run to spot any usages outside the approved components:
If translations need markup, prefer component interpolation () or a dedicated SafeHtml component with sanitization (e.g., DOMPurify) instead of v-html.
I can draft a SafeHtml component or refactor examples using if helpful.
🏁 Script executed:
#!/bin/bash
# List all v-html occurrences in Vue files
rg -n --glob 'src/**/*.vue' -C2 '\bv-html\b' || true
# Show only those not in the vetted components
rg -n --glob 'src/**/*.vue' '\bv-html\b' | rg -v 'src/components/(Table|TableLog)\.vue' || trueLength of output: 3331
Remove unauthorized v-html usages and add necessary exceptions
- src/pages/accountDisabled.vue:113 still uses v-html; replace with
<i18n-t>interpolation or a sanitized SafeHtml component. - src/components/Table.vue (lines 387, 410, 416) and TableLog.vue (line 419) rely on v-html—either add
// eslint-disable-next-line vue/no-v-htmloverrides or refactor into a dedicated sanitization component.
There was a problem hiding this comment.
Actionable comments posted: 0
♻️ Duplicate comments (4)
supabase/migrations/20250906094709_better_account_delete.sql (4)
31-45: Gate probe to self or service_role; lock EXECUTE; set search_pathAs written, anyone can probe any user’s disabled state and the function inherits default PUBLIC EXECUTE. Add a privacy check, and REVOKE/GRANT.
-CREATE OR REPLACE FUNCTION public.is_account_disabled(user_id UUID) -RETURNS BOOLEAN -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public +CREATE OR REPLACE FUNCTION public.is_account_disabled(user_id UUID) +RETURNS BOOLEAN +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, auth AS $$ BEGIN - -- Check if the user_id exists in the to_delete_accounts table + -- Allow service_role to query any user; others only themselves + IF current_user <> 'service_role' AND user_id IS DISTINCT FROM auth.uid() THEN + RAISE EXCEPTION 'forbidden' USING ERRCODE = '42501'; + END IF; RETURN EXISTS ( SELECT 1 FROM public.to_delete_accounts WHERE account_id = user_id ); END; $$; + +-- Restrict execution +REVOKE ALL ON FUNCTION public.is_account_disabled(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.is_account_disabled(uuid) TO authenticated, service_role;
48-70: Same privacy gate for removal_date; restrict EXECUTE; set search_pathAnyone can read another user’s removal_date; fix similarly.
-CREATE OR REPLACE FUNCTION public.get_account_removal_date(user_id UUID) -RETURNS TIMESTAMPTZ -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = public +CREATE OR REPLACE FUNCTION public.get_account_removal_date(user_id UUID) +RETURNS TIMESTAMPTZ +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, auth AS $$ DECLARE removal_date TIMESTAMPTZ; BEGIN + IF current_user <> 'service_role' AND user_id IS DISTINCT FROM auth.uid() THEN + RAISE EXCEPTION 'forbidden' USING ERRCODE = '42501'; + END IF; -- Get the removal_date for the user_id SELECT to_delete_accounts.removal_date INTO removal_date FROM public.to_delete_accounts WHERE account_id = user_id; @@ END; $$; + +REVOKE ALL ON FUNCTION public.get_account_removal_date(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.get_account_removal_date(uuid) TO authenticated, service_role;
72-108: delete_user lacks auth guard, search_path hardening, idempotency; avoid embedding API key rows
- Add explicit search_path.
- Reject unauthenticated calls early.
- Use ON CONFLICT to upsert and extend the window.
- Don’t store API key rows in removed_data.
- Lock EXECUTE to authenticated/service_role.
-CREATE OR REPLACE FUNCTION "public"."delete_user" () RETURNS "void" LANGUAGE "plpgsql" -SECURITY DEFINER AS $$ +CREATE OR REPLACE FUNCTION public.delete_user () RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public, auth +AS $$ DECLARE user_id_fn uuid; user_email text; BEGIN -- Get the current user ID and email - SELECT "auth"."uid"() INTO user_id_fn; - SELECT "email" INTO user_email FROM "auth"."users" WHERE "id" = user_id_fn; + SELECT auth.uid() INTO user_id_fn; + IF user_id_fn IS NULL THEN + RAISE EXCEPTION 'authentication required' USING ERRCODE = '28000'; + END IF; + SELECT email INTO user_email FROM auth.users WHERE id = user_id_fn; @@ - PERFORM "pgmq"."send"( + PERFORM pgmq.send( 'on_user_delete'::text, - "jsonb_build_object"( + jsonb_build_object( 'user_id', user_id_fn, 'email', user_email ) ); @@ - INSERT INTO "public"."to_delete_accounts" ( - "account_id", - "removal_date", - "removed_data" + INSERT INTO public.to_delete_accounts ( + account_id, + removal_date, + removed_data ) VALUES ( user_id_fn, NOW() + INTERVAL '30 days', - "jsonb_build_object"('email', user_email, 'apikeys', (SELECT "jsonb_agg"("to_jsonb"(a.*)) FROM "public"."apikeys" a WHERE a."user_id" = user_id_fn)) - ); + jsonb_build_object('email', user_email) + ) + ON CONFLICT (account_id) DO UPDATE + SET removal_date = GREATEST(EXCLUDED.removal_date, public.to_delete_accounts.removal_date); @@ - DELETE FROM "public"."apikeys" WHERE "public"."apikeys"."user_id" = user_id_fn; + DELETE FROM public.apikeys WHERE public.apikeys.user_id = user_id_fn; END; $$; + +REVOKE ALL ON FUNCTION public.delete_user() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.delete_user() TO authenticated, service_role;
172-178: Make pg_cron scheduling idempotentRepeated deploys can error or create dup jobs. Wrap with a guard.
- SELECT "cron"."schedule"( - 'delete-expired-accounts', -- job name - '* * * * *', -- cron expression (every minute) - 'SELECT "public"."delete_accounts_marked_for_deletion"();' -- SQL command - ); +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM cron.job WHERE jobname = 'delete-expired-accounts' + ) THEN + PERFORM cron.schedule( + 'delete-expired-accounts', + '* * * * *', + 'SELECT public.delete_accounts_marked_for_deletion();' + ); + END IF; +END $$;
🧹 Nitpick comments (10)
supabase/tests/29_test_delete_accounts_marked_for_deletion.sql (7)
9-15: Harden SECURITY DEFINER helper with explicit search_pathSet a safe search_path to avoid function-hijack risks during tests.
-CREATE OR REPLACE FUNCTION create_test_user_for_deletion( +CREATE OR REPLACE FUNCTION create_test_user_for_deletion( user_id UUID, user_email TEXT ) RETURNS VOID LANGUAGE plpgsql -SECURITY DEFINER +SECURITY DEFINER +SET search_path = public, auth, extensions AS $$
18-33: Confirm auth.users insert shape won’t driftDirectly inserting many columns into auth.users is brittle across Supabase upgrades (column set/constraints can change). Prefer a test helper that provisions users or keep the column list minimal and stable. At least verify this passes CI against your current Postgres/Supabase version.
Do you want me to adapt this to a helper-based user factory to reduce coupling?
48-60: Test 2 is a weak proxy for “returns table”Querying information_schema for a name collision could still pass/flake. Stronger: assert the function’s output columns via a typed record or by selecting (f()). Example:
--- a -SELECT - ok ( ... ) ; +WITH r AS (SELECT * FROM delete_accounts_marked_for_deletion()) +SELECT ok( + (SELECT array_agg(attname ORDER BY attname) + FROM pg_attribute + WHERE attrelid = 'public.delete_accounts_marked_for_deletion'::regprocedure::oid) IS NULL + , 'function does not materialize a table' +);Alternatively, simply assert that (SELECT deleted_count, deleted_user_ids FROM delete_accounts_marked_for_deletion()) returns a single row with expected types.
156-176: Also assert deleted_user_ids contains the expected UUIDYou validate count on first run and 0 on second, but not the contents. Add an assertion that the returned array contains 2222…22 on first run.
205-245: Multiple-deletion: assert set equality of IDs, not just countCheck the array has exactly {3333…33,4444…44}.
308-346: Strengthen inconsistent-state test with result assertionslives_ok + cleanup check is good. Also assert deleted_count = 1 and that deleted_user_ids contains 7777…77.
350-363: Add permission tests for is_account_disabled/get_account_removal_dateOnce those RPCs are privacy-gated (self or service_role), mirror this throws_ok pattern to ensure regular users can’t probe other accounts.
supabase/migrations/20250906094709_better_account_delete.sql (3)
3-3: Prefer IDENTITY over SERIALSERIAL is legacy and creates a separate sequence. Use GENERATED ALWAYS (or BY DEFAULT) AS IDENTITY for cleaner DDL and easier resets.
- id SERIAL PRIMARY KEY, + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
21-24: Consider FORCE ROW LEVEL SECURITY for defense-in-depthIf you expect even table owners to be constrained (outside superuser/service_role), add FORCE RLS. Non-blocking given SEC DEFINER usage.
ALTER TABLE public.to_delete_accounts FORCE ROW LEVEL SECURITY;
110-161: Set safe search_path for SECURITY DEFINEREven though objects are schema-qualified, set search_path explicitly for consistency and safety.
CREATE OR REPLACE FUNCTION "public"."delete_accounts_marked_for_deletion" () RETURNS TABLE(deleted_count INTEGER, deleted_user_ids UUID[]) LANGUAGE "plpgsql" -SECURITY DEFINER +SECURITY DEFINER +SET search_path = public, auth AS $$
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
supabase/migrations/20250906094709_better_account_delete.sql(1 hunks)supabase/tests/29_test_delete_accounts_marked_for_deletion.sql(1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: Run tests
- GitHub Check: Analyze (java-kotlin)
… update accountDisabled page for safe HTML rendering
… into better_account_delete
…multiple language files
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
src/pages/accountDisabled.vue (1)
22-23: Use browser-safe timer type (duplicate of prior comment).Replace Node-specific
NodeJS.Timeoutwith a cross-env-safe type.-let intervalId: NodeJS.Timeout | null = null +let intervalId: ReturnType<typeof setInterval> | null = null
🧹 Nitpick comments (5)
src/pages/accountDisabled.vue (3)
137-140: Render the anchor only if there’s visible link text.Prevents empty, focusable anchors when translators omit the link text.
- <a :href="restoreParts.href" target="_blank" rel="noopener noreferrer" class="text-blue-500 underline hover:text-blue-600">{{ restoreParts.linkText }}</a> + <a + v-if="restoreParts.linkText" + :href="restoreParts.href" + target="_blank" + rel="noopener noreferrer" + class="text-blue-500 underline hover:text-blue-600" + >{{ restoreParts.linkText }}</a>
86-101: Avoid comparing translated strings for control flow.Using
timeRemaining === t('account-deletion-very-soon')is brittle. Compute a boolean for “imminent” instead and use it in the template.const timeRemaining = computed(() => { if (loading.value) { return t('loading') } if (error.value || !deletionDate.value) { return error.value || t('error') } const diff = deletionDate.value.getTime() - currentTime.value.getTime() if (diff <= 0) { return t('account-deletion-very-soon') }Add (nearby, after
timeRemaining):+const isDeletionImminent = computed(() => { + if (loading.value || error.value || !deletionDate.value) return false + return deletionDate.value.getTime() - currentTime.value.getTime() <= 0 +})And update the label:
- {{ timeRemaining === t('account-deletion-very-soon') ? t('account-deletion-timer') : t('account-deletion-timer-in') }} + {{ isDeletionImminent ? t('account-deletion-timer') : t('account-deletion-timer-in') }}Also applies to: 143-144
81-84: Minor cleanup after clearing the interval.Null out the handle post-clear to avoid accidental reuse.
onUnmounted(() => { if (intervalId) { clearInterval(intervalId) + intervalId = null } })src/components/Table.vue (2)
7-7: Import PropType/VNodeChild for proper typing of RenderCell.You’re introducing a component; bring in Vue’s PropType and VNodeChild as type-only imports to strongly type the renderer.
-import { computed, defineComponent, onMounted, onUnmounted, ref, watch } from 'vue' +import { computed, defineComponent, onMounted, onUnmounted, ref, watch } from 'vue' +import type { PropType, VNodeChild } from 'vue'
295-305: Strongly type RenderCell props; remove confusing Function casts.
Function as unknown as () => ((item) => any)is misleading and harms DX. Use PropType and return VNodeChild; drop the generic on defineComponent to avoid conflicts with runtime props.-// Helper component to render VNode content from a column's renderFunction -const RenderCell = defineComponent<{ renderer?: (item: any) => any, item: any }>({ +// Helper component to render VNode content from a column's renderFunction +const RenderCell = defineComponent({ name: 'RenderCell', props: { - renderer: Function as unknown as () => ((item: any) => any) | undefined, - item: { type: Object as any, required: true }, + renderer: Function as PropType<(item: any) => VNodeChild | null | undefined>, + item: { type: Object as PropType<any>, required: true }, }, setup(props) { return () => (props.renderer ? (props.renderer as any)(props.item) : null) }, })
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (7)
src/components/Table.vue(4 hunks)src/components/TableLog.vue(1 hunks)src/components/comp_def.ts(1 hunks)src/components/tables/AppTable.vue(2 hunks)src/pages/accountDisabled.vue(1 hunks)src/pages/settings/organization/Members.vue(2 hunks)src/typed-router.d.ts(2 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
{src,supabase/functions/_backend}/**/*.{ts,tsx,js,jsx,vue}
📄 CodeRabbit inference engine (CLAUDE.md)
Enforce code style per @antfu/eslint-config: single quotes and no semicolons
Files:
src/components/comp_def.tssrc/typed-router.d.tssrc/components/TableLog.vuesrc/components/Table.vuesrc/pages/settings/organization/Members.vuesrc/components/tables/AppTable.vuesrc/pages/accountDisabled.vue
src/**/*.vue
📄 CodeRabbit inference engine (CLAUDE.md)
Use Vue 3 with Composition API and <script setup> syntax in Vue components
Files:
src/components/TableLog.vuesrc/components/Table.vuesrc/pages/settings/organization/Members.vuesrc/components/tables/AppTable.vuesrc/pages/accountDisabled.vue
src/pages/**/*.vue
📄 CodeRabbit inference engine (CLAUDE.md)
Use file-based routing; place route components under src/pages/
Files:
src/pages/settings/organization/Members.vuesrc/pages/accountDisabled.vue
🧠 Learnings (2)
📚 Learning: 2025-09-08T17:29:59.366Z
Learnt from: CR
PR: Cap-go/capgo#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-08T17:29:59.366Z
Learning: Applies to src/pages/**/*.vue : Use file-based routing; place route components under src/pages/
Applied to files:
src/typed-router.d.ts
📚 Learning: 2025-09-08T17:29:59.366Z
Learnt from: CR
PR: Cap-go/capgo#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-08T17:29:59.366Z
Learning: Applies to src/layouts/**/*.vue : Place reusable page layout components under src/layouts/
Applied to files:
src/typed-router.d.ts
🪛 GitHub Check: CodeQL
src/pages/accountDisabled.vue
[failure] 30-30: Incomplete multi-character sanitization
This string may still contain <script, which may cause an HTML element injection vulnerability.
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Run tests
🔇 Additional comments (8)
src/typed-router.d.ts (2)
22-23: Route mapping for /accountDisabled looks correct.Matches file-based routing and naming patterns already present (e.g., /ApiKeys). No action needed.
70-73: File-path map for src/pages/accountDisabled.vue is consistent.Route name and view mapping align with unplugin-vue-router expectations.
src/components/TableLog.vue (1)
419-421: LGTM! Dynamic component rendering is a significant improvement over v-html.The switch from
v-htmlto dynamic component rendering aligns with Vue best practices and eliminates XSS vulnerabilities from HTML injection. This change is consistent with the broader refactor across the codebase to userenderFunctionfor complex cell content.src/components/comp_def.ts (1)
40-42: Well-designed API addition for safe cell rendering.The new
renderFunctionproperty provides a clean, type-safe alternative to HTML string manipulation. This is a much better approach than the previousdisplayFunction+allowHtml+sanitizeHtmlpattern, as it leverages Vue's virtual DOM for secure rendering.src/pages/settings/organization/Members.vue (1)
115-133: Excellent implementation of VNode-based cell rendering.The
renderFunctionimplementation properly constructs VNodes using Vue'shfunction, providing a secure and performant way to render complex cell content. The avatar logic with fallback to initials is well-structured and follows Vue best practices.src/components/tables/AppTable.vue (1)
56-73: Clean VNode construction for app table cells.The implementation correctly uses Vue's
hfunction to build the cell content programmatically. The structure with avatar fallback to initials mirrors the pattern used in Members.vue, maintaining consistency across the codebase.src/components/Table.vue (2)
399-403: LGTM: safe per-cell rendering via RenderCell.Cleanly routes complex cell rendering through a function without using raw HTML.
429-433: LGTM: body cell RenderCell usage.Consistent with header usage; avoids raw HTML while enabling rich cells.
| <button | ||
| class="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-600 dark:hover:text-gray-300 rounded-md cursor-pointer" | ||
| @click.stop="col.onClick ? col.onClick(elem) : () => {}" | ||
| v-html="col.icon" | ||
| /> | ||
| > | ||
| <component :is="col.icon" /> | ||
| </button> |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
Icon rendering via dynamic component: good security upgrade; add a11y label and verify icons are components.
Switching from HTML strings to components is safer. Consider adding an aria-label/title for icon-only buttons, and verify all col.icon/action.icon usages are components (not strings).
Apply a small accessibility tweak:
- <button
- class="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-600 dark:hover:text-gray-300 rounded-md cursor-pointer"
- @click.stop="col.onClick ? col.onClick(elem) : () => {}"
- >
+ <button
+ class="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-600 dark:hover:text-gray-300 rounded-md cursor-pointer"
+ :aria-label="col.ariaLabel ?? col.label ?? 'action'"
+ :title="col.ariaLabel ?? col.label ?? 'action'"
+ @click.stop="col.onClick ? col.onClick(elem) : () => {}"
+ >
<component :is="col.icon" />
</button>Run this to spot legacy string icons or v-html:
🏁 Script executed:
#!/bin/bash
# Find any icon props still using string literals (legacy HTML/SVG)
rg -nP -C2 --type=ts --type=tsx --type=vue '\bicon\s*:\s*[\'"]' || true
# Find residual v-html usages
rg -nP -C2 --type=vue 'v-html\s*=' || true
# Quick scan of renderFunction definitions
rg -nP -C1 --type=ts --type=vue '\brenderFunction\s*:\s*\('Length of output: 68
🏁 Script executed:
#!/bin/bash
# Corrected quoting to detect legacy string icons, v-html, and renderFunction usages
# Find any icon props still using string literals (legacy HTML/SVG)
rg -nP -C2 --type=ts --type=tsx --type=vue "\\bicon\\s*:\\s*['\"]" || true
# Find residual v-html usages
rg -nP -C2 --type=vue "v-html\\s*=" || true
# Quick scan of renderFunction definitions
rg -nP -C1 --type=ts --type=vue "\\brenderFunction\\s*:\\s*\\(" || trueLength of output: 83
🏁 Script executed:
#!/bin/bash
# Search for legacy string icons across TS/TSX/Vue files
rg -nP -C2 "\\bicon\\s*:\\s*['\"]" -g '*.ts' -g '*.tsx' -g '*.vue' || true
# Find residual v-html usages in Vue files
rg -nP -C2 "v-html\\s*=" -g '*.vue' || true
# Quick scan of renderFunction definitions in TS and Vue files
rg -nP -C1 "\\brenderFunction\\s*:\\s*\\(" -g '*.ts' -g '*.vue' || trueLength of output: 21531
Ensure icon props use component references and add ARIA labels
- Add accessible labels to icon‐only buttons:
<button …
- :aria-label="col.ariaLabel ?? col.label ?? 'action'"
- :title="col.ariaLabel ?? col.label ?? 'action'"
@click.stop="col.onClick ? col.onClick(elem) : () => {}"
<component :is="col.icon" />
📝 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.
| <button | |
| class="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-600 dark:hover:text-gray-300 rounded-md cursor-pointer" | |
| @click.stop="col.onClick ? col.onClick(elem) : () => {}" | |
| v-html="col.icon" | |
| /> | |
| > | |
| <component :is="col.icon" /> | |
| </button> | |
| <button | |
| class="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-600 dark:hover:text-gray-300 rounded-md cursor-pointer" | |
| :aria-label="col.ariaLabel ?? col.label ?? 'action'" | |
| :title="col.ariaLabel ?? col.label ?? 'action'" | |
| @click.stop="col.onClick ? col.onClick(elem) : () => {}" | |
| > | |
| <component :is="col.icon" /> | |
| </button> |
🤖 Prompt for AI Agents
In src/components/Table.vue around lines 419-424, the icon-only button uses a
dynamic component and a click ternary that returns a no-op function and lacks an
accessible label; update columns so their icon fields are Vue component
references (import the emoji icons as components in the files listed in the
review and replace string literals with those component references), ensure the
button includes an aria-label (e.g., aria-label="col.ariaLabel || col.label ||
'action'") or binds to a computed label on the column, and change the click
handler to invoke the function (e.g., @click.stop="col.onClick ?
col.onClick(elem) : null") so the handler actually runs.
There was a problem hiding this comment.
Actionable comments posted: 0
♻️ Duplicate comments (5)
messages/pt-br.json (1)
20-20: Fix malformed, unsafe HTML anchor and brand typos in account-deletion-restore.Remove spaces around attributes, fix Tailwind class token, add rel="noopener noreferrer", and don’t translate brand/class tokens.
Apply:
- "account-deletion-restore": "Entre em contato com <a href = 'https://support.capgo.app/' target='_blank' class='text-blue-500 hover: text-blue-600'> Suporte ao capto </a> Se você quiser restaurá-lo", + "account-deletion-restore": "Entre em contato com <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>o suporte da Capgo</a> se quiser restaurá-la"messages/tr.json (1)
20-20: Fix malformed, unsafe HTML anchor in account-deletion-restore.Normalize attributes/classes and add rel="noopener noreferrer".
- "account-deletion-restore": "Lütfen <a href = 'https://support.capgo.app/' target='_blank' class='text-blue-500 hover: text-blue-600'> capgo desteği </a> Geri yüklemek istiyorsanız", + "account-deletion-restore": "Lütfen <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>Capgo desteği</a> ile iletişime geçin, hesabınızı geri yüklemek istiyorsanız",messages/ru.json (1)
20-20: Fix malformed, unsafe HTML anchor in account-deletion-restore.Remove attribute spacing, fix class token, add rel, and localize only visible text.
- "account-deletion-restore": "Пожалуйста, свяжитесь с <a href = 'https://support.capgo.app/' target='_blank' class='text-blue-500 hover: text-blue-600'> поддержка Capgo </a> Если вы хотите его восстановить", + "account-deletion-restore": "Пожалуйста, свяжитесь с <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>службой поддержки Capgo</a>, если вы хотите восстановить учётную запись",messages/en.json (2)
20-20: Harden external link (reverse‑tabnabbing).Add rel="noopener noreferrer".
- "account-deletion-restore": "Please contact <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a> if you would like to restore it", + "account-deletion-restore": "Please contact <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a> if you would like to restore it",
182-193: Add rel='noopener noreferrer' to all target='_blank' anchors in this file.Security hardening across support links.
- "cannot-find-unknown-version": "Cannot find unknown version, please contact <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>capgo support</a> and check browser logs", + "cannot-find-unknown-version": "Cannot find unknown version, please contact <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>capgo support</a> and check browser logs", - "cannot-get-apps": "Cannot get apps. Please check the browser console and contact <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>", + "cannot-get-apps": "Cannot get apps. Please check the browser console and contact <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>", - "cannot-invite-user": "Cannot invite user to organization. Please contact <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>", + "cannot-invite-user": "Cannot invite user to organization. Please contact <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>", - "cannot-rescind-invitation": "Cannot rescind invitation, please look in the browser console and report this error to the <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Capgo support team</a>", + "cannot-rescind-invitation": "Cannot rescind invitation, please look in the browser console and report this error to the <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>Capgo support team</a>", - "cannot-transfer-app": "Cannot transfer organization. Please check the browser console and report the error to <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>.", + "cannot-transfer-app": "Cannot transfer organization. Please check the browser console and report the error to <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>.", - "error-message-invitation": "Error happened. Please contact <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>", + "error-message-invitation": "Error happened. Please contact <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>",Also applies to: 342-342
🧹 Nitpick comments (7)
messages/pt-br.json (2)
341-344: Unify error tone and grammar.Prefer consistent impersonal phrasing (“Não é possível …”).
- "error-loading-deletion-date": "Não é possível encontrar a data de exclusão da sua conta", - "error-message-invitation": "Ocorreu um erro. Por favor, entre em contato com o suporte da Capgo.", - "error-no-user-id": "Não consigo encontrar sua conta de usuário", + "error-loading-deletion-date": "Não é possível encontrar a data de exclusão da sua conta", + "error-message-invitation": "Ocorreu um erro. Por favor, entre em contato com o suporte da Capgo.", + "error-no-user-id": "Não é possível encontrar sua conta de usuário",
372-372: Improve translation of “I am sure”.“Tenho certeza” is the idiomatic PT‑BR choice.
- "i-am-sure": "estou certo", + "i-am-sure": "Tenho certeza",messages/tr.json (2)
21-23: Make “timer-in” translatable; avoid concatenation trap.“account-deletion-timer-in” equals the base string and won’t read correctly when a time value is appended. Switch to a parameterized message.
- "account-deletion-timer": "Hesabınız var olmayı bırakacak", - "account-deletion-timer-in": "Hesabınız var olmayı bırakacak", + "account-deletion-timer": "Hesabınız var olmayı bırakacak", + "account-deletion-timer-in": "Hesabınız {time} içinde var olmayı bırakacak"Follow-up: pass {time} from the component instead of string concatenation.
341-344: Prefer neutral/impersonal error voice.Use “bulunamadı” instead of first-person “bulamıyorum”.
- "error-no-user-id": "Kullanıcı hesabınızı bulamıyorum", + "error-no-user-id": "Kullanıcı hesabınız bulunamadı",messages/ru.json (2)
372-372: Gender-inclusive wording.Use “Я уверен(а)” to avoid gendered phrasing.
- "i-am-sure": "я уверен", + "i-am-sure": "я уверен(а)",
522-523: Natural phrasing for “paid-subscriptions-to-cancel”.More idiomatic RU.
- "paid-subscriptions-to-cancel": "Подписки должны быть отменены", + "paid-subscriptions-to-cancel": "Подписки для отмены",messages/en.json (1)
20-20: Prefer rendering links in components, not translations.Consider moving the out of i18n and injecting a link via parameters/slots to avoid XSS and grammar-order issues for RTL/locales.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (14)
messages/de.json(7 hunks)messages/en.json(8 hunks)messages/es.json(7 hunks)messages/fr.json(7 hunks)messages/id.json(7 hunks)messages/it.json(7 hunks)messages/ja.json(7 hunks)messages/ko.json(7 hunks)messages/pl.json(7 hunks)messages/pt-br.json(7 hunks)messages/ru.json(7 hunks)messages/tr.json(7 hunks)messages/vi.json(7 hunks)messages/zh-cn.json(7 hunks)
🚧 Files skipped from review as they are similar to previous changes (10)
- messages/ko.json
- messages/zh-cn.json
- messages/id.json
- messages/it.json
- messages/de.json
- messages/pl.json
- messages/ja.json
- messages/vi.json
- messages/es.json
- messages/fr.json
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Run tests
…zation support - Removed petite-vue-i18n dependency from package.json - Updated imports in all components and pages to use vue-i18n - Adjusted i18n usage in the accountDisabled page to utilize component interpolation - Modified vite.config.mts to reflect the change in i18n module
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
There was a problem hiding this comment.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (30)
src/pages/forgot_password.vue (3)
117-125: Prevent stuck spinner: always reset isLoading in submit()Early returns inside step2() can leave the UI loading forever. Wrap submit() in try/finally so isLoading is cleared regardless of path.
Apply:
async function submit(form: { email: string, password: string, password_confirm: string }) { - isLoading.value = true - if (step.value === 1) { - await step1(form) - } - else if (step.value === 2 && route.hash) { - await step2(form) - } + isLoading.value = true + try { + if (step.value === 1) { + await step1(form) + } + else if (step.value === 2 && route.hash) { + await step2(form) + } + } + finally { + isLoading.value = false + } }
56-58: Handle AAL retrieval errors explicitlyGuard against failures from getAuthenticatorAssuranceLevel() to avoid undefined access on data.
Apply:
- const aal = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() - const { currentLevel, nextLevel } = aal.data! + const aal = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() + if (aal.error) { + setErrors('forgot-password', [aal.error.message], {}) + console.error('Cannot get AAL level', aal.error) + return + } + const { currentLevel, nextLevel } = aal.data!
89-96: Sanitize MFA code to digits and validate lengthreplace(' ', '') removes only the first space. Strip all non-digits and reject short inputs before verify.
Apply:
- const { data: _verify, error: errorVerify } = await supabase.auth.mfa.verify({ + const code = mfaCode.value.replace(/\D/g, '') + if (code.length !== 6) { + toast.error(t('invalid-mfa-code')) + return false + } + const { data: _verify, error: errorVerify } = await supabase.auth.mfa.verify({ factorId: factor.id, challengeId: challenge.id, - code: mfaCode.value.replace(' ', ''), + code, })src/pages/settings/ChangePassword.vue (2)
11-17: Missing import for useDisplayStore causes runtime error
useDisplayStore()is used but not imported.import { useSupabase } from '~/services/supabase' import { useDialogV2Store } from '~/stores/dialogv2' +import { useDisplayStore } from '~/stores/display'
28-46: Form error messages target wrong form id
setErrorsuses 'forgot-password' but the form id is 'change-pass', so errors won’t show.- setErrors('forgot-password', [mfaError.message], {}) + setErrors('change-pass', [mfaError.message], {}) ... - setErrors('forgot-password', ['Cannot find MFA factor'], {}) + setErrors('change-pass', ['Cannot find MFA factor'], {}) ... - setErrors('forgot-password', [errorChallenge.message], {}) + setErrors('change-pass', [errorChallenge.message], {})src/pages/onboarding/set_password.vue (2)
112-127: Nested inside breaks submit behaviorInvalid HTML; only one button should wrap spinner/label.
- <div> - <button type="submit" class="inline-flex items-center justify-center w-full"> - <svg v-if="isLoading" class="inline-block w-5 h-5 mr-3 -ml-1 text-gray-900 align-middle animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> - <circle - class="opacity-25" - cx="12" - cy="12" - r="10" - stroke="currentColor" - stroke-width="4" - /> - <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /> - </svg> - <button v-if="!isLoading" type="submit" class="inline-flex items-center justify-center w-full px-4 py-4 text-base font-semibold text-white transition-all duration-200 border border-transparent rounded-md bg-muted-blue-700 focus:bg-blue-700 hover:bg-blue-700 focus:outline-hidden"> - {{ t('validate') }} - </button> - </button> - </div> + <div> + <button type="submit" class="inline-flex items-center justify-center w-full px-4 py-4 text-base font-semibold text-white transition-all duration-200 border border-transparent rounded-md bg-muted-blue-700 focus:bg-blue-700 hover:bg-blue-700 focus:outline-hidden"> + <svg v-if="isLoading" class="inline-block w-5 h-5 mr-3 -ml-1 text-gray-900 align-middle animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> + <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /> + </svg> + <span v-else>{{ t('validate') }}</span> + </button> + </div>
39-46: Redirect occurs even when password update failsOnly navigate after a successful update.
- if (updateError) - setErrors('set-password', [updateError.message], {}) - else - toast.success(t('changed-password-suc')) - router.push('/onboarding/activation') + if (updateError) { + setErrors('set-password', [updateError.message], {}) + } else { + toast.success(t('changed-password-suc')) + router.push('/onboarding/activation') + }src/pages/settings/organization/index.vue (2)
98-103: Typo in payload breaks email update
emialshould beconst { error } = await supabase.functions.invoke('private/set_org_email', { body: { - emial: form.email, + email: form.email, org_id: orgCopy.gid, }, })
221-223: Typo in template attribute prevents styling
clas→class.- <div clas="dark:text-gray-100"> + <div class="dark:text-gray-100"> {{ t('modify-org-info') }} </div>src/components/tables/ChannelTable.vue (1)
183-196: Fix undefined variable in delete dialog descriptionname is not defined; should use one.name.
Apply this diff:
dialogStore.openDialog({ title: t('alert-confirm-delete'), - description: `${t('alert-not-reverse-message')} ${t('alert-delete-message')} ${name}?`, + description: `${t('alert-not-reverse-message')} ${t('alert-delete-message')} ${one.name}?`, buttons: [src/components/dashboard/AppSetting.vue (3)
2-2: Missing Organization type import (TS error)confirmTransferAppOwnership uses Organization but it isn’t imported.
-import type { OrganizationRole } from '~/stores/organization' +import type { Organization, OrganizationRole } from '~/stores/organization'
277-283: Fix Supabase Storage bucket/path usage.from expects a bucket name, not a path. Current code will target a non-existent bucket like images/org/... and fail. Use bucket 'images' and put the path in the object key.
- const { error } = await supabase.storage - .from(`images/org/${appRef.value?.owner_org.id}/${props.appId}`) - .upload('icon', blob, { - contentType: mimeType, - }) + const { error } = await supabase.storage + .from('images') + .upload(`org/${appRef.value?.owner_org.id}/${props.appId}/icon`, blob, { + contentType: mimeType, + })- const { data: signedURLData } = await supabase - .storage - .from(`images/org/${appRef.value?.owner_org.id}/${props.appId}`) - .getPublicUrl('icon') + const { data: signedURLData } = await supabase + .storage + .from('images') + .getPublicUrl(`org/${appRef.value?.owner_org.id}/${props.appId}/icon`)Also applies to: 289-297
392-400: Fix transfer history lookup (logic/type bug)transferHistory is assigned to the entire app object, not its history array. Access the proper property before length/sort.
- const transferHistory: { transferred_at: string }[] = (appRef.value as any) ?? [] - if (!transferHistory || transferHistory.length === 0) + const transferHistory = ((appRef.value as any)?.transfer_history ?? []) as { transferred_at: string }[] + if (transferHistory.length === 0) returnIf the actual property name differs, adjust accordingly.
src/pages/invitation.vue (1)
30-31: Password policy mismatch (UI vs validation)UI says “at least 12 characters” and “at least two special characters”, but validation enforces 6 and 1. Align these to avoid weak passwords and user confusion.
-const hasMinLength = computed(() => password.value.length >= 6) +const hasMinLength = computed(() => password.value.length >= 12) ... - return symbolCount >= 1 + return symbolCount >= 2Also applies to: 43-44
src/pages/login.vue (2)
223-231: Untrusted “message” from URL hash shown to usersYou render a message taken directly from the URL fragment. If the toast renders HTML, this is an XSS vector. Prefer a whitelist of known messages or sanitize/escape.
- if (message) { + if (message) { + // only allow known messages or escape content + const allowed = new Set(['Email change confirmed', 'Magic link confirmed']) + const safeMessage = allowed.has(message) ? message : t('something-went-wrong-try-again-later') isLoading.value = false return setTimeout(() => { - toast.success(message, { + toast.success(safeMessage, { duration: 7000, }) }, 400) }
186-205: Non-null assertion on mfaFactor can crashIf no verified factor exists,
mfaFactor!.idwill throw. Add a guard.- const mfaFactor = mfaFactors?.all.find(factor => factor.status === 'verified') + const mfaFactor = mfaFactors?.all.find(factor => factor.status === 'verified') + if (!mfaFactor) { + // fallback: continue login or prompt to enroll 2FA + await nextLogin() + return + } ... - const { data: challenge, error: errorChallenge } = await supabase.auth.mfa.challenge({ factorId: mfaFactor!.id }) + const { data: challenge, error: errorChallenge } = await supabase.auth.mfa.challenge({ factorId: mfaFactor.id }) ... - mfaLoginFactor.value = mfaFactor! + mfaLoginFactor.value = mfaFactorsrc/pages/delete_account.vue (1)
95-99: Form error target uses wrong FormKit idErrors are set on 'login-account' but the form id is 'delete-account'. Users won’t see errors.
- setErrors('login-account', [error.message], {}) + setErrors('delete-account', [error.message], {})src/components/LineChartStats.vue (1)
167-186: chartData not reactive to prop changeschartData is initialized once from computed values; subsequent updates to
props.datawon’t reflect unless you rebuild datasets. Make it computed.-const chartData = ref<ChartData<'line'>>({ - labels: monthdays(), - datasets: [{ - label: props.title, - data: accumulateData.value, - borderColor: props.colors[400], - backgroundColor: props.colors[200], - tension: 0.3, - pointRadius: 2, - pointBorderWidth: 0, - }, { - label: t('prediction'), - data: projectionData.value, - borderColor: 'transparent', - backgroundColor: props.colors[200], - tension: 0.9, - pointRadius: 2, - pointBorderWidth: 0, - }], -}) +const chartData = computed<ChartData<'line'>>(() => ({ + labels: monthdays(), + datasets: [{ + label: props.title, + data: accumulateData.value, + borderColor: props.colors[400], + backgroundColor: props.colors[200], + tension: 0.3, + pointRadius: 2, + pointBorderWidth: 0, + }, { + label: t('prediction'), + data: projectionData.value, + borderColor: 'transparent', + backgroundColor: props.colors[200], + tension: 0.9, + pointRadius: 2, + pointBorderWidth: 0, + }], +}))- <Line :data="chartData" height="auto" :options="(chartOptions as any)" :plugins="[inlineAnnotationPlugin]" /> + <Line :data="chartData" height="auto" :options="(chartOptions as any)" :plugins="[inlineAnnotationPlugin]" />Also applies to: 219-221
src/components/tables/BundleTable.vue (2)
226-231: Fix null checks in Supabase filters (.neq with null won’t work)PostgREST requires IS/IS NOT for null. Using .neq(..., null) won’t filter anything.
Apply:
- req = req.eq('deleted', filters.value.deleted) - if (filters.value['external-storage']) - req = req.neq('external_url', null) - if (filters.value.encrypted) - req = req.neq('session_key', null) + req = req.eq('deleted', filters.value.deleted) + if (filters.value['external-storage']) + req = req.not('external_url', 'is', null) + if (filters.value.encrypted) + req = req.not('session_key', 'is', null)
215-224: Harden search: use ilike consistently and escape input to avoid broken OR filter
- Use ilike for case-insensitive name search.
- Escape %, _ and commas in user input before embedding into the OR string; otherwise certain inputs produce invalid filters.
- if (search.value) { + if (search.value) { + const escapeILike = (s: string) => s.replaceAll('%', '\\%').replaceAll('_', '\\_').replaceAll(',', '\\,').replaceAll(')', '\\)') + const q = escapeILike(search.value) if (channelsToSearch && channelsToSearch.length > 0) { // Search by both version name or linked channel - req = req.or(`name.ilike.%${search.value}%,id.in.(${channelsToSearch.join(',')})`) + req = req.or(`name.ilike.%${q}%,id.in.(${channelsToSearch.join(',')})`) } else { // Search by version name only - req = req.like('name', `%${search.value}%`) + req = req.ilike('name', `%${q}%`) } }src/pages/ApiKeys.vue (1)
1-8: Missing Ref type import causes TS errorcolumns uses Ref<TableColumn[]> but Ref isn’t imported.
<script setup lang="ts"> import type { TableColumn } from '~/components/comp_def' import type { Database } from '~/types/supabase.types' import { FormKit } from '@formkit/vue' -import { computed, ref, watch } from 'vue' +import { computed, ref, watch } from 'vue' +import type { Ref } from 'vue' import { useI18n } from 'vue-i18n'vite.config.mts (1)
35-39: Vue I18n plugin include path likely wrong (messages vs locales)You build the locales list from ./messages/, but the unplugin include still points to locales/**. This can prevent bundling of JSON messages.
- VueI18n({ - module: 'vue-i18n', - runtimeOnly: true, - compositionOnly: true, - fullInstall: true, - include: [path.resolve(__dirname, 'locales/**')], - }), + VueI18n({ + module: 'vue-i18n', + runtimeOnly: true, + compositionOnly: true, + fullInstall: true, + include: [path.resolve(__dirname, 'messages/**')], + }),Also applies to: 150-157
src/components/dashboard/StepsBundle.vue (2)
168-175: Supabase realtime options: typo and broken filter
- Property should be filter, not filtr.
- The filter string is malformed (missing column for the second eq). Likely intended to filter by app_id.
Apply:
- { - event: 'INSERT', - schema: 'public', - table: 'app_versions', - filtr: `owner_org=eq.${organizationStore.currentOrganization?.gid}&eq.${props.appId}`, - }, + { + event: 'INSERT', + schema: 'public', + table: 'app_versions', + filter: `owner_org=eq.${organizationStore.currentOrganization?.gid}&app_id=eq.${props.appId}`, + },
136-140: isLoading can stay stuck on early returns in getKeyWhen main.user?.id is falsy or after upsert/recursion paths, isLoading may never reset.
Use try/finally or guard-reset:
async function getKey(retry = true): Promise<void> { isLoading.value = true - if (!main?.user?.id) - return + if (!main?.user?.id) { + isLoading.value = false + return + } const { data, error } = await supabase @@ - isLoading.value = false + isLoading.value = false }Also applies to: 157-158
src/components/dashboard/UsageCard.vue (2)
30-33: Missing import for useOrganizationStoreuseOrganizationStore is used but not imported, causing a runtime error.
import { getDaysInCurrentMonth } from '~/services/date' import { useMainStore } from '~/stores/main' +import { useOrganizationStore } from '~/stores/organization'
139-141: Undefined template identifier 'unit'Template references unit, but only props.unit exists.
- {{ total?.toLocaleString() }} {{ unit }} + {{ total?.toLocaleString() }} {{ props.unit }}src/services/stripe.ts (1)
42-45: Supabase getSession misusegetSession returns an object; current truthy check can pass when there is no session. Destructure properly.
- const session = await supabase.auth.getSession() - if (!session) - return + const { data: { session }, error: sessionErr } = await supabase.auth.getSession() + if (sessionErr || !session) + return @@ - const session = await supabase.auth.getSession() - if (!session) - return + const { data: { session }, error: sessionErr2 } = await supabase.auth.getSession() + if (sessionErr2 || !session) + returnAlso applies to: 79-85
src/pages/settings/Account.vue (1)
441-446: MFA handler checks wrong variable on unenrollChecks error from enroll instead of unregisterError.
- if (error) - console.error('Cannot unregister MFA', unregisterError) + if (unregisterError) + console.error('Cannot unregister MFA', unregisterError) @@ - if (didCancel2) { + if (didCancel2) { // User closed the window, go ahead and unregister mfa const { error: unregisterError } = await supabase.auth.mfa.unenroll({ factorId: data.id }) - if (error) - console.error('Cannot unregister MFA', unregisterError) + if (unregisterError) + console.error('Cannot unregister MFA', unregisterError) }Also applies to: 489-494
src/components/Table.vue (1)
103-110: URL filter params logic drops previously set filters.delete('filter', key) wipes all “filter” params; subsequent appends in the same loop may be lost. Clear once, then append only truthy filters.
- if (props.filters) { - Object.entries(props.filters).forEach(([key, value]) => { - if (value) - params.append('filter', key) - else - params.delete('filter', key) - }) - } + if (props.filters) { + // Reset then append active filters + params.delete('filter') + Object.entries(props.filters).forEach(([key, value]) => { + if (value) + params.append('filter', key) + }) + }src/pages/app/p/[package].channel.[channel].vue (1)
600-602: Untranslated user-facing string
toast.info('ignore-compatibility')will show raw English key/string. Use i18n.- else if (localDependencies.length === 0) { - toast.info('ignore-compatibility') - } + else if (localDependencies.length === 0) { + toast.info(t('ignore-compatibility')) + }
♻️ Duplicate comments (5)
messages/vi.json (1)
20-20: Account restore string now uses a placeholder — fixedThe earlier malformed anchor is gone; {link} placeholder is safer and consistent with other locales. Nice fix.
src/pages/settings/Account.vue (2)
40-104: Make canProceed meaningful; guard RPC resultsmembers may be null/undefined; also canProceed always true even on errors.
async function checkOrganizationImpact() { @@ - const orgsToBeDeleted: string[] = [] - const paidOrgsToBeDeleted: Array<{ name: string, planName: string, orgId: string }> = [] + const orgsToBeDeleted: string[] = [] + const paidOrgsToBeDeleted: Array<{ name: string, planName: string, orgId: string }> = [] + let hadError = false @@ - if (error) { + if (error) { console.error('Error getting org members:', error) + hadError = true continue } @@ - const superAdminCount = members.filter(member => + const list = Array.isArray(members) ? members : [] + const superAdminCount = list.filter(member => member.role === 'super_admin' && !member.is_tmp, ).length @@ - try { + try { const isPaying = await isPayingOrg(org.gid) if (isPaying) { const planNameFromDb = await getCurrentPlanNameOrg(org.gid) @@ - catch (error) { + catch (error) { console.error('Error checking payment status for org:', org.name, error) + hadError = true } } @@ - return { orgsToBeDeleted, paidOrgsToBeDeleted, canProceed: true } + return { orgsToBeDeleted, paidOrgsToBeDeleted, canProceed: !hadError }
179-198: Localize final confirmation content; use new i18n keysFinal dialog uses hardcoded English via Teleport.
dialogStore.openDialog({ title: t('are-u-sure'), - description: '', // We'll use Teleport for custom content + description: '', // Teleport below shows localized content @@ - <Teleport v-if="dialogStore.showDialog && dialogStore.dialogOptions?.title === t('are-u-sure')" to="#dialog-v2-content" defer> - <div class="text-base text-gray-500 dark:text-gray-400"> - <p class="mb-4"> - This action cannot be undone. Your account and all associated data will be permanently deleted. - </p> - <p class="font-medium text-gray-700 dark:text-gray-300"> - Your account will be deleted after 30 days - </p> - </div> - </Teleport> + <Teleport v-if="dialogStore.showDialog && dialogStore.dialogOptions?.title === t('are-u-sure')" to="#dialog-v2-content" defer> + <div class="text-base text-gray-500 dark:text-gray-400"> + <p class="mb-4" v-html="t('account-deletion-final-warning')" /> + <p class="font-medium text-gray-700 dark:text-gray-300"> + {{ t('account-deletion-timer-in') }} 30 {{ t('days') || 'days' }} + </p> + </div> + </Teleport>Optional: fetch exact date via a get_account_removal_date RPC and render it instead of “30”.
Also applies to: 730-739
messages/en.json (1)
182-193: Add rel="noopener noreferrer" to external links opened in new tabs.Prevents reverse‑tabnabbing and is a low-effort hardening. Also keep HTML in i18n minimal/safe.
- "cannot-find-unknown-version": "Cannot find unknown version, please contact <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>capgo support</a> and check browser logs", + "cannot-find-unknown-version": "Cannot find unknown version, please contact <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a> and check browser logs", - "cannot-get-apps": "Cannot get apps. Please check the browser console and contact <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>", + "cannot-get-apps": "Cannot get apps. Please check the browser console and contact <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>", - "cannot-invite-user": "Cannot invite user to organization. Please contact <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>", + "cannot-invite-user": "Cannot invite user to organization. Please contact <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>", - "cannot-rescind-invitation": "Cannot rescind invitation, please look in the browser console and report this error to the <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Capgo support team</a>", + "cannot-rescind-invitation": "Cannot rescind invitation, please look in the browser console and report this error to the <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>Capgo support team</a>", - "cannot-transfer-app": "Cannot transfer organization. Please check the browser console and report the error to <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>.", + "cannot-transfer-app": "Cannot transfer organization. Please check the browser console and report the error to <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>.", - "error-message-invitation": "Error happened. Please contact <a href='https://support.capgo.app/' target='_blank' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>", + "error-message-invitation": "Error happened. Please contact <a href='https://support.capgo.app/' target='_blank' rel='noopener noreferrer' class='text-blue-500 underline hover:text-blue-600'>Capgo support</a>",#!/bin/bash # Find external anchors with target="_blank" missing rel="noopener noreferrer" across locales rg -nP --glob 'messages/*.json' "<a[^>]*target=['\"]_blank['\"][^>]*>" -C0 | rg -nPv "rel=['\"]noopener noreferrer['\"]"Also applies to: 342-342
src/components/Table.vue (1)
423-428: Add ARIA label/title to icon-only button for a11y.Improves screen-reader usability. Also ensure click handler doesn’t create a no-op function unnecessarily.
- <button - class="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-600 dark:hover:text-gray-300 rounded-md cursor-pointer" - @click.stop="col.onClick ? col.onClick(elem) : () => {}" - > + <button + class="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-600 dark:hover:text-gray-300 rounded-md cursor-pointer" + :aria-label="col.ariaLabel ?? col.label ?? 'action'" + :title="col.ariaLabel ?? col.label ?? 'action'" + @click.stop="col.onClick ? col.onClick(elem) : null" + > <component :is="col.icon" /> </button>
🧹 Nitpick comments (56)
src/components/TabSidebar.vue (5)
28-39: Watch only activeTab and avoid redundant navigationsWatching the whole
propsis deep and noisy; alsorouter.pushmay re-push the same path.Apply:
-watch(props, (p) => { - // console.log('activeTab', p.activeTab) - const tab = findTab(p.activeTab) - if (!tab || props.noRoute) - return - if (tab.onClick) { - tab.onClick(p.activeTab) - } - else { - router.push(tab.key) - } -}) +watch(() => props.activeTab, (active) => { + const tab = findTab(active) + if (!tab || props.noRoute) + return + if (tab.onClick) + tab.onClick(active) + else if (router.currentRoute.value.path !== tab.key) + router.push(tab.key) +})
40-42: Guard update emission to prevent churnEmit only when the value actually changes.
Apply:
-watch(router.currentRoute, (p) => { - emit('update:activeTab', p.path) -}) +watch(router.currentRoute, (p) => { + if (p.path !== props.activeTab) + emit('update:activeTab', p.path) +})
43-48: Drop noisy console.log (and consider removing onMounted entirely)Remove debug log; optional: rely on
{ immediate: true }on one of the watches and delete this block.Apply:
onMounted(() => { if (props.activeTab && props.activeTab !== router.currentRoute.value.path) { - console.log('activeTab', props.activeTab) openLink(props.activeTab) } })
60-61: Use stable keys and prefer id from key, not labelIndex keys cause unnecessary re-renders; labels may not be unique and can contain punctuation.
Apply:
- <li v-for="(m, i) in tabs" :key="i" class="mr-0.5 md:mb-0.5 md:mr-0 w-full" @click="openLink(m.key)"> - <button :id="`tab-${m.label}`" class="flex items-center whitespace-nowrap rounded-sm px-2.5 py-2 hover:bg-gray-400 w-full first-letter:uppercase cursor-pointer" :class="{ 'text-blue-600 hover:text-blue-800': isActive(m.key), 'text-slate-400 hover:text-slate-100': !isActive(m.key) }"> + <li v-for="m in tabs" :key="m.key" class="mr-0.5 md:mb-0.5 md:mr-0 w-full" @click="openLink(m.key)"> + <button :id="`tab-${m.key}`" class="flex items-center whitespace-nowrap rounded-sm px-2.5 py-2 hover:bg-gray-400 w-full first-letter:uppercase cursor-pointer" :class="{ 'text-blue-600 hover:text-blue-800': isActive(m.key), 'text-slate-400 hover:text-slate-100': !isActive(m.key) }">
70-70: Class on doesn’t style slotted contentIf styling is intended, wrap the slot.
Apply:
- <slot class="h-full overflow-y-scroll" /> + <div class="h-full overflow-y-scroll"> + <slot /> + </div>src/pages/forgot_password.vue (2)
42-42: De-duplicate loading state management (optional)With submit() guarding isLoading, these lines become redundant.
Apply:
- isLoading.value = falseAlso applies to: 105-105
67-70: Fix misleading log when no verified factor is foundThe console references mfaError even though the error is about an absent verified factor.
Apply:
- setErrors('forgot-password', ['Cannot find MFA factor'], {}) - console.error('Cannot get MFA factors', mfaError) + setErrors('forgot-password', ['Cannot find a verified MFA factor'], {}) + console.error('No verified MFA factor found')src/pages/resend_email.vue (1)
74-76: Localize hard-coded “Submit” labelUse a translation key for consistency with the rest of the page.
- <div v-if="!isLoading" class="inline-flex items-center justify-center w-full px-4 py-4 text-base font-semibold text-white transition-all duration-200 border border-transparent rounded-md bg-muted-blue-700 focus:bg-blue-700 hover:bg-blue-700 focus:outline-hidden"> - Submit - </div> + <div v-if="!isLoading" class="inline-flex items-center justify-center w-full px-4 py-4 text-base font-semibold text-white transition-all duration-200 border border-transparent rounded-md bg-muted-blue-700 focus:bg-blue-700 hover:bg-blue-700 focus:outline-hidden"> + {{ t('submit') }} + </div>src/pages/settings/Notifications.vue (2)
19-34: Avoid manual flip; use the emitted value and only commit on successCurrent handlers invert state locally before the API call and ignore failures. Prefer accepting the new value from Toggle and persisting only when updateUser succeeds.
Apply this diff:
-async function submitNotif() { - isLoading.value = true - enableNotifications.value = !enableNotifications.value +async function submitNotif(newValue: boolean) { + isLoading.value = true const activation = main.auth?.user_metadata?.activation || {} const { data, error } = await supabase.auth.updateUser({ data: { activation: { ...activation, - enableNotifications: enableNotifications.value, + enableNotifications: newValue, }, }, }) - if (!error && data) - main.auth = data.user + if (!error && data) { + enableNotifications.value = newValue + main.auth = data.user + } isLoading.value = false } -async function submitDoi() { - isLoading.value = true - console.log('submitDoi') - - optForNewsletters.value = !optForNewsletters.value +async function submitDoi(newValue: boolean) { + isLoading.value = true const activation = main.auth?.user_metadata?.activation || {} const { data, error } = await supabase.auth.updateUser({ data: { activation: { ...activation, - optForNewsletters: optForNewsletters.value, + optForNewsletters: newValue, }, }, }) - if (!error && data) - main.auth = data.user - console.log('main.auth', data) + if (!error && data) { + optForNewsletters.value = newValue + main.auth = data.user + } isLoading.value = false }And in template:
- <Toggle - :value="enableNotifications" - @change="submitNotif()" - /> + <Toggle :value="enableNotifications" @change="submitNotif($event)" /> ... - <Toggle - :value="optForNewsletters" - @change="submitDoi()" - /> + <Toggle :value="optForNewsletters" @change="submitDoi($event)" />Also applies to: 68-77
37-37: Remove debug logsLeftover console.log calls.
Apply this diff:
- console.log('submitDoi') ... - console.log('main.auth', data)Also applies to: 51-51
src/pages/onboarding/verify_email.vue (2)
44-55: Upsert instead of insert to avoid duplicate-key failuresIf users row already exists (e.g., retries), insert will fail. Use upsert on id.
Apply this diff:
- const { error } = await supabase - .from('users') - .insert( + const { error } = await supabase + .from('users') + .upsert( { id: user.value?.id, first_name: user.value?.user_metadata.first_name, last_name: user.value?.user_metadata.last_name, email: user.value?.email ?? '', image_url: '', - }, - ) + }, + { onConflict: 'id' }, + )
69-75: Avoid repeated delayed calls; trigger once on mountwatchEffect + setTimeout can schedule multiple updateDb calls. Prefer onMounted with a single timeout or watch with immediate: true.
Apply this diff:
-watchEffect(() => { - if (route.path === '/onboarding/verify_email') { - setTimeout(async () => { - await updateDb() - }, 500) - } -}) +onMounted(() => { + if (route.path === '/onboarding/verify_email') + setTimeout(updateDb, 500) +})src/components/tables/LogTable.vue (1)
106-121: Harden external call with timeoutAdd a ky timeout to avoid hanging requests when the stats API is slow.
Apply this diff:
- const dataD = await ky - .post(`${defaultApiHost}/private/stats`, { + const dataD = await ky + .post(`${defaultApiHost}/private/stats`, { headers: { 'Content-Type': 'application/json', 'authorization': `Bearer ${currentJwt ?? ''}`, }, + timeout: 10000, body: JSON.stringify({ appId: props.appId, devicesId: props.deviceId ? [props.deviceId] : undefined, search: search.value ? search.value : undefined, order: columns.value.filter(elem => elem.sortable).map(elem => ({ key: elem.key as string, sortable: elem.sortable })), rangeStart: paginatedRange.value.rangeStart, rangeEnd: paginatedRange.value.rangeEnd, }), })src/components/tables/HistoryTable.vue (2)
33-37: Show actual deployer; select email and/or display nameColumn uses user?.email but the query doesn’t fetch email and the type doesn’t include it.
Apply this diff:
user?: { id: string first_name: string last_name: string + email?: string }{ label: t('deployed-by'), key: 'created_by', mobile: false, - displayFunction: item => item.user?.email || '-', + displayFunction: item => item.user?.email + ?? [item.user?.first_name, item.user?.last_name].filter(Boolean).join(' ') + || '-', },`, ), - user:created_by ( - id, - first_name, - last_name - ) + user:created_by ( + id, + first_name, + last_name, + email + ) `, { count: 'exact' })Also applies to: 125-126, 174-192
279-281: Trigger initial fetchwatch without immediate won’t run on first render. Make it immediate.
Apply this diff:
-watch([() => props.channelId, () => props.appId], () => { - fetchDeployHistory() -}) +watch([() => props.channelId, () => props.appId], () => { + fetchDeployHistory() +}, { immediate: true })src/components/MobileStats.vue (2)
55-61: Remove debug logMinor cleanup.
Apply this diff:
- console.log('loadData mobile data')
85-92: Locale-aware date formattingCurrently uses fixed 'MMMM D, YYYY HH:mm' (English month names). Consider Intl.DateTimeFormat with current locale or dayjs.locale(locale.value).
src/components/tables/ChannelTable.vue (2)
328-331: Watch props explicitly or use immediateWatching the reactive props object can be brittle. Watch specific keys and run immediately.
Apply this diff:
-watch(props, async () => { - await refreshData() -}) +watch([() => props.appId], async () => { + await refreshData() +}, { immediate: true })
339-339: i18n for static labelHardcode 'Filters' -> t('filters') for consistency.
Apply this diff:
- filter-text="Filters" + :filter-text="t('filters')"src/pages/app/p/[package].vue (3)
76-76: Localize hardcoded hover tooltipsThese user-facing strings should go through i18n.
Proposed change:
- hoverLabel: 'Click to explore the channel list', + hoverLabel: t('click-to-explore-channel-list'), - hoverLabel: 'Click to explore the bundle list', + hoverLabel: t('click-to-explore-bundle-list'), - hoverLabel: 'Click to explore the device list', + hoverLabel: t('click-to-explore-device-list'), - hoverLabel: 'Click to explore the logs', + hoverLabel: t('click-to-explore-logs'),I can add these keys across locales if you want.
Also applies to: 82-82, 88-88, 94-94
108-113: Parallelize independent awaits in loadAppInfocapgoVersion, updatesNb, and devicesNb can be fetched concurrently to reduce latency.
- const promises = [] - capgoVersion.value = await getCapgoVersion(id.value, app.value?.last_version) - updatesNb.value = await main.getTotalStatsByApp(id.value, organizationStore.currentOrganization?.subscription_start) - devicesNb.value = await main.getTotalMauByApp(id.value, organizationStore.currentOrganization?.subscription_start) + const promises = [] + const [version, updates, devices] = await Promise.all([ + getCapgoVersion(id.value, app.value?.last_version), + main.getTotalStatsByApp(id.value, organizationStore.currentOrganization?.subscription_start), + main.getTotalMauByApp(id.value, organizationStore.currentOrganization?.subscription_start), + ]) + capgoVersion.value = version + updatesNb.value = updates + devicesNb.value = devices
4-4: Avoid reactive churn when syncing tab to queryUse watch on ActiveTab instead of a broad watchEffect, and import watch.
-import { computed, ref, watchEffect } from 'vue' +import { computed, ref, watch, watchEffect } from 'vue'-watchEffect(() => { - router.replace({ query: { ...route.query, tab: ActiveTab.value } }) -}) +watch(() => ActiveTab.value, (val) => { + router.replace({ query: { ...route.query, tab: val } }) +})Also applies to: 168-170
src/components/dashboard/DropdownProfile.vue (2)
62-62: Drop unnecessary await on useSupabase()useSupabase appears synchronous; awaiting it is redundant.
- const supabase = await useSupabase() + const supabase = useSupabase()
106-114: Use success toast when unspoofingUnspoof is a success path; show a success toast instead of error.
- if (unspoofUser()) { - toast.error('Stop Spoofed, will reload') + if (unspoofUser()) { + toast.success('Stop Spoofed, will reload')Also consider localizing all toast strings in this component.
src/pages/app/modules_test.vue (2)
147-147: Conform to repo code style (no semicolons)Remove the trailing semicolon to align with @antfu/eslint-config.
- toast.success(`runMethod: ${JSON.stringify(m)}`); + toast.success(`runMethod: ${JSON.stringify(m)}`)
147-158: Localize toast messagesTo keep UX consistent with the i18n migration, route these toasts through t().
I can draft keys like run-method, res-method, err-method and wire them here if you want.
src/pages/invitation.vue (1)
175-176: Localize remaining headingsThese headings are plain text; switch to t(...) for consistency.
- <h2 class="mb-3 text-lg font-medium text-center text-gray-700 dark:text-gray-300"> - Organization - </h2> + <h2 class="mb-3 text-lg font-medium text-center text-gray-700 dark:text-gray-300"> + {{ t('organization') }} + </h2>- <h2 class="mb-3 text-lg font-medium text-center text-gray-700 dark:text-gray-300"> - Your login details - </h2> + <h2 class="mb-3 text-lg font-medium text-center text-gray-700 dark:text-gray-300"> + {{ t('your-login-details') }} + </h2>- <h3 class="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300"> - Password Requirements: - </h3> + <h3 class="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300"> + {{ t('password-requirements') }} + </h3>I can add these keys to all locales.
Also applies to: 199-201, 215-216
src/components/dashboard/AppCard.vue (1)
24-26: Rename typo: openSettngs → openSettingsMinor readability nit; rename function and its usages.
-function openSettngs(app: Database['public']['Tables']['apps']['Row']) { +function openSettings(app: Database['public']['Tables']['apps']['Row']) { router.push(`/app/p/${appIdToUrl(app.app_id)}/settings`) } ... - class="flex items-center p-3 mx-auto truncate rounded-lg hover:bg-gray-400 hover:text-white" @click.stop="openSettngs(app)" + class="flex items-center p-3 mx-auto truncate rounded-lg hover:bg-gray-400 hover:text-white" @click.stop="openSettings(app)"Also applies to: 120-123
src/modules/i18n.ts (2)
9-13: Set explicit defaults for locale and fallbackAvoids empty-locale flicker and provides sane fallback.
-export const i18n = createI18n({ - legacy: false, - locale: '', - messages: {}, -}) +export const i18n = createI18n({ + legacy: false, + locale: 'en', + fallbackLocale: 'en', + messages: {}, +})
66-69: Improve locale detection for regional variantsUse full navigator.language, then fall back to base and finally to 'en'.
- let lang = localStorage.getItem('lang') ?? window.navigator.language.split('-')[0] - if (!(lang in languages)) - lang = 'en' + let lang = localStorage.getItem('lang') ?? window.navigator.language + if (!(lang in languages)) { + const base = lang.split('-')[0] + lang = (Object.keys(languages).find(k => k.startsWith(base)) ?? 'en') + }src/pages/delete_account.vue (1)
52-66: Align with staged deletion workflowThis still calls
delete_user(immediate delete). Given the new 30‑day staged deletion, switch to the new RPC that marks the account for deletion and redirect to the disabled page (e.g.,/accountDisabled) with a restoration option.I can draft the exact changes once you confirm the RPC/route names (e.g.,
mark_account_for_deletion,get_account_removal_date).src/pages/settings/organization/Plans.vue (1)
134-135: Remove debug logStray console.log in pricing path.
- getCurrentPlanNameOrg(orgId).then((res) => { - console.log('getCurrentPlanNameOrg', res) + getCurrentPlanNameOrg(orgId).then((res) => {src/components/tables/BundleTable.vue (3)
178-188: Guard empty arrays before .in() to avoid PostgREST errorsWhen dataVersions is empty, .in('id', []) can error. Short-circuit early.
async function enhenceVersionElems(dataVersions: Database['public']['Tables']['app_versions']['Row'][]) { - const { data: dataVersionsMeta } = await supabase + if (!dataVersions?.length) + return [] as Element[] + const { data: dataVersionsMeta } = await supabase
251-258: Same empty-array guard for channel lookupAvoid .in('version', []) calls.
async function fetchChannelsForVersions(versions: Element[]) { - const versionIds = versions.map(v => v.id) + const versionIds = versions.map(v => v.id) + if (!versionIds.length) return
178-178: Nit: fix typo in function nameenhenceVersionElems → enhanceVersionElems.
src/pages/ApiKeys.vue (1)
96-154: Batch fetch orgs/apps instead of N single queriesUse a single .in() per table for uncached IDs to cut round-trips and speed large lists. Happy to draft a patch if desired.
vite.config.mts (1)
150-157: Optional: consider removing include entirely if you load messages at runtimeIf you dynamically load messages (e.g., via HTTP or import()), you can drop include to speed the build.
src/components/dashboard/StepsBundle.vue (1)
41-51: Static i18n strings won’t react to locale changessteps is initialized with t() once; switching language won’t update titles.
Compute steps from a computed(() => [...]) that uses t() so it re-runs on locale changes.
src/pages/app/p/[package].bundle.[bundle].vue (3)
247-252: Localize toast messageLiteral 'ignore-compatibility' should use i18n.
- toast.info('ignore-compatibility') + toast.info(t('ignore-compatibility'))
722-746: Duplicate id attribute 'open-channel'The id is used twice (v-if/v-else), producing duplicate IDs.
Remove the id or make them unique (e.g., open-channel-list / open-channel-set) to avoid DOM conflicts.
325-333: Minor: replace single slash with global replace in filenamereplace('/') only replaces the first occurrence.
- const filename = version.value.r2_path?.replace('/', '_') + const filename = version.value.r2_path?.replaceAll('/', '_')src/services/stripe.ts (2)
20-25: Prevent tabnabbing with window.openHarden new-tab opens by clearing opener.
- handler: async () => { - window.open(url, '_blank') - }, + handler: async () => { + const w = window.open(url, '_blank') + if (w) w.opener = null + },- else - window.open(link, '_blank') + else { + const w = window.open(link, '_blank') + if (w) w.opener = null + }Also applies to: 31-36
69-72: Localize error toastsUse existing i18n error strings.
- else - toast.error('Cannot open your portal') + else + toast.error(t('something-went-wrong-try-again-later'))- toast.error('Cannot get your checkout') + toast.error(t('something-went-wrong-try-again-later'))Also applies to: 100-101
messages/vi.json (1)
518-519: Vietnamese phrasing tweaks for clarity
- “Các tổ chức bị xóa” ⇒ “Các tổ chức sẽ bị xóa”.
- “Đăng ký bị hủy” ⇒ “Đăng ký cần hủy”.
-"organizations-to-be-deleted": "Các tổ chức bị xóa", +"organizations-to-be-deleted": "Các tổ chức sẽ bị xóa", @@ -"paid-subscriptions-to-cancel": "Đăng ký bị hủy", +"paid-subscriptions-to-cancel": "Đăng ký cần hủy",Also applies to: 523-523
src/pages/settings/Account.vue (1)
152-176: Subscription cancellation: surface as explicit TODO or block proceedCurrently continues after warning without performing cancellation.
Consider blocking deletion until cancellations succeed, or add a visible TODO in UI. I can help wire Stripe portal links or backend cancellation hooks.
messages/en.json (2)
20-23: Clarify countdown copy to explicitly say “deleted”.Slightly clearer and consistent with the irreversible deletion language elsewhere.
- "account-deletion-timer": "Your account will cease to exist", - "account-deletion-timer-in": "Your account will cease to exist in", + "account-deletion-timer": "Your account will be permanently deleted", + "account-deletion-timer-in": "Your account will be permanently deleted in",
522-522: US English consistency: “canceled” (1 L).Most EN strings use US spelling; align this key.
- "paid-subscriptions-to-cancel": "Subscriptions to be cancelled", + "paid-subscriptions-to-cancel": "Subscriptions to be canceled",src/components/tables/AppTable.vue (1)
56-73: Use lazy-loading for app icons.Reduce bandwidth and improve list performance; harmless fallback if unsupported.
- ? h('img', { + ? h('img', { src: item.icon_url, alt: `App icon ${item.name}`, class: 'mr-2 rounded-sm shrink-0 sm:mr-3 d-mask d-mask-squircle', width: 42, height: 42, + loading: 'lazy', + decoding: 'async', })src/components/Table.vue (1)
262-265: Watch source should be a getter to track prop changes.watch(props.elementList, …) evaluates once; use a getter for reactivity.
-watch(props.elementList, () => { +watch(() => props.elementList, () => { selectedRows.value = props.elementList.map(_ => false) previousSelectedRow.value = null -}) +}, { deep: false })src/pages/app/p/[package].channel.[channel].vue (7)
65-67: Avoid reactive feedback loop risk when syncing tab to routeUsing
watchEffecthere capturesroute.queryand can retrigger itself. WatchActiveTabdirectly.-watchEffect(() => { - router.replace({ query: { ...route.query, tab: ActiveTab.value } }) -}) +watch(ActiveTab, tab => { + router.replace({ query: { ...route.query, tab } }) +})
185-191: Harden window.open against reverse tabnabbingAdd
noopener,noreferrerin features; keepingopener = nullis fine as a belt-and-suspenders.- const win = window.open(url, '_blank') + const win = window.open(url, '_blank', 'noopener,noreferrer')
581-586: Use i18n interpolation instead of manual .replaceManual string replacement breaks localization. Prefer named placeholders.
- toast.info(t('channel-not-compatible-with-channel-description').replace('%', 'npx @capgo/cli@latest bundle compatibility')) + toast.info(t('channel-not-compatible-with-channel-description', { cmd: 'npx @capgo/cli@latest bundle compatibility' })) ... - description: t('set-even-not-compatible').replace('%', 'npx @capgo/cli@latest bundle compatibility'), + description: t('set-even-not-compatible', { cmd: 'npx @capgo/cli@latest bundle compatibility' }),Ensure the locale messages use something like "… {cmd} …".
519-521: Local reactive state may not update when value is falsySet
disable_auto_updateunconditionally after the update to keep UI in sync.- if (channel.value?.disable_auto_update) - channel.value.disable_auto_update = value + if (channel.value) + channel.value.disable_auto_update = value
463-470: Variable shadowing: localidhides the outer refidRename for clarity.
- const id = await getUnknownVersion() - if (!id) + const unknownVersionId = await getUnknownVersion() + if (!unknownVersionId) return - saveChannelChange('version', id) + saveChannelChange('version', unknownVersionId)
204-205: Remove or downgrade debug logsStray
console.logshould be removed or replace error cases withconsole.error.- console.log(role.value) + // role value available if needed for debugging - console.log('openBundle', channel.value.version.id) - console.log('saveChannelChange', key, val) - console.log('error', iosError ?? hiddenError) + console.error('error', iosError ?? hiddenError)Also applies to: 229-230, 309-309, 391-393
108-121: Platform inference from UUID casing is brittleEqual counts (e.g., mostly digits) forces an error. Consider explicitly asking the user to choose platform when ambiguous.
I can draft a small platform selection dialog if you want.
| <button v-if="pisSelectAllEnabled" class="inline-flex items-center self-end px-3 py-2 ml-auto mr-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-800 hover:bg-gray-100 dark:text-white focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:hover:border-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-700 cursor-pointer" type="button" @click="selectedRows = selectedRows.map(() => true); emit('selectRow', selectedRows)"> | ||
| <span class="text-sm">{{ t('select_all') }}</span> | ||
| </button> | ||
| <button v-if="props.massSelect && selectedRows.find(val => val)" class=" self-end mr-2 inline-flex items-center border border-gray-300 rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-gray-500 dark:border-gray-600 dark:bg-gray-800 hover:bg-gray-100 dark:text-white focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:hover:border-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-700 cursor-pointer" type="button" @click="emit('massDelete')"> | ||
| <button v-if="isSelectAllEnabled" class=" self-end mr-2 inline-flex items-center border border-gray-300 rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-gray-500 dark:border-gray-600 dark:bg-gray-800 hover:bg-gray-100 dark:text-white focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:hover:border-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-700 cursor-pointer" type="button" @click="emit('massDelete')"> | ||
| <IconTrash class="text-red-500 h-[24px]" /> |
There was a problem hiding this comment.
Build failure: typo in template variable (“pisSelectAllEnabled”).
Breaks vue-tsc and hides “Select all” control.
- <button v-if="pisSelectAllEnabled" class="inline-flex items-center self-end px-3 py-2 ml-auto mr-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-800 hover:bg-gray-100 dark:text-white focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:hover:border-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-700 cursor-pointer" type="button" @click="selectedRows = selectedRows.map(() => true); emit('selectRow', selectedRows)">
+ <button v-if="isSelectAllEnabled" class="inline-flex items-center self-end px-3 py-2 ml-auto mr-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-800 hover:bg-gray-100 dark:text-white focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:hover:border-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-700 cursor-pointer" type="button" @click="selectedRows = selectedRows.map(() => true); emit('selectRow', selectedRows)">📝 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.
| <button v-if="pisSelectAllEnabled" class="inline-flex items-center self-end px-3 py-2 ml-auto mr-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-800 hover:bg-gray-100 dark:text-white focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:hover:border-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-700 cursor-pointer" type="button" @click="selectedRows = selectedRows.map(() => true); emit('selectRow', selectedRows)"> | |
| <span class="text-sm">{{ t('select_all') }}</span> | |
| </button> | |
| <button v-if="props.massSelect && selectedRows.find(val => val)" class=" self-end mr-2 inline-flex items-center border border-gray-300 rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-gray-500 dark:border-gray-600 dark:bg-gray-800 hover:bg-gray-100 dark:text-white focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:hover:border-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-700 cursor-pointer" type="button" @click="emit('massDelete')"> | |
| <button v-if="isSelectAllEnabled" class=" self-end mr-2 inline-flex items-center border border-gray-300 rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-gray-500 dark:border-gray-600 dark:bg-gray-800 hover:bg-gray-100 dark:text-white focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:hover:border-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-700 cursor-pointer" type="button" @click="emit('massDelete')"> | |
| <IconTrash class="text-red-500 h-[24px]" /> | |
| <button | |
| v-if="isSelectAllEnabled" | |
| class="inline-flex items-center self-end px-3 py-2 ml-auto mr-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-lg dark:border-gray-600 dark:bg-gray-800 hover:bg-gray-100 dark:text-white focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:hover:border-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-700 cursor-pointer" | |
| type="button" | |
| @click="selectedRows = selectedRows.map(() => true); emit('selectRow', selectedRows)" | |
| > | |
| <span class="text-sm">{{ t('select_all') }}</span> | |
| </button> | |
| <button | |
| v-if="isSelectAllEnabled" | |
| class="self-end mr-2 inline-flex items-center border border-gray-300 rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-gray-500 dark:border-gray-600 dark:bg-gray-800 hover:bg-gray-100 dark:text-white focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:hover:border-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-700 cursor-pointer" | |
| type="button" | |
| @click="emit('massDelete')" | |
| > | |
| <IconTrash class="text-red-500 h-[24px]" /> | |
| </button> |
🧰 Tools
🪛 GitHub Actions: Deploy Preview Environment
[error] 354-354: vue-tsc --noEmit: TS2551: Property 'pisSelectAllEnabled' does not exist on type 'CreateComponentPublicInstanceWithMixins<ToResolvedProps<Props, { reset: (...args: any[]) => void; reload: (...args: any[]) => void; add: (...args: any[]) => void; next: (...args: any[]) => void; prev: (...args: any[]) => void; fastForward: (...args: any[]) => void; fastBackward: (...args: any[]) => void; ... 6 more ...}'. Did you mean 'isSelectAllEnabled'?'
🤖 Prompt for AI Agents
In src/components/Table.vue around lines 354 to 358 the template uses a typo'd
prop/variable name "pisSelectAllEnabled" which causes vue-tsc build failure and
hides the "Select all" button; change "pisSelectAllEnabled" to the correct
"isSelectAllEnabled" (matching the other usage in the template/props) so the
conditional renders properly and rebuilds successfully.
| import { useI18n } from 'vue-i18n' | ||
| import { toast } from 'vue-sonner' |
There was a problem hiding this comment.
Missing import for useRouter causes runtime error
useRouter is used but not imported.
Apply this diff:
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
+import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'Also applies to: 45-46
🤖 Prompt for AI Agents
In src/components/tables/HistoryTable.vue around lines 5-6 (and also affecting
lines 45-46), the runtime error is caused by using useRouter without importing
it; add the missing import for useRouter from 'vue-router' at the top of the
file and ensure any other places that reference useRouter have the proper import
or use the router instance from setup() so the symbol is defined before use.
| import { watch } from 'vue' | ||
| import { useI18n } from 'vue-i18n' | ||
| import { useRouter } from 'vue-router' |
There was a problem hiding this comment.
onMounted is used but not imported
This will break at runtime/build. Import it from vue.
Apply:
-import { watch } from 'vue'
+import { onMounted, watch } from 'vue'📝 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.
| import { watch } from 'vue' | |
| import { useI18n } from 'vue-i18n' | |
| import { useRouter } from 'vue-router' | |
| import { onMounted, watch } from 'vue' | |
| import { useI18n } from 'vue-i18n' | |
| import { useRouter } from 'vue-router' |
🤖 Prompt for AI Agents
In src/components/TabSidebar.vue around lines 3 to 5, onMounted is referenced in
the component but not imported from 'vue', which will cause build/runtime
errors; update the import from 'vue' to include onMounted (e.g., add onMounted
to the existing import alongside watch) so the lifecycle hook is available.
| import { useDebounceFn } from '@vueuse/core' | ||
| import { useI18n } from 'petite-vue-i18n' | ||
| import { ref, watch, watchEffect } from 'vue' | ||
| import { useI18n } from 'vue-i18n' |
There was a problem hiding this comment.
Missing imports for computed/onClickOutside/useTemplateRef cause runtime errors
computed (used at Lines 57, 61), onClickOutside (Line 55), and useTemplateRef (Line 53) are referenced but not imported. Unless you rely on auto-imports, this will break.
Apply this diff:
-import { useDebounceFn } from '@vueuse/core'
-import { ref, watch, watchEffect } from 'vue'
+import { onClickOutside, useDebounceFn } from '@vueuse/core'
+import { computed, ref, watch, watchEffect, useTemplateRef } from 'vue'If you do use auto-imports, consider removing the explicit ref/watch/watchEffect import for consistency.
📝 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.
| import { useDebounceFn } from '@vueuse/core' | |
| import { useI18n } from 'petite-vue-i18n' | |
| import { ref, watch, watchEffect } from 'vue' | |
| import { useI18n } from 'vue-i18n' | |
| import { onClickOutside, useDebounceFn } from '@vueuse/core' | |
| import { computed, ref, watch, watchEffect, useTemplateRef } from 'vue' | |
| import { useI18n } from 'vue-i18n' |
🤖 Prompt for AI Agents
In src/pages/app/p/[package].channel.[channel].vue around lines 6 to 8, the file
references computed (lines ~57/61), onClickOutside (line ~55), and
useTemplateRef (line ~53) but does not import them; update the imports to
include computed from 'vue', onClickOutside from '@vueuse/core' and
useTemplateRef from '@vueuse/core' (or their correct source in your project),
and optionally consolidate/remove explicit ref/watch/watchEffect imports if you
rely on auto-imports so the import list is consistent.
| import { ref } from 'vue' | ||
| import { useI18n } from 'vue-i18n' | ||
| import { useSupabase } from '~/services/supabase' | ||
| import { useMainStore } from '~/stores/main' | ||
|
|
There was a problem hiding this comment.
Import missing: useDisplayStore not imported (runtime error risk)
useDisplayStore is used but not imported here (unlike verify_email.vue). If auto-import isn’t configured for stores, this will crash.
Apply this diff:
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSupabase } from '~/services/supabase'
import { useMainStore } from '~/stores/main'
+import { useDisplayStore } from '~/stores/display'Also applies to: 13-14
🤖 Prompt for AI Agents
In src/pages/settings/Notifications.vue around lines 2 to 6 (and also lines
13-14), the component uses useDisplayStore but does not import it which will
cause a runtime crash if stores aren't auto-imported; add an explicit import for
useDisplayStore from the correct store module (e.g., import { useDisplayStore }
from '~/stores/display') at the top alongside the other imports, and ensure any
references to useDisplayStore in the later lines remain unchanged so they
resolve correctly.
| import { useI18n } from 'vue-i18n' | ||
| import { toast } from 'vue-sonner' |
There was a problem hiding this comment.
Missing import for useRouter causes runtime error
useRouter is referenced but not imported.
Apply this diff:
import { defineExpose, defineProps, ref } from 'vue'
import { useI18n } from 'vue-i18n'
+import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'Also applies to: 12-13
🤖 Prompt for AI Agents
In src/pages/settings/organization/DeleteOrgDialog.vue around lines 4-5 (and
also in the nearby import block at lines 12-13), the composition API function
useRouter is referenced but not imported; add an import for useRouter from
'vue-router' to the imports list so the runtime reference resolves, and update
any related import ordering or grouping as needed.
| import { computed, onMounted, ref } from 'vue' | ||
| import { useI18n } from 'vue-i18n' | ||
| import { toast } from 'vue-sonner' |
There was a problem hiding this comment.
Missing imports: watch/useDisplayStore cause runtime errors
watch() and useDisplayStore() are used but not imported.
-import { computed, onMounted, ref } from 'vue'
+import { computed, onMounted, ref, watch } from 'vue'
+import { useDisplayStore } from '~/stores/display'📝 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.
| import { computed, onMounted, ref } from 'vue' | |
| import { useI18n } from 'vue-i18n' | |
| import { toast } from 'vue-sonner' | |
| <script setup lang='ts'> | |
| import { computed, onMounted, ref, watch } from 'vue' | |
| import { useDisplayStore } from '~/stores/display' | |
| import { useI18n } from 'vue-i18n' | |
| import { toast } from 'vue-sonner' | |
| // …rest of your setup code… | |
| </script> |
🤖 Prompt for AI Agents
In src/pages/settings/organization/index.vue lines 5-7, the file imports
computed, onMounted, ref but not watch and also references useDisplayStore
without importing it; add watch to the Vue import (e.g., import { computed,
onMounted, ref, watch } from 'vue') and import useDisplayStore from the display
store module (e.g., import { useDisplayStore } from '@/stores/display' or the
correct relative path to your store) so both symbols are available at runtime.
|
🧹 Preview Environment Cleaned Up The preview environment for this PR has been successfully removed from Cloudflare. Deleted:
|



Summary by CodeRabbit
New Features
Localization
UX Improvements
Documentation
Tests