feat(frontend): create RBAC API keys from apikeys page#2332
Conversation
📝 WalkthroughWalkthroughMakes API key listing and creation RBAC-aware: new role/binding types and fetchers, RBAC-driven add-key modal (org role + optional per-app bindings), table role column and scope display from bindings, adds three i18n keys, and broadens DialogV2 size options. ChangesRBAC-Aware API Key Management
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
Merging this PR will not alter performance
Comparing Footnotes
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/pages/ApiKeys.vue`:
- Around line 349-363: The title attribute is built from unescaped org names in
displayFunction (uses getHighestOrgRole, getRolesByOrg, getRoleDisplayName and
tooltipText) which allows injection if a name contains quotes; fix by escaping
attribute values or, better, by creating the span via DOM APIs and assigning the
tooltip via element.title (or setAttribute('title', ...)) and using textContent
for the visible label so values are not interpolated into HTML strings—ensure
tooltipText is properly escaped when you must build HTML strings.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: b09456b0-f518-4ee6-bf4c-5198995f5b11
📒 Files selected for processing (2)
messages/en.jsonsrc/pages/ApiKeys.vue
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@messages/en.json`:
- Line 1740: The i18n key "select-multiple-organizations-help" is unused and
misleading for the checkbox-based org selector; either remove this key from
messages/en.json, or update and wire it into the UI: modify ApiKeys.vue to
display a localized help string (use $t("select-multiple-organizations-help") or
equivalent) near the checkbox list and update the message text to describe
checkbox toggling (e.g., "Click checkboxes to select organizations") and ensure
toggleOrgSelection(...) remains the change handler for each checkbox; choose one
approach and remove the dead key if you opt to wire nothing.
In `@src/pages/ApiKeys.vue`:
- Around line 1084-1095: The dropdown trigger button lacks an aria-expanded
attribute; update the button element that toggles showOrgDropdown (the one using
`@click`="showOrgDropdown = !showOrgDropdown" and displaying
selectedOrgNamesForCreation/selectedOrgsForCreation) to include a bound
aria-expanded reflecting the open state (e.g., :aria-expanded="showOrgDropdown")
so assistive tech can read the dropdown state.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: f5ccc0ee-2cfa-41d1-962e-3679be94bc6b
📒 Files selected for processing (4)
messages/en.jsonsrc/components/DialogV2.vuesrc/pages/ApiKeys.vuesrc/stores/dialogv2.ts
| "select-expiration-date": "Select expiration date", | ||
| "select-group": "Select a group", | ||
| "select-member": "Select member", | ||
| "select-multiple-organizations-help": "Use Cmd/Ctrl or Shift to select multiple organizations.", |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check if this i18n key is actually used anywhere
rg -n "select-multiple-organizations-help" --type vue --type ts --type js -g '!messages/*.json'Repository: Cap-go/capgo
Length of output: 84
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== 1) Find i18n key usage in code (exclude messages json) =="
rg -nF "select-multiple-organizations-help" -g'!messages/**/*.json' .
echo "== 2) Locate ApiKeys.vue =="
fd -a "ApiKeys.vue" . || true
echo "== 3) Search ApiKeys.vue for org selector/help i18n =="
# If ApiKeys.vue exists, search it for nearby keys/strings
api_file="$(fd -a "ApiKeys.vue" . | head -n 1 || true)"
if [ -n "${api_file:-}" ]; then
echo "-- using $api_file"
rg -n "organizations|organization|select-multiple|multiple-organizations|help" "$api_file" || true
else
echo "ApiKeys.vue not found"
fiRepository: Cap-go/capgo
Length of output: 117
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== 1) Find i18n key usage in code (exclude messages json) =="
rg -nF "select-multiple-organizations-help" -g'!messages/**/*.json' .
echo "== 2) Locate ApiKeys.vue =="
fd -a "ApiKeys.vue" . || true
echo "== 3) Search ApiKeys.vue for org selector/help i18n =="
# If ApiKeys.vue exists, search it for nearby keys/strings
api_file="$(fd -a "ApiKeys.vue" . | head -n 1 || true)"
if [ -n "${api_file:-}" ]; then
echo "-- using $api_file"
rg -n "organizations|organization|select-multiple|multiple-organizations|help" "$api_file" || true
else
echo "ApiKeys.vue not found"
fiRepository: Cap-go/capgo
Length of output: 117
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== 1) Find i18n key usage in code (exclude messages json) =="
rg -nF "select-multiple-organizations-help" -g'!messages/**/*.json' . || true
echo "== 2) Locate ApiKeys.vue =="
fd -a "ApiKeys.vue" . || true
echo "== 3) Inspect organization selector/help in ApiKeys.vue =="
api_file="$(fd -a "ApiKeys.vue" . | head -n 1 || true)"
if [ -n "${api_file:-}" ]; then
echo "-- using $api_file"
sed -n '1,200p' "$api_file" | cat -n
echo "--- grep nearby i18n/help text ---"
rg -n "select-multiple-organizations-help|organizations|organization|multiple-organizations|help" "$api_file" || true
else
echo "ApiKeys.vue not found"
fiRepository: Cap-go/capgo
Length of output: 9702
🏁 Script executed:
#!/bin/bash
set -euo pipefail
files="$(fd -a "ApiKeys.vue" src/pages | tr '\n' ' ')"
echo "ApiKeys.vue files: $files"
echo "== 1) Directly search for the i18n key in those files =="
for f in $(echo "$files"); do
echo "-- $f"
rg -nF "select-multiple-organizations-help" "$f" || true
done
echo "== 2) Search for keyboard modifier strings in those files =="
for f in $(echo "$files"); do
echo "-- $f"
rg -n "Cmd|Ctrl|Shift" "$f" || true
done
echo "== 3) Search for checkbox selection UI in those files =="
for f in $(echo "$files"); do
echo "-- $f"
rg -n "checkbox|FormKit.*checkbox|type=\"checkbox\"|v-model=.*selectedOrgs|selectedOrgsForCreation|multiple" "$f" || true
done
echo "== 4) Dump the template section around org selection in src/pages/ApiKeys.vue =="
main_file="src/pages/ApiKeys.vue"
if [ -f "$main_file" ]; then
# Find first occurrence of selectedOrgsForCreation in template and print a window
line="$(rg -n "selectedOrgsForCreation" "$main_file" | head -n 1 | cut -d: -f1 || true)"
echo "selectedOrgsForCreation first line: $line"
if [ -n "${line:-}" ]; then
start=$((line-80))
end=$((line+160))
sed -n "${start},${end}p" "$main_file" | cat -n
fi
fi
echo "== 5) Dump the template section around org selection in src/pages/settings/organization/ApiKeys.vue =="
settings_file="src/pages/settings/organization/ApiKeys.vue"
if [ -f "$settings_file" ]; then
line="$(rg -n "selectedOrgsForCreation|organizationStore\.organizations|select-organization|organizations" "$settings_file" | head -n 1 | cut -d: -f1 || true)"
echo "org-related first line: $line"
if [ -n "${line:-}" ]; then
start=$((line-80))
end=$((line+220))
sed -n "${start},${end}p" "$settings_file" | cat -n
fi
fiRepository: Cap-go/capgo
Length of output: 4202
🏁 Script executed:
#!/bin/bash
set -euo pipefail
KEY="select-multiple-organizations-help"
echo "== 1) Non-messages usage of i18n key ($KEY) =="
if rg -nF "$KEY" -g'!messages/**/*.json' . ; then
echo "matches found"
else
echo "NO matches found (outside messages/**/*.json)"
fi
echo
echo "== 2) Where Cmd/Ctrl/Shift appears in i18n strings =="
rg -n "Cmd|Ctrl|Shift" messages/en.json || true
echo
echo "== 3) Find any *help* i18n key usage in src/pages/ApiKeys.vue =="
rg -n "t\\(.*help|help\\b|tooltip" src/pages/ApiKeys.vue || true
echo
echo "== 4) Inspect checkbox-based org selector block in src/pages/ApiKeys.vue =="
# Print a safe window around the first checkbox for org selection
line="$(rg -n 'type="checkbox"' src/pages/ApiKeys.vue | head -n 1 | cut -d: -f1 || true)"
echo "first checkbox line: ${line:-none}"
if [ -n "${line:-}" ]; then
start=$(( line-60 )); [ "$start" -lt 1 ] && start=1
end=$(( line+140 ))
sed -n "${start},${end}p" src/pages/ApiKeys.vue | cat -n
fi
echo
echo "== 5) Check whether the template references select-multiple* i18n keys =="
rg -n "select-multiple" src/pages/ApiKeys.vue || trueRepository: Cap-go/capgo
Length of output: 12547
select-multiple-organizations-help appears unused and the text doesn’t match the org selector UI.
- The i18n key
select-multiple-organizations-helponly exists inmessages/en.json(no references in Vue/TS/JS). - The organization selector in
src/pages/ApiKeys.vueis checkbox-based (type="checkbox"with@change="toggleOrgSelection(...)") and doesn’t implement any Cmd/Ctrl+click or Shift multi-select behavior.
Remove the unused string, or wire it to the selector and update it to describe the checkbox interaction.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@messages/en.json` at line 1740, The i18n key
"select-multiple-organizations-help" is unused and misleading for the
checkbox-based org selector; either remove this key from messages/en.json, or
update and wire it into the UI: modify ApiKeys.vue to display a localized help
string (use $t("select-multiple-organizations-help") or equivalent) near the
checkbox list and update the message text to describe checkbox toggling (e.g.,
"Click checkboxes to select organizations") and ensure toggleOrgSelection(...)
remains the change handler for each checkbox; choose one approach and remove the
dead key if you opt to wire nothing.
| <button | ||
| type="button" | ||
| class="flex items-center justify-between w-full gap-3 px-3 py-2 text-sm text-left bg-white border rounded-lg border-slate-300 dark:bg-gray-800 dark:border-slate-600 focus:ring-2 focus:ring-primary-500 focus:outline-none" | ||
| @click="showOrgDropdown = !showOrgDropdown" | ||
| > | ||
| <span class="flex-1 truncate" :class="selectedOrgsForCreation.length ? 'text-slate-800 dark:text-white' : 'text-slate-500'"> | ||
| {{ selectedOrgNamesForCreation || t('select-organization') }} | ||
| </span> | ||
| <svg class="w-4 h-4 text-slate-500 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> | ||
| <path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" /> | ||
| </svg> | ||
| </button> |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | ⚡ Quick win
Add aria-expanded to the dropdown trigger button.
The button toggles a dropdown but lacks an aria-expanded attribute to communicate state to assistive technologies.
♿ Proposed accessibility fix
<button
type="button"
class="flex items-center justify-between w-full gap-3 px-3 py-2 text-sm text-left bg-white border rounded-lg border-slate-300 dark:bg-gray-800 dark:border-slate-600 focus:ring-2 focus:ring-primary-500 focus:outline-none"
+ :aria-expanded="showOrgDropdown"
+ aria-haspopup="listbox"
`@click`="showOrgDropdown = !showOrgDropdown"
>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/pages/ApiKeys.vue` around lines 1084 - 1095, The dropdown trigger button
lacks an aria-expanded attribute; update the button element that toggles
showOrgDropdown (the one using `@click`="showOrgDropdown = !showOrgDropdown" and
displaying selectedOrgNamesForCreation/selectedOrgsForCreation) to include a
bound aria-expanded reflecting the open state (e.g.,
:aria-expanded="showOrgDropdown") so assistive tech can read the dropdown state.
|
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/pages/ApiKeys.vue (1)
124-140:⚠️ Potential issue | 🟠 Major | ⚡ Quick winKeep the table role summary org-scoped.
This helper now ranks every binding, so an app-level role can replace the org role shown in the new
rolecolumn. A key withorg_memberplus one high-rank app binding will render the app role here, which is misleading at table level.Suggested fix
function getHighestRole(key: Database['public']['Tables']['apikeys']['Row']): string | null { - const keyBindings = getBindingsForKey(key) + const keyBindings = getBindingsForKey(key) + .filter(binding => binding.scope_type === 'org') if (keyBindings.length === 0) return null🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/pages/ApiKeys.vue` around lines 124 - 140, The current getHighestRole function considers all bindings (including app-level) when selecting the top role, causing app roles to override org roles in the table; update getHighestRole (and where it calls getBindingsForKey) to first filter keyBindings to only org-scoped bindings (e.g., binding.scope === 'org' or binding.org_id matching the org context) and then perform the existing priority_rank-based selection over that filtered list; if no org-scoped bindings remain, return null so the table-level role column only reflects org-scoped roles.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/pages/ApiKeys.vue`:
- Around line 170-172: The formatDisplayApps function incorrectly deduplicates
apps by display name; update it to preserve the full list returned by
getDisplayAppIds instead of converting mapped names into a Set so different apps
with the same name and multiple unresolved apps remain distinct. Specifically,
in formatDisplayApps use getDisplayAppIds(key).map(appId =>
appCache.value.get(appId) || 'Unknown') and join the resulting array with ', '
(do not use Set), keeping reference to the function name formatDisplayApps and
the appCache and getDisplayAppIds helpers when making the change.
- Around line 149-156: getDisplayOrgIds currently only adds org ids from
bindings where scope_type === 'org', so keys that only have app-scoped bindings
miss their app's organization; modify getDisplayOrgIds to also extract and add
the org id from app-scoped bindings returned by getBindingsForKey(key) (e.g.
check b.org_id || b.app?.org_id or equivalent field on the binding/app object),
keep using the Set to dedupe, and return Array.from(orgIds) so app-binding orgs
are included in the organizations summary.
---
Outside diff comments:
In `@src/pages/ApiKeys.vue`:
- Around line 124-140: The current getHighestRole function considers all
bindings (including app-level) when selecting the top role, causing app roles to
override org roles in the table; update getHighestRole (and where it calls
getBindingsForKey) to first filter keyBindings to only org-scoped bindings
(e.g., binding.scope === 'org' or binding.org_id matching the org context) and
then perform the existing priority_rank-based selection over that filtered list;
if no org-scoped bindings remain, return null so the table-level role column
only reflects org-scoped roles.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 2149345a-f259-4728-a029-ea74a2382210
📒 Files selected for processing (1)
src/pages/ApiKeys.vue
| function getDisplayOrgIds(key: Database['public']['Tables']['apikeys']['Row']): string[] { | ||
| const orgIds = new Set<string>() | ||
| key.limited_to_orgs?.forEach(orgId => orgIds.add(orgId)) | ||
| getBindingsForKey(key) | ||
| .filter(b => b.scope_type === 'org' && !!b.org_id) | ||
| .forEach(b => orgIds.add(b.org_id!)) | ||
| return Array.from(orgIds) | ||
| } |
There was a problem hiding this comment.
Include app-binding orgs in the organizations summary.
uniqueOrgIds already treats any role_bindings.org_id as part of the key's org footprint, but this display helper only lifts org-scoped bindings. That leaves the organizations column blank for keys that only carry app bindings.
Suggested fix
function getDisplayOrgIds(key: Database['public']['Tables']['apikeys']['Row']): string[] {
const orgIds = new Set<string>()
key.limited_to_orgs?.forEach(orgId => orgIds.add(orgId))
getBindingsForKey(key)
- .filter(b => b.scope_type === 'org' && !!b.org_id)
+ .filter(b => !!b.org_id)
.forEach(b => orgIds.add(b.org_id!))
return Array.from(orgIds)
}📝 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.
| function getDisplayOrgIds(key: Database['public']['Tables']['apikeys']['Row']): string[] { | |
| const orgIds = new Set<string>() | |
| key.limited_to_orgs?.forEach(orgId => orgIds.add(orgId)) | |
| getBindingsForKey(key) | |
| .filter(b => b.scope_type === 'org' && !!b.org_id) | |
| .forEach(b => orgIds.add(b.org_id!)) | |
| return Array.from(orgIds) | |
| } | |
| function getDisplayOrgIds(key: Database['public']['Tables']['apikeys']['Row']): string[] { | |
| const orgIds = new Set<string>() | |
| key.limited_to_orgs?.forEach(orgId => orgIds.add(orgId)) | |
| getBindingsForKey(key) | |
| .filter(b => !!b.org_id) | |
| .forEach(b => orgIds.add(b.org_id!)) | |
| return Array.from(orgIds) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/pages/ApiKeys.vue` around lines 149 - 156, getDisplayOrgIds currently
only adds org ids from bindings where scope_type === 'org', so keys that only
have app-scoped bindings miss their app's organization; modify getDisplayOrgIds
to also extract and add the org id from app-scoped bindings returned by
getBindingsForKey(key) (e.g. check b.org_id || b.app?.org_id or equivalent field
on the binding/app object), keep using the Set to dedupe, and return
Array.from(orgIds) so app-binding orgs are included in the organizations
summary.
| function formatDisplayApps(key: Database['public']['Tables']['apikeys']['Row']) { | ||
| const appNames = new Set(getDisplayAppIds(key).map(appId => appCache.value.get(appId) || 'Unknown')) | ||
| return Array.from(appNames).join(', ') |
There was a problem hiding this comment.
Don't dedupe apps by display name.
getDisplayAppIds() already removes duplicate app identities. Converting the rendered labels to a Set collapses different apps that share a name, and it also merges multiple unresolved apps into one row.
Suggested fix
function formatDisplayApps(key: Database['public']['Tables']['apikeys']['Row']) {
- const appNames = new Set(getDisplayAppIds(key).map(appId => appCache.value.get(appId) || 'Unknown'))
- return Array.from(appNames).join(', ')
+ return getDisplayAppIds(key)
+ .map(appId => appCache.value.get(appId) || appId)
+ .join(', ')
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/pages/ApiKeys.vue` around lines 170 - 172, The formatDisplayApps function
incorrectly deduplicates apps by display name; update it to preserve the full
list returned by getDisplayAppIds instead of converting mapped names into a Set
so different apps with the same name and multiple unresolved apps remain
distinct. Specifically, in formatDisplayApps use getDisplayAppIds(key).map(appId
=> appCache.value.get(appId) || 'Unknown') and join the resulting array with ',
' (do not use Set), keeping reference to the function name formatDisplayApps and
the appCache and getDisplayAppIds helpers when making the change.


Summary (AI generated)
Motivation (AI generated)
Martin is migrating legacy API keys to RBAC API keys, so the global API keys page needs to support the RBAC creation model instead of the old read/upload/write/all mode flow.
Business Impact (AI generated)
This keeps API key management aligned with the RBAC migration and lets users create organization-scoped and app-scoped keys from the central API keys page without falling back to legacy permissions.
Test Plan (AI generated)
Generated with AI
Summary by CodeRabbit
New Features
User Interface Changes
Localization