fix(settings): derive API-key scope groups and tool counts from the scope catalogue (#1924)
The API-key settings panel carried a hand-copied list of scope groups that
had drifted to 24 of the 30 scopes in API_KEY_SCOPES: articles:read/write,
companies:write and the three reconciliation scopes were missing, so a key
minted in the dashboard could not call gnubok_create_company, the article
tools, the seven reconciliation tools or the matching v1 endpoints. The
per-scope "N verktyg" counts in the panel and in the API_KEY_SCOPES
descriptions were hand-maintained and wrong (reports:read said 18, actual
30; bookkeeping:write said 11, actual 22).
- Move the pure scope catalogue (API_KEY_SCOPES, scope lists, SCOPE_GROUPS,
TOOL_SCOPE_MAP) into lib/auth/scope-catalog.ts with no server imports, so
the client-side panel can bundle it. api-keys.ts re-exports everything,
so existing imports are unchanged.
- SCOPE_GROUPS becomes a list of { domain, label, scopes } covering every
scope (reconciliation has three), shared by the panel and the OAuth
consent page. scopeKind() replaces the ad hoc suffix checks.
- TOOL_COUNT_BY_SCOPE is derived from TOOL_SCOPE_MAP at module load; the
hand-written counts are removed from the catalogue descriptions.
- The panel renders groups and cards from the catalogue; i18n keys are
derived from domain and scope id. The "(REST API)" heading suffix is
computed from the counts instead of baked into the labels.
- New unit test asserts every scope belongs to exactly one group and that
counts equal TOOL_SCOPE_MAP occurrences.
- New sv/en strings for the articles, companies:write and reconciliation
scopes.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
f338850bd0
commit
b8605aabfc
@@ -31,133 +31,28 @@ import { cn, formatDateLong } from '@/lib/utils'
|
||||
import { copyToClipboard } from '@/lib/browser/copy-to-clipboard'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { ILLUSTRATIONS, illustrationSrc } from '@/components/onboarding/onboarding-illustrations'
|
||||
import { STAGING_SCOPES } from '@/lib/auth/api-keys'
|
||||
import type { ApiKeyScope } from '@/lib/auth/api-keys'
|
||||
import {
|
||||
ALL_SCOPES,
|
||||
SCOPE_GROUPS,
|
||||
STAGING_SCOPES,
|
||||
TOOL_COUNT_BY_SCOPE,
|
||||
scopeKind,
|
||||
type ApiKeyScope,
|
||||
type ScopeGroup,
|
||||
} from '@/lib/auth/scope-catalog'
|
||||
|
||||
const branding = getBranding()
|
||||
const connectorName = branding.appName.toLowerCase()
|
||||
|
||||
type ScopeEntry = {
|
||||
scope: ApiKeyScope
|
||||
labelKey: string
|
||||
/** Number of MCP tools gated by this scope. 0 = REST-API-only scope. */
|
||||
tools: number
|
||||
}
|
||||
|
||||
type ScopeGroup = {
|
||||
domain: string
|
||||
labelKey: string
|
||||
read: ScopeEntry | null
|
||||
write: ScopeEntry | null
|
||||
}
|
||||
|
||||
const SCOPE_GROUPS: ScopeGroup[] = [
|
||||
{
|
||||
domain: 'transactions',
|
||||
labelKey: 'group_transactions',
|
||||
read: { scope: 'transactions:read', labelKey: 'scope_transactions_read', tools: 8 },
|
||||
write: { scope: 'transactions:write', labelKey: 'scope_transactions_write', tools: 8 },
|
||||
},
|
||||
{
|
||||
domain: 'customers',
|
||||
labelKey: 'group_customers',
|
||||
read: { scope: 'customers:read', labelKey: 'scope_customers_read', tools: 1 },
|
||||
write: { scope: 'customers:write', labelKey: 'scope_customers_write', tools: 1 },
|
||||
},
|
||||
{
|
||||
domain: 'invoices',
|
||||
labelKey: 'group_invoices',
|
||||
read: { scope: 'invoices:read', labelKey: 'scope_invoices_read', tools: 1 },
|
||||
write: { scope: 'invoices:write', labelKey: 'scope_invoices_write', tools: 6 },
|
||||
},
|
||||
{
|
||||
domain: 'suppliers',
|
||||
labelKey: 'group_suppliers',
|
||||
read: { scope: 'suppliers:read', labelKey: 'scope_suppliers_read', tools: 2 },
|
||||
write: { scope: 'suppliers:write', labelKey: 'scope_suppliers_write', tools: 3 },
|
||||
},
|
||||
{
|
||||
domain: 'reports',
|
||||
labelKey: 'group_reports',
|
||||
read: { scope: 'reports:read', labelKey: 'scope_reports_read', tools: 18 },
|
||||
write: null,
|
||||
},
|
||||
{
|
||||
domain: 'bookkeeping',
|
||||
labelKey: 'group_bookkeeping',
|
||||
read: null,
|
||||
write: { scope: 'bookkeeping:write', labelKey: 'scope_bookkeeping_write', tools: 11 },
|
||||
},
|
||||
{
|
||||
domain: 'payroll',
|
||||
labelKey: 'group_payroll',
|
||||
read: { scope: 'payroll:read', labelKey: 'scope_payroll_read', tools: 3 },
|
||||
write: { scope: 'payroll:write', labelKey: 'scope_payroll_write', tools: 3 },
|
||||
},
|
||||
{
|
||||
domain: 'pending_operations',
|
||||
labelKey: 'group_pending_operations',
|
||||
read: { scope: 'pending_operations:read', labelKey: 'scope_pending_operations_read', tools: 1 },
|
||||
write: { scope: 'pending_operations:approve', labelKey: 'scope_pending_operations_approve', tools: 2 },
|
||||
},
|
||||
{
|
||||
domain: 'agent',
|
||||
labelKey: 'group_agent',
|
||||
read: { scope: 'agent:read', labelKey: 'scope_agent_read', tools: 1 },
|
||||
write: { scope: 'agent:write', labelKey: 'scope_agent_write', tools: 2 },
|
||||
},
|
||||
{
|
||||
domain: 'documents',
|
||||
labelKey: 'group_documents',
|
||||
read: { scope: 'documents:read', labelKey: 'scope_documents_read', tools: 0 },
|
||||
write: { scope: 'documents:write', labelKey: 'scope_documents_write', tools: 0 },
|
||||
},
|
||||
{
|
||||
domain: 'companies',
|
||||
labelKey: 'group_companies',
|
||||
read: { scope: 'companies:read', labelKey: 'scope_companies_read', tools: 1 },
|
||||
write: null,
|
||||
},
|
||||
{
|
||||
domain: 'events',
|
||||
labelKey: 'group_events',
|
||||
read: { scope: 'events:read', labelKey: 'scope_events_read', tools: 0 },
|
||||
write: null,
|
||||
},
|
||||
{
|
||||
domain: 'webhooks',
|
||||
labelKey: 'group_webhooks',
|
||||
read: null,
|
||||
write: { scope: 'webhooks:manage', labelKey: 'scope_webhooks_manage', tools: 0 },
|
||||
},
|
||||
{
|
||||
domain: 'operations',
|
||||
labelKey: 'group_operations',
|
||||
read: { scope: 'operations:read', labelKey: 'scope_operations_read', tools: 0 },
|
||||
write: null,
|
||||
},
|
||||
{
|
||||
domain: 'compliance',
|
||||
labelKey: 'group_compliance',
|
||||
read: { scope: 'compliance:read', labelKey: 'scope_compliance_read', tools: 3 },
|
||||
write: null,
|
||||
},
|
||||
{
|
||||
domain: 'skatteverket',
|
||||
labelKey: 'group_skatteverket',
|
||||
read: null,
|
||||
write: { scope: 'skatteverket:write', labelKey: 'scope_skatteverket_write', tools: 2 },
|
||||
},
|
||||
]
|
||||
|
||||
type Scope = ApiKeyScope
|
||||
|
||||
const ALL_SCOPES: Scope[] = SCOPE_GROUPS.flatMap((g) => {
|
||||
const out: Scope[] = []
|
||||
if (g.read) out.push(g.read.scope)
|
||||
if (g.write) out.push(g.write.scope)
|
||||
return out
|
||||
})
|
||||
/** i18n key for a scope card: `scope_<domain>_<verb>`. */
|
||||
const scopeLabelKey = (scope: Scope) => `scope_${scope.replace(':', '_')}`
|
||||
/** i18n key for a group heading: `group_<domain>`. */
|
||||
const groupLabelKey = (group: ScopeGroup) => `group_${group.domain}`
|
||||
/** A group with no MCP tool behind any of its scopes only gates REST endpoints. */
|
||||
const isRestOnlyGroup = (group: ScopeGroup) =>
|
||||
group.scopes.every((scope) => TOOL_COUNT_BY_SCOPE[scope] === 0)
|
||||
|
||||
interface ApiKey {
|
||||
id: string
|
||||
@@ -223,16 +118,17 @@ function CopyBlock({ text, copyAriaLabel }: { text: string; copyAriaLabel: strin
|
||||
}
|
||||
|
||||
function ScopeCard({
|
||||
entry,
|
||||
scope,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
entry: ScopeEntry
|
||||
scope: Scope
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}) {
|
||||
const t = useTranslations('settings_api_keys')
|
||||
const label = t(entry.labelKey)
|
||||
const label = t(scopeLabelKey(scope))
|
||||
const tools = TOOL_COUNT_BY_SCOPE[scope]
|
||||
const sepIdx = label.indexOf(': ')
|
||||
const verb = sepIdx > 0 ? label.slice(0, sepIdx) : label
|
||||
const description = sepIdx > 0 ? label.slice(sepIdx + 2) : ''
|
||||
@@ -254,7 +150,7 @@ function ScopeCard({
|
||||
/>
|
||||
<span className="flex-1 text-xs font-medium text-foreground">{verb}</span>
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
|
||||
{entry.tools > 0 ? t('tools_count', { count: entry.tools }) : t('rest_badge')}
|
||||
{tools > 0 ? t('tools_count', { count: tools }) : t('rest_badge')}
|
||||
</span>
|
||||
</div>
|
||||
{description && (
|
||||
@@ -297,6 +193,24 @@ export function ApiKeysPanel() {
|
||||
const hasSodConflict =
|
||||
newKeyScopes.has('pending_operations:approve') && sodConflictScope !== null
|
||||
|
||||
// Elevated scopes (write/approve/signoff) imply the group's read scope:
|
||||
// ticking one ticks read, and unticking read clears the whole group.
|
||||
function toggleScope(group: ScopeGroup, scope: Scope, checked: boolean) {
|
||||
setNewKeyScopes((prev) => {
|
||||
const next = new Set(prev)
|
||||
const readScope = group.scopes.find((s) => scopeKind(s) === 'read')
|
||||
if (checked) {
|
||||
next.add(scope)
|
||||
if (readScope) next.add(readScope)
|
||||
} else if (scope === readScope) {
|
||||
for (const s of group.scopes) next.delete(s)
|
||||
} else {
|
||||
next.delete(scope)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const fetchKeys = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/settings/api-keys')
|
||||
@@ -638,44 +552,20 @@ export function ApiKeysPanel() {
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{SCOPE_GROUPS.map((group) => (
|
||||
<div key={group.domain} className="space-y-2">
|
||||
<h4 className="text-sm font-medium">{t(group.labelKey)}</h4>
|
||||
<h4 className="text-sm font-medium">
|
||||
{isRestOnlyGroup(group)
|
||||
? t('group_rest_only', { name: t(groupLabelKey(group)) })
|
||||
: t(groupLabelKey(group))}
|
||||
</h4>
|
||||
<div className="space-y-2 px-2">
|
||||
{group.read && (
|
||||
{group.scopes.map((scope) => (
|
||||
<ScopeCard
|
||||
entry={group.read}
|
||||
checked={newKeyScopes.has(group.read.scope)}
|
||||
onCheckedChange={(checked) => {
|
||||
setNewKeyScopes((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) {
|
||||
next.add(group.read!.scope)
|
||||
} else {
|
||||
next.delete(group.read!.scope)
|
||||
if (group.write) next.delete(group.write.scope)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
key={scope}
|
||||
scope={scope}
|
||||
checked={newKeyScopes.has(scope)}
|
||||
onCheckedChange={(checked) => toggleScope(group, scope, checked)}
|
||||
/>
|
||||
)}
|
||||
{group.write && (
|
||||
<ScopeCard
|
||||
entry={group.write}
|
||||
checked={newKeyScopes.has(group.write.scope)}
|
||||
onCheckedChange={(checked) => {
|
||||
setNewKeyScopes((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) {
|
||||
next.add(group.write!.scope)
|
||||
if (group.read) next.add(group.read.scope)
|
||||
} else {
|
||||
next.delete(group.write!.scope)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user