Admin credit grant UI and backend#1481
Conversation
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. 📝 WalkthroughWalkthroughThis PR introduces a complete admin credits management system. It adds translation keys across 17 languages, a new admin dashboard page for granting credits to organizations, backend APIs for credit operations (grant, search, balance, history), and comprehensive tests for the new endpoints. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Admin Client
participant Frontend as Vue Component
participant Cloudflare as Cloudflare Worker
participant Supabase as Supabase Function
participant DB as Database
Client->>Frontend: Search for organization
Frontend->>Cloudflare: GET /private/admin_credits/search-orgs?q=...
Cloudflare->>Supabase: Forward request + validate token
Supabase->>Supabase: verifyAdmin() - check is_admin RLC
alt Admin verified
Supabase->>DB: Query orgs (name/email/id match)
DB-->>Supabase: Return matching orgs
Supabase-->>Cloudflare: Return org list
Cloudflare-->>Frontend: org search results
Frontend-->>Client: Display dropdown
Client->>Frontend: Select organization
Frontend->>Cloudflare: GET /private/admin_credits/org-balance/:orgId
Cloudflare->>Supabase: Forward request
Supabase->>DB: Query usage_credit_balances
DB-->>Supabase: Return balance
Supabase-->>Cloudflare: balance response
Cloudflare-->>Frontend: Current balance
Frontend-->>Client: Display balance
Client->>Frontend: Submit grant form (amount, expiry, notes)
Frontend->>Cloudflare: POST /private/admin_credits/grant
Cloudflare->>Supabase: Forward request + body
Supabase->>Supabase: Validate request (amount, org_id)
alt Validation passed
Supabase->>DB: Call top_up_usage_credits RPC
DB-->>Supabase: Grant created
Supabase-->>Cloudflare: Success response
Cloudflare-->>Frontend: Grant confirmation
Frontend-->>Client: Success toast
else Validation failed
Supabase-->>Cloudflare: Error (400)
Cloudflare-->>Frontend: Error message
Frontend-->>Client: Error toast
end
else Not admin
Supabase-->>Cloudflare: 400 not_admin
Cloudflare-->>Frontend: Unauthorized
Frontend-->>Client: Error feedback
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes The changes span multiple domains (backend, frontend, translations, routing) with new complex logic in the Vue component and Supabase handler, but homogeneous repetition across 17 translation files reduces overall cognitive load. The backend implementation is focused and test-covered, and the frontend follows established patterns. Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. 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.
Pull request overview
This PR adds a secure admin interface for granting credits to customer organizations. The feature includes backend API endpoints for searching organizations, viewing balances, granting credits, and viewing grant history, along with a Vue frontend component that provides the admin UI. All operations verify admin status and log admin user IDs for audit trails.
Changes:
- Added 4 new backend endpoints under
/private/admin_credits/with admin authentication - Created Vue component with organization search, credit granting form, and grant history table
- Added translations for admin credits UI in all 15 supported languages
- Integrated new credits tab into admin dashboard navigation
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
supabase/functions/_backend/private/admin_credits.ts |
New backend module with 4 admin-only endpoints: grant credits, search orgs, get balance, view grant history |
supabase/functions/private/index.ts |
Routes new admin_credits backend module for Supabase functions |
cloudflare_workers/api/index.ts |
Routes new admin_credits backend module for Cloudflare Workers |
src/pages/admin/dashboard/credits.vue |
New admin UI page with org search, credit grant form, balance display, and grant history table |
src/constants/adminTabs.ts |
Adds credits tab to admin dashboard navigation |
messages/*.json (15 files) |
Translation strings for admin credits UI in all supported languages |
| orgBalance.value = result.balance ?? null | ||
| } | ||
| catch (error) { | ||
| console.error('Balance load error:', error) |
There was a problem hiding this comment.
The error handling only logs to console but doesn't provide user feedback via toast for failed balance loads. Consider adding a toast.error() call when balance fetch fails to inform users of the error.
| console.error('Balance load error:', error) | |
| console.error('Balance load error:', error) | |
| toast.error(t('admin-credits-balance-error')) |
| function handleSearchInput() { | ||
| if (searchDebounce) | ||
| clearTimeout(searchDebounce) | ||
|
|
||
| searchDebounce = setTimeout(() => { | ||
| searchOrgs(searchQuery.value) | ||
| }, 300) | ||
| } |
There was a problem hiding this comment.
The debounce timeout is not cleared when the component unmounts. This can lead to memory leaks if the component is destroyed before the timeout executes. Add cleanup in onUnmounted to clear the timeout.
| async function searchOrgs(query: string) { | ||
| if (query.length < 2) { | ||
| searchResults.value = [] | ||
| return | ||
| } | ||
|
|
||
| isSearching.value = true | ||
|
|
||
| try { | ||
| const { data } = await supabase.auth.getSession() | ||
| const response = await fetch(`${defaultApiHost}/private/admin_credits/search-orgs?q=${encodeURIComponent(query)}`, { | ||
| headers: { | ||
| authorization: `Bearer ${data.session?.access_token}`, | ||
| }, | ||
| }) | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error('Search failed') | ||
| } | ||
|
|
||
| const result = await response.json() as { orgs?: OrgSearchResult[] } | ||
| searchResults.value = result.orgs || [] | ||
| } | ||
| catch (error) { | ||
| console.error('Search error:', error) | ||
| searchResults.value = [] | ||
| } | ||
| finally { | ||
| isSearching.value = false | ||
| } | ||
| } |
There was a problem hiding this comment.
There's a potential race condition where if a user types quickly and changes the search query, multiple concurrent search requests may be in flight. The last request to complete will set the results, which may not correspond to the current search query. Consider tracking a request ID or canceling previous requests.
| org_id: z.string().check(z.minLength(1)), | ||
| amount: z.number().check(z.minimum(1)), | ||
| notes: z.optional(z.string().check(z.minLength(1))), |
There was a problem hiding this comment.
The schema validation uses check() which is not a standard Zod method. The correct Zod method is min(). This should be z.string().min(1) instead of z.string().check(z.minLength(1)).
| org_id: z.string().check(z.minLength(1)), | |
| amount: z.number().check(z.minimum(1)), | |
| notes: z.optional(z.string().check(z.minLength(1))), | |
| org_id: z.string().min(1), | |
| amount: z.number().min(1), | |
| notes: z.optional(z.string().min(1)), |
| org_id: z.string().check(z.minLength(1)), | ||
| amount: z.number().check(z.minimum(1)), | ||
| notes: z.optional(z.string().check(z.minLength(1))), |
There was a problem hiding this comment.
The schema validation uses check() which is not a standard Zod method. The correct Zod method is min(). This should be z.number().min(1) instead of z.number().check(z.minimum(1)).
| org_id: z.string().check(z.minLength(1)), | |
| amount: z.number().check(z.minimum(1)), | |
| notes: z.optional(z.string().check(z.minLength(1))), | |
| org_id: z.string().min(1), | |
| amount: z.number().min(1), | |
| notes: z.optional(z.string().min(1)), |
| recentGrants.value = result.grants || [] | ||
| } | ||
| catch (error) { | ||
| console.error('Grants load error:', error) |
There was a problem hiding this comment.
The error handling only logs to console but doesn't provide user feedback via toast for failed grant history loads. Consider adding a toast.error() call when grant history fetch fails to inform users of the error.
| console.error('Grants load error:', error) | |
| console.error('Grants load error:', error) | |
| toast.error(t('admin-credits-grants-load-error')) |
| org_id: z.string().check(z.minLength(1)), | ||
| amount: z.number().check(z.minimum(1)), | ||
| notes: z.optional(z.string().check(z.minLength(1))), |
There was a problem hiding this comment.
The schema validation uses check() which is not a standard Zod method. The correct Zod method is min(). This should be z.optional(z.string().min(1)) instead of z.optional(z.string().check(z.minLength(1))).
| org_id: z.string().check(z.minLength(1)), | |
| amount: z.number().check(z.minimum(1)), | |
| notes: z.optional(z.string().check(z.minLength(1))), | |
| org_id: z.string().min(1), | |
| amount: z.number().min(1), | |
| notes: z.optional(z.string().min(1)), |
| p_notes: notes || `Admin grant by ${userId}`, | ||
| p_source_ref: sourceRef, | ||
| }) | ||
| .single() |
There was a problem hiding this comment.
The RPC call uses .single() but top_up_usage_credits is an RPC function that returns a single value by default. The .single() modifier is typically used with select queries, not RPC calls. This may cause an error. Remove .single() from this RPC call.
| .single() |
| const adminSupabase = supabaseAdmin(c) | ||
|
|
||
| // Search by name, email, or exact ID match | ||
| const { data: orgs, error } = await adminSupabase | ||
| .from('orgs') | ||
| .select('id, name, management_email, created_at') | ||
| .or(`name.ilike.%${searchTerm}%,management_email.ilike.%${searchTerm}%,id.eq.${searchTerm}`) |
There was a problem hiding this comment.
The SQL query uses string interpolation for the search term which can cause SQL injection vulnerabilities. The .or() method in Supabase should use parameterized queries. Consider using .or() with proper escaping or use separate .or() clauses with .ilike() filters.
| const adminSupabase = supabaseAdmin(c) | |
| // Search by name, email, or exact ID match | |
| const { data: orgs, error } = await adminSupabase | |
| .from('orgs') | |
| .select('id, name, management_email, created_at') | |
| .or(`name.ilike.%${searchTerm}%,management_email.ilike.%${searchTerm}%,id.eq.${searchTerm}`) | |
| // Escape special characters to avoid breaking the PostgREST filter grammar | |
| const sanitizedSearchTerm = searchTerm.replace(/[%_,]/g, (c) => '\\' + c) | |
| const ilikePattern = `%${sanitizedSearchTerm}%` | |
| const adminSupabase = supabaseAdmin(c) | |
| // Search by name, email, or exact ID match | |
| const { data: orgs, error } = await adminSupabase | |
| .from('orgs') | |
| .select('id, name, management_email, created_at') | |
| .or(`name.ilike.${ilikePattern},management_email.ilike.${ilikePattern},id.eq.${sanitizedSearchTerm}`) |
| searchResults.value = result.orgs || [] | ||
| } | ||
| catch (error) { | ||
| console.error('Search error:', error) |
There was a problem hiding this comment.
The error handling only logs to console but doesn't provide user feedback via toast for failed searches. Consider adding a toast.error() call when search fails to inform users of the error.
| console.error('Search error:', error) | |
| console.error('Search error:', error) | |
| toast.error('Failed to search organizations. Please try again.') |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 09652f86aa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const { data: orgs, error } = await adminSupabase | ||
| .from('orgs') | ||
| .select('id, name, management_email, created_at') | ||
| .or(`name.ilike.%${searchTerm}%,management_email.ilike.%${searchTerm}%,id.eq.${searchTerm}`) |
There was a problem hiding this comment.
Guard UUID search filter to avoid 400s
The OR filter always includes id.eq.${searchTerm} even though orgs.id is a UUID column. For typical name/email searches (e.g., “acme” or “billing@…”), PostgREST will reject the filter with an invalid UUID error, so the endpoint returns search_failed and the UI shows no results unless the query happens to be a valid UUID. Consider only adding the id.eq clause when searchTerm validates as a UUID (or cast the ID to text before comparing) so non-UUID searches still work.
Useful? React with 👍 / 👎.
| const expiresAt = computed(() => { | ||
| if (expiresInMonths.value <= 0) | ||
| return null | ||
| return dayjs().add(expiresInMonths.value, 'month').toISOString() |
There was a problem hiding this comment.
Recompute expiration timestamp per grant
expiresAt is a cached computed value that only recalculates when expiresInMonths changes. If an admin grants credits multiple times with the same month value, the later grants reuse the original timestamp from the first grant, so their expiration is earlier by the elapsed time. Computing the timestamp inside grantCredits (or using a non-cached getter) would ensure each grant’s expiration is based on the actual grant time.
Useful? React with 👍 / 👎.
23aae77 to
f9fedda
Compare
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Fix all issues with AI agents
In `@messages/fr.json`:
- Around line 507-533: Convert the French admin-credits strings from Title Case
to sentence case and fix agreement for “notes”: update keys such as
"admin-credits", "admin-credits-title", "admin-credits-description",
"admin-credits-grant-title", "admin-credits-select-org",
"admin-credits-search-placeholder", "admin-credits-current-balance",
"admin-credits-no-balance", "admin-credits-expires",
"admin-credits-amount-label", "admin-credits-amount-required",
"admin-credits-expires-months", "admin-credits-notes-label",
"admin-credits-notes-placeholder", "admin-credits-grant-button",
"admin-credits-grant-success", "admin-credits-grant-error",
"admin-credits-search-error", "admin-credits-balance-error",
"admin-credits-grants-load-error", "admin-credits-recent-grants",
"admin-credits-no-grants", "admin-credits-col-org", "admin-credits-col-amount",
"admin-credits-col-notes", "admin-credits-col-date", and
"admin-credits-col-expires" to sentence case (e.g., "Accorder des crédits" ->
"Accorder des crédits" remains but others like "Solde Actuel" -> "Solde
actuel"), and change "Notes (optionnel)" to agree with plural feminine as "Notes
(optionnelles)". Ensure punctuation and placeholders ({amount}, {org}) are
preserved.
In `@messages/ru.json`:
- Around line 507-533: The RU translations for the admin credits UI use Title
Case but the project prefers sentence case; update the values for the keys
admin-credits, admin-credits-title, admin-credits-description,
admin-credits-grant-title, admin-credits-select-org,
admin-credits-search-placeholder, admin-credits-current-balance,
admin-credits-no-balance, admin-credits-expires, admin-credits-amount-label,
admin-credits-amount-required, admin-credits-expires-months,
admin-credits-notes-label, admin-credits-notes-placeholder,
admin-credits-grant-button, admin-credits-grant-success,
admin-credits-grant-error, admin-credits-search-error,
admin-credits-balance-error, admin-credits-grants-load-error,
admin-credits-recent-grants, admin-credits-no-grants, admin-credits-col-org,
admin-credits-col-amount, admin-credits-col-notes, admin-credits-col-date and
admin-credits-col-expires to use sentence case (lowercase internal nouns and
only capitalize where grammatically required) so they match the rest of the RU
file and read naturally in the UI.
In `@messages/tr.json`:
- Around line 507-533: Update the "admin-credits-expires" translation value to
include the noun "tarihi" so it reads consistently with other keys (e.g.,
"credit-transaction-expiry", "expiration-date"); locate the
"admin-credits-expires" entry in the messages/tr.json diff and change its value
from "Sonraki son kullanma" to a phrasing that includes "tarihi" (e.g., "Sonraki
son kullanma tarihi").
In `@supabase/functions/_backend/private/admin_credits.ts`:
- Around line 3-4: The file imports Hono directly; per guidelines replace direct
Hono usage with the app factory createHono from utils/hono.ts: remove the direct
import of Hono and import createHono instead, then initialize the app using
createHono() wherever new Hono() is currently used (refer to the Hono import and
any initialization around line 48), updating routes to use the returned app
instance; ensure the zod import remains unchanged.
🧹 Nitpick comments (3)
messages/pt-br.json (1)
507-533: Consider aligning “Admin” wording with the existing “administrador” terms.Several new strings use “Admin” (e.g., “Créditos de Admin”, “Concessões Recentes de Admin”), while the file already uses “administrador” elsewhere. A consistent form (e.g., “Créditos do administrador”, “Concessões recentes do administrador”) would read more naturally and avoid mixed terminology.
tests/admin-credits.test.ts (1)
41-305: Consider usingit.concurrent()for test parallelism.Per coding guidelines, tests should use
it.concurrent()to maximize parallelism. Since these tests only read from the test org (none modify it), they could run concurrently within each describe block.♻️ Example refactor for one describe block
describe('[POST] /private/admin_credits/grant - Admin Access Control', () => { - it('should return 401 when no authorization header is provided', async () => { + it.concurrent('should return 401 when no authorization header is provided', async () => { // ... }) - it('should return 400 not_admin when non-admin user tries to grant credits', async () => { + it.concurrent('should return 400 not_admin when non-admin user tries to grant credits', async () => { // ... }) // ... apply to other tests })supabase/functions/_backend/private/admin_credits.ts (1)
211-251: Consider validating orgId as UUID before querying.The endpoint accepts any string as orgId (line 224). If an invalid UUID is passed, the database query will fail. Consider adding UUID validation similar to the search-orgs endpoint.
♻️ Proposed validation
const orgId = c.req.param('orgId') if (!orgId) { throw simpleError('missing_org_id', 'Organization ID is required') } + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + if (!uuidRegex.test(orgId)) { + throw simpleError('invalid_org_id', 'Invalid organization ID format') + } + const adminSupabase = supabaseAdmin(c)
af77e3c to
a28fb9e
Compare
Add secure admin interface to grant credits to customer organizations with full audit trail and multi-language support. Includes organization search, balance display, grant form with optional expiration, and recent grants history. Backend verifies admin privileges and logs all actions with admin user ID. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Add onUnmounted cleanup for debounce timeout (prevents memory leaks) - Track search query to avoid race conditions with concurrent requests - Add toast.error() for balance, grants, and search load failures - Compute expiresAt freshly per grant (fixes stale timestamp bug) - Use parsedBodyResult.data instead of raw body after validation - Remove unnecessary .single() from RPC call - Sanitize search term and only add UUID filter when valid UUID - Add 3 new error translation keys to all 15 language files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Verify that only admin users can access admin_credits endpoints: - POST /grant - Grant credits to an organization - GET /search-orgs - Search organizations - GET /org-balance/:orgId - Get organization credit balance - GET /grants-history - View admin grant history Tests verify: - Requests without auth are rejected (401) - Non-admin users get not_admin error (400) - Invalid inputs are properly validated - SQL injection patterns are blocked by admin check Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Auto-generated route types for the new admin credits page. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Use sentence case in French translations - Use sentence case in Russian translations - Add "tarihi" to Turkish admin-credits-expires for consistency - Use createHono from utils instead of importing Hono directly Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
a28fb9e to
e58b8e4
Compare
|



Summary
Add secure admin interface to grant credits to customer organizations with full audit trail and multi-language support. Admins can search organizations, view credit balances, grant credits with optional expiration, and view recent grant history. All operations are verified with admin authentication and logged with the admin user ID.
Test plan
Checklist
bun run lintandbun typechecktop_up_usage_creditsRPC with admin verificationmainStore.isAdmincheck🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
New Features
UI Updates
✏️ Tip: You can customize this high-level summary in your review settings.