@kelpie/ui 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/dist/api/resources/activities.d.ts +2 -1
  2. package/dist/api/resources/activities.d.ts.map +1 -1
  3. package/dist/api/resources/activities.js +6 -2
  4. package/dist/api/resources/activities.js.map +1 -1
  5. package/dist/api/resources/conversions.d.ts +16 -0
  6. package/dist/api/resources/conversions.d.ts.map +1 -0
  7. package/dist/api/resources/conversions.js +81 -0
  8. package/dist/api/resources/conversions.js.map +1 -0
  9. package/dist/api/resources/enquiries.d.ts +2 -7
  10. package/dist/api/resources/enquiries.d.ts.map +1 -1
  11. package/dist/api/resources/enquiries.js +2 -30
  12. package/dist/api/resources/enquiries.js.map +1 -1
  13. package/dist/api/resources/importJobs.d.ts +2 -2
  14. package/dist/api/resources/importJobs.d.ts.map +1 -1
  15. package/dist/api/resources/importJobs.js.map +1 -1
  16. package/dist/app/KelpieApp.js +1 -1
  17. package/dist/app/KelpieApp.js.map +1 -1
  18. package/dist/components/ActivitiesPanel.d.ts +2 -2
  19. package/dist/components/ActivitiesPanel.d.ts.map +1 -1
  20. package/dist/components/ActivitiesPanel.js +7 -5
  21. package/dist/components/ActivitiesPanel.js.map +1 -1
  22. package/dist/components/ApiKeysPanel.d.ts +0 -7
  23. package/dist/components/ApiKeysPanel.d.ts.map +1 -1
  24. package/dist/components/ApiKeysPanel.js +83 -16
  25. package/dist/components/ApiKeysPanel.js.map +1 -1
  26. package/dist/components/ConvertRecordDialog.d.ts +30 -0
  27. package/dist/components/ConvertRecordDialog.d.ts.map +1 -0
  28. package/dist/components/ConvertRecordDialog.js +50 -0
  29. package/dist/components/ConvertRecordDialog.js.map +1 -0
  30. package/dist/components/Shell.d.ts.map +1 -1
  31. package/dist/components/Shell.js +19 -7
  32. package/dist/components/Shell.js.map +1 -1
  33. package/dist/index.d.ts +4 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +5 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/pages/DealDetail.d.ts.map +1 -1
  38. package/dist/pages/DealDetail.js +15 -2
  39. package/dist/pages/DealDetail.js.map +1 -1
  40. package/dist/pages/DecisionsPage.d.ts.map +1 -1
  41. package/dist/pages/DecisionsPage.js +1 -7
  42. package/dist/pages/DecisionsPage.js.map +1 -1
  43. package/dist/pages/EnquiryDetail.d.ts.map +1 -1
  44. package/dist/pages/EnquiryDetail.js +17 -25
  45. package/dist/pages/EnquiryDetail.js.map +1 -1
  46. package/dist/pages/ListDetail.d.ts.map +1 -1
  47. package/dist/pages/ListDetail.js +1 -14
  48. package/dist/pages/ListDetail.js.map +1 -1
  49. package/dist/pages/OpportunityDetail.d.ts.map +1 -1
  50. package/dist/pages/OpportunityDetail.js +15 -2
  51. package/dist/pages/OpportunityDetail.js.map +1 -1
  52. package/dist/pages/PartnershipDetail.d.ts.map +1 -1
  53. package/dist/pages/PartnershipDetail.js +15 -2
  54. package/dist/pages/PartnershipDetail.js.map +1 -1
  55. package/dist/pages/RaiseDetail.d.ts.map +1 -1
  56. package/dist/pages/RaiseDetail.js +15 -2
  57. package/dist/pages/RaiseDetail.js.map +1 -1
  58. package/dist/pages/account/SecurityPage.js +2 -2
  59. package/dist/pages/account/SecurityPage.js.map +1 -1
  60. package/dist/pages/admin/DataPage.d.ts.map +1 -1
  61. package/dist/pages/admin/DataPage.js +3 -3
  62. package/dist/pages/admin/DataPage.js.map +1 -1
  63. package/dist/pages/admin/PrivacyPage.js +3 -3
  64. package/dist/pages/admin/PrivacyPage.js.map +1 -1
  65. package/dist/pages/admin/TeamPage.js +1 -1
  66. package/dist/pages/admin/TeamPage.js.map +1 -1
  67. package/dist/pages/admin/WorkspaceSettingsPage.js +2 -12
  68. package/dist/pages/admin/WorkspaceSettingsPage.js.map +1 -1
  69. package/dist/pages/auth/AuthForm.d.ts +18 -0
  70. package/dist/pages/auth/AuthForm.d.ts.map +1 -1
  71. package/dist/pages/auth/AuthForm.js +17 -0
  72. package/dist/pages/auth/AuthForm.js.map +1 -1
  73. package/dist/pages/auth/AuthMethods.d.ts +12 -0
  74. package/dist/pages/auth/AuthMethods.d.ts.map +1 -0
  75. package/dist/pages/auth/AuthMethods.js +20 -0
  76. package/dist/pages/auth/AuthMethods.js.map +1 -0
  77. package/dist/pages/auth/SignInPage.d.ts +7 -0
  78. package/dist/pages/auth/SignInPage.d.ts.map +1 -1
  79. package/dist/pages/auth/SignInPage.js +3 -8
  80. package/dist/pages/auth/SignInPage.js.map +1 -1
  81. package/dist/pages/auth/SignUpPage.d.ts.map +1 -1
  82. package/dist/pages/auth/SignUpPage.js +2 -1
  83. package/dist/pages/auth/SignUpPage.js.map +1 -1
  84. package/dist/pages/auth/nextPath.d.ts +15 -0
  85. package/dist/pages/auth/nextPath.d.ts.map +1 -0
  86. package/dist/pages/auth/nextPath.js +17 -0
  87. package/dist/pages/auth/nextPath.js.map +1 -0
  88. package/dist/pages/forms/AddFieldMenu.js +2 -2
  89. package/dist/pages/forms/AddFieldMenu.js.map +1 -1
  90. package/dist/pages/forms/FieldsEditor.d.ts.map +1 -1
  91. package/dist/pages/forms/FieldsEditor.js +22 -10
  92. package/dist/pages/forms/FieldsEditor.js.map +1 -1
  93. package/dist/pages/forms/MapTargetSearch.d.ts +7 -0
  94. package/dist/pages/forms/MapTargetSearch.d.ts.map +1 -0
  95. package/dist/pages/forms/MapTargetSearch.js +32 -0
  96. package/dist/pages/forms/MapTargetSearch.js.map +1 -0
  97. package/dist/pages/forms/fieldList.d.ts +6 -7
  98. package/dist/pages/forms/fieldList.d.ts.map +1 -1
  99. package/dist/pages/forms/fieldList.js +14 -16
  100. package/dist/pages/forms/fieldList.js.map +1 -1
  101. package/dist/pages/forms/template.d.ts.map +1 -1
  102. package/dist/pages/forms/template.js +7 -0
  103. package/dist/pages/forms/template.js.map +1 -1
  104. package/dist/registry/context.d.ts +3 -1
  105. package/dist/registry/context.d.ts.map +1 -1
  106. package/dist/registry/context.js +4 -0
  107. package/dist/registry/context.js.map +1 -1
  108. package/dist/registry/contributions.d.ts +24 -0
  109. package/dist/registry/contributions.d.ts.map +1 -1
  110. package/dist/registry/contributions.js.map +1 -1
  111. package/dist/registry/registry.d.ts +4 -1
  112. package/dist/registry/registry.d.ts.map +1 -1
  113. package/dist/registry/registry.js +6 -0
  114. package/dist/registry/registry.js.map +1 -1
  115. package/package.json +2 -2
  116. package/src/api/resources/activities.ts +10 -2
  117. package/src/api/resources/conversions.ts +125 -0
  118. package/src/api/resources/enquiries.ts +9 -35
  119. package/src/api/resources/importJobs.ts +3 -2
  120. package/src/app/KelpieApp.tsx +1 -0
  121. package/src/components/ActivitiesPanel.tsx +20 -14
  122. package/src/components/ApiKeysPanel.tsx +206 -21
  123. package/src/components/ConvertRecordDialog.tsx +210 -0
  124. package/src/components/Shell.tsx +91 -36
  125. package/src/index.ts +7 -0
  126. package/src/pages/DealDetail.tsx +35 -0
  127. package/src/pages/DecisionsPage.tsx +0 -14
  128. package/src/pages/EnquiryDetail.tsx +37 -90
  129. package/src/pages/ListDetail.tsx +0 -47
  130. package/src/pages/OpportunityDetail.tsx +35 -0
  131. package/src/pages/PartnershipDetail.tsx +35 -0
  132. package/src/pages/RaiseDetail.tsx +35 -0
  133. package/src/pages/account/SecurityPage.tsx +6 -1
  134. package/src/pages/admin/DataPage.tsx +14 -9
  135. package/src/pages/admin/PrivacyPage.tsx +4 -4
  136. package/src/pages/admin/TeamPage.tsx +1 -2
  137. package/src/pages/admin/WorkspaceSettingsPage.tsx +1 -31
  138. package/src/pages/auth/AuthForm.tsx +38 -0
  139. package/src/pages/auth/AuthMethods.tsx +29 -0
  140. package/src/pages/auth/SignInPage.tsx +3 -8
  141. package/src/pages/auth/SignUpPage.tsx +7 -0
  142. package/src/pages/auth/nextPath.ts +17 -0
  143. package/src/pages/forms/AddFieldMenu.tsx +2 -2
  144. package/src/pages/forms/FieldsEditor.tsx +45 -20
  145. package/src/pages/forms/MapTargetSearch.tsx +69 -0
  146. package/src/pages/forms/fieldList.ts +34 -16
  147. package/src/pages/forms/template.ts +7 -0
  148. package/src/registry/context.ts +6 -0
  149. package/src/registry/contributions.ts +26 -0
  150. package/src/registry/registry.ts +12 -0
@@ -0,0 +1,125 @@
1
+ import {
2
+ convertPipelineRecordRequest,
3
+ dealSchema,
4
+ enquirySchema,
5
+ opportunitySchema,
6
+ partnershipSchema,
7
+ raiseSchema,
8
+ } from '@kelpie/schemas'
9
+ import type {
10
+ ConvertPipelineRecordInput,
11
+ ConvertedTo,
12
+ Deal,
13
+ Enquiry,
14
+ Opportunity,
15
+ Partnership,
16
+ PipelineKind,
17
+ Raise,
18
+ } from '@kelpie/schemas'
19
+ import { useMutation, useQueryClient } from '@tanstack/react-query'
20
+
21
+ import { useApiClient } from '../context.ts'
22
+ import { toError } from '../errors.ts'
23
+ import type { MutationResult } from '../resource.ts'
24
+
25
+ const COLLECTION_PATHS: Readonly<Record<PipelineKind, string>> = {
26
+ enquiry: 'enquiries',
27
+ deal: 'deals',
28
+ opportunity: 'opportunities',
29
+ raise: 'raises',
30
+ partnership: 'partnerships',
31
+ }
32
+
33
+ const DETAIL_PATHS: Readonly<Record<PipelineKind, string>> = {
34
+ enquiry: '/enquiries',
35
+ deal: '/deals',
36
+ opportunity: '/opportunities',
37
+ raise: '/raises',
38
+ partnership: '/partnerships',
39
+ }
40
+
41
+ export function detailPathForPipelineKind(kind: PipelineKind, id: string): string {
42
+ return `${DETAIL_PATHS[kind]}/${id}`
43
+ }
44
+
45
+ export type ConvertedPipelineRecord = Deal | Enquiry | Opportunity | Raise | Partnership
46
+
47
+ function parseConvertedRecord(kind: PipelineKind, body: unknown): ConvertedPipelineRecord {
48
+ switch (kind) {
49
+ case 'enquiry':
50
+ return enquirySchema.parse(body)
51
+ case 'deal':
52
+ return dealSchema.parse(body)
53
+ case 'opportunity':
54
+ return opportunitySchema.parse(body)
55
+ case 'raise':
56
+ return raiseSchema.parse(body)
57
+ case 'partnership':
58
+ return partnershipSchema.parse(body)
59
+ }
60
+ }
61
+
62
+ export interface ConvertPipelineRecordArguments {
63
+ readonly id: string
64
+ readonly body: ConvertPipelineRecordInput
65
+ }
66
+
67
+ export function useConvertPipelineRecord(
68
+ sourceKind: PipelineKind,
69
+ ): MutationResult<ConvertPipelineRecordArguments, ConvertedPipelineRecord> {
70
+ const client = useApiClient()
71
+ const queryClient = useQueryClient()
72
+ const collection = COLLECTION_PATHS[sourceKind]
73
+
74
+ const mutation = useMutation({
75
+ mutationFn: ({ id, body }: ConvertPipelineRecordArguments) =>
76
+ client.post(
77
+ `/${collection}/${id}/convert`,
78
+ convertPipelineRecordRequest(body),
79
+ (wire) => parseConvertedRecord(body.targetType, wire),
80
+ ),
81
+ onSuccess: (_record, variables) => {
82
+ void queryClient.invalidateQueries({ queryKey: [collection] })
83
+ void queryClient.invalidateQueries({
84
+ queryKey: [COLLECTION_PATHS[variables.body.targetType]],
85
+ })
86
+ void queryClient.invalidateQueries({ queryKey: ['activities'] })
87
+ void queryClient.invalidateQueries({ queryKey: ['notes'] })
88
+ void queryClient.invalidateQueries({ queryKey: ['plan_items'] })
89
+ void queryClient.invalidateQueries({ queryKey: ['decisions'] })
90
+ },
91
+ })
92
+
93
+ return {
94
+ run: (input) => {
95
+ mutation.mutate(input)
96
+ },
97
+ runAsync: (input) => mutation.mutateAsync(input),
98
+ isPending: mutation.isPending,
99
+ error: toError(mutation.error),
100
+ }
101
+ }
102
+
103
+ export function convertedTargetPath(converted: ConvertedTo): string {
104
+ return detailPathForPipelineKind(converted.targetType, converted.targetId)
105
+ }
106
+
107
+ /** @deprecated Use `useConvertPipelineRecord('enquiry')` with `{ targetType: 'deal' }`. */
108
+ export function useConvertEnquiry(): MutationResult<string, Deal> {
109
+ const convert = useConvertPipelineRecord('enquiry')
110
+
111
+ return {
112
+ run: (id) => {
113
+ convert.run({ id, body: { targetType: 'deal' } })
114
+ },
115
+ runAsync: async (id) => {
116
+ const record = await convert.runAsync({ id, body: { targetType: 'deal' } })
117
+
118
+ return dealSchema.parse(record)
119
+ },
120
+ isPending: convert.isPending,
121
+ error: convert.error,
122
+ }
123
+ }
124
+
125
+ export { COLLECTION_PATHS, DETAIL_PATHS }
@@ -1,11 +1,7 @@
1
- import { dealSchema, enquiryBody, enquirySchema } from '@kelpie/schemas'
2
- import type { Deal, Enquiry, EnquiryInput } from '@kelpie/schemas'
3
- import { useMutation, useQueryClient } from '@tanstack/react-query'
4
-
5
- import type { QueryParameters } from '../client.ts'
6
- import { useApiClient } from '../context.ts'
7
- import { toError } from '../errors.ts'
1
+ import { enquiryBody, enquirySchema } from '@kelpie/schemas'
2
+ import type { Enquiry, EnquiryInput } from '@kelpie/schemas'
8
3
  import { createResourceHooks } from '../resource.ts'
4
+ import type { QueryParameters } from '../client.ts'
9
5
  import type {
10
6
  ListOptions,
11
7
  MutationResult,
@@ -88,31 +84,9 @@ export function useDeleteEnquiry(): MutationResult<string, void> {
88
84
  return enquiries.useRemove()
89
85
  }
90
86
 
91
- /**
92
- * `POST /v1/enquiries/:id/convert`. Returns the freshly created deal, so the
93
- * caller can navigate straight to it. 409 if the enquiry has already been
94
- * converted; 422 if it has no linked company yet.
95
- */
96
- export function useConvertEnquiry(): MutationResult<string, Deal> {
97
- const client = useApiClient()
98
- const queryClient = useQueryClient()
99
- const mutation = useMutation({
100
- mutationFn: (id: string) =>
101
- client.post(`/enquiries/${id}/convert`, {}, dealSchema.parse),
102
- onSuccess: () => {
103
- // The enquiry, deals list, activities on both sides are all stale.
104
- void queryClient.invalidateQueries({ queryKey: ['enquiries'] })
105
- void queryClient.invalidateQueries({ queryKey: ['deals'] })
106
- void queryClient.invalidateQueries({ queryKey: ['activities'] })
107
- },
108
- })
109
-
110
- return {
111
- run: (input) => {
112
- mutation.mutate(input)
113
- },
114
- runAsync: (input) => mutation.mutateAsync(input),
115
- isPending: mutation.isPending,
116
- error: toError(mutation.error),
117
- }
118
- }
87
+ export {
88
+ useConvertEnquiry,
89
+ useConvertPipelineRecord,
90
+ detailPathForPipelineKind,
91
+ convertedTargetPath,
92
+ } from './conversions.ts'
@@ -1,5 +1,6 @@
1
1
  import { importJobSchema, isImportJobSettled } from '@kelpie/schemas'
2
2
  import type {
3
+ ExportObject,
3
4
  ImportColumnMap,
4
5
  ImportConflictMode,
5
6
  ImportJob,
@@ -205,7 +206,7 @@ export interface CsvDownload {
205
206
  * a failure is an `ApiError` the page can show beside the button.
206
207
  */
207
208
  export function useExportCsv(): MutationResult<
208
- { object: ImportObject; template: boolean },
209
+ { object: ExportObject; template: boolean },
209
210
  CsvDownload
210
211
  > {
211
212
  const client = useApiClient()
@@ -216,7 +217,7 @@ export function useExportCsv(): MutationResult<
216
217
  object,
217
218
  template,
218
219
  }: {
219
- object: ImportObject
220
+ object: ExportObject
220
221
  template: boolean
221
222
  }): Promise<CsvDownload> => {
222
223
  const path = template ? `/export/templates/${object}.csv` : `/export/${object}.csv`
@@ -180,6 +180,7 @@ function AppRoutes(): React.JSX.Element {
180
180
  {/* The optional segment is the mockup's: /handbook opens the first page. */}
181
181
  <Route path="handbook" element={<HandbookLayout />} />
182
182
  <Route path="handbook/:pageId" element={<HandbookLayout />} />
183
+ <Route path="admin" element={<Navigate to="/admin/workspace" replace />} />
183
184
  <Route path="admin/workspace" element={<WorkspaceSettingsPage />} />
184
185
  <Route path="admin/team" element={<TeamPage />} />
185
186
  <Route path="admin/fields" element={<FieldsPage />} />
@@ -57,6 +57,9 @@ interface MonthGroup {
57
57
  readonly items: readonly Activity[]
58
58
  }
59
59
 
60
+ /** How many events the Overview tab's latest-activity block shows. */
61
+ const LATEST_ACTIVITY_COUNT = 5
62
+
60
63
  /** Consecutive runs, not a map: the list is already ordered, so a month never recurs. */
61
64
  function groupByMonth(
62
65
  activities: readonly Activity[],
@@ -126,19 +129,19 @@ export function ActivitiesPanel({
126
129
  }
127
130
 
128
131
  /**
129
- * The single most recent event, for a record's Overview tab.
132
+ * The five most recent events, for a record's Overview tab.
130
133
  *
131
134
  * Its own request rather than a slice of the panel's: the panel lives on a
132
- * different tab, so on Overview there is no list to take the first row of.
135
+ * different tab, so on Overview there is no list to take the first rows of.
133
136
  */
134
137
  export function LatestActivity({
135
138
  targetType,
136
139
  targetId,
137
140
  }: ActivitiesPanelProps): React.JSX.Element {
138
- const activities = useActivities({ targetType, targetId })
141
+ const activities = useActivities({ targetType, targetId }, { limit: LATEST_ACTIVITY_COUNT })
139
142
  const members = useMembers()
140
143
  const timezone = useTimezone()
141
- const latest = activities.records[0]
144
+ const latest = activities.records
142
145
 
143
146
  return (
144
147
  <section>
@@ -147,20 +150,23 @@ export function LatestActivity({
147
150
  {activities.error !== null && <ErrorPanel error={activities.error} />}
148
151
  {activities.isLoading && <p className="text-[13px] text-ink-faint">Loading activity…</p>}
149
152
 
150
- {!activities.isLoading && latest === undefined && activities.error === null && (
153
+ {!activities.isLoading && latest.length === 0 && activities.error === null && (
151
154
  <p className="text-[13px] text-ink-faint">No activity yet.</p>
152
155
  )}
153
156
 
154
- {latest !== undefined && (
157
+ {latest.length > 0 && (
155
158
  <ol className="overflow-hidden rounded-md border border-border">
156
- <ActivityEvent
157
- activity={latest}
158
- actorName={actorNameFor(latest, members.nameById)}
159
- contextType={targetType}
160
- contextId={targetId}
161
- isLast
162
- timezone={timezone}
163
- />
159
+ {latest.map((activity, index) => (
160
+ <ActivityEvent
161
+ key={activity.id}
162
+ activity={activity}
163
+ actorName={actorNameFor(activity, members.nameById)}
164
+ contextType={targetType}
165
+ contextId={targetId}
166
+ isLast={index === latest.length - 1}
167
+ timezone={timezone}
168
+ />
169
+ ))}
164
170
  </ol>
165
171
  )}
166
172
  </section>
@@ -1,5 +1,13 @@
1
- import type { ApiKey, ApiKeyKind, CreatedApiKey } from '@kelpie/schemas'
2
- import { useState } from 'react'
1
+ import type { ApiKey, ApiKeyGranularScope, ApiKeyKind, ApiKeyScope, CreatedApiKey } from '@kelpie/schemas'
2
+ import {
3
+ API_KEY_PRESET_SCOPES,
4
+ API_KEY_SCOPE_GROUPS,
5
+ API_KEY_SCOPE_LABELS,
6
+ expandApiKeyScopes,
7
+ isApiKeyGranularScope,
8
+ isApiKeyPresetScope,
9
+ } from '@kelpie/schemas'
10
+ import { useMemo, useState } from 'react'
3
11
  import type { FormEvent } from 'react'
4
12
 
5
13
  import { useTimezone } from '../api/resources/account.ts'
@@ -11,22 +19,17 @@ import { ErrorPanel, LoadingPanel } from './QueryState.tsx'
11
19
  /**
12
20
  * The create/list/revoke flow shared by workspace keys (`/admin/api-keys`) and
13
21
  * personal keys (`/account/api-keys`).
14
- *
15
- * `kind` and the three strings below are the only difference between the two
16
- * pages, the same way `KanbanBoard` takes a `kind` for Deals versus
17
- * Opportunities. Who may reach this component at all — the admin gate on the
18
- * workspace page, none on the personal page — is the caller's decision; the API
19
- * enforces its own half regardless.
20
22
  */
21
23
 
22
24
  export interface ApiKeysPanelProps {
23
25
  readonly kind: ApiKeyKind
24
- /** Shown in the create form's name field, e.g. "CI pipeline" or "Laptop Claude". */
25
26
  readonly namePlaceholder: string
26
27
  readonly createTitle: string
27
28
  readonly emptyMessage: string
28
29
  }
29
30
 
31
+ const PRESET_OPTIONS = API_KEY_PRESET_SCOPES.filter((scope) => scope !== 'write:all')
32
+
30
33
  export function ApiKeysPanel({
31
34
  kind,
32
35
  namePlaceholder,
@@ -96,6 +99,8 @@ export function ApiKeysPanel({
96
99
  )
97
100
  }
98
101
 
102
+ type AccessMode = 'full' | 'presets' | 'custom'
103
+
99
104
  function CreateKeyModal({
100
105
  kind,
101
106
  title,
@@ -111,18 +116,78 @@ function CreateKeyModal({
111
116
  }): React.JSX.Element {
112
117
  const createApiKey = useCreateApiKey()
113
118
  const [name, setName] = useState('')
119
+ const [accessMode, setAccessMode] = useState<AccessMode>('full')
120
+ const [selectedPresets, setSelectedPresets] = useState<readonly ApiKeyScope[]>([])
121
+ const [selectedCustom, setSelectedCustom] = useState<readonly ApiKeyScope[]>([])
122
+ const [showCustom, setShowCustom] = useState(false)
123
+
124
+ const presetExpandedScopes = useMemo(
125
+ () => expandApiKeyScopes(selectedPresets),
126
+ [selectedPresets],
127
+ )
128
+
129
+ const showScopeGrid =
130
+ (accessMode === 'custom' && showCustom) ||
131
+ (accessMode === 'presets' && selectedPresets.length > 0)
132
+
133
+ function scopesForCreate(): readonly ApiKeyScope[] | undefined {
134
+ if (accessMode === 'full') {
135
+ return undefined
136
+ }
137
+
138
+ if (accessMode === 'presets') {
139
+ return selectedPresets.length === 0 ? undefined : selectedPresets
140
+ }
141
+
142
+ return selectedCustom.length === 0 ? undefined : selectedCustom
143
+ }
114
144
 
115
145
  function submit(event: FormEvent): void {
116
146
  event.preventDefault()
147
+ const scopes = scopesForCreate()
148
+
117
149
  createApiKey
118
- .runAsync({ name: name.trim(), kind })
150
+ .runAsync({
151
+ name: name.trim(),
152
+ kind,
153
+ ...(scopes === undefined ? {} : { scopes }),
154
+ })
119
155
  .then(onCreated)
120
156
  .catch(() => undefined)
121
157
  }
122
158
 
159
+ function togglePreset(scope: ApiKeyScope): void {
160
+ setAccessMode('presets')
161
+ setSelectedCustom([])
162
+ setShowCustom(false)
163
+ setSelectedPresets((current) =>
164
+ current.includes(scope) ? current.filter((entry) => entry !== scope) : [...current, scope],
165
+ )
166
+ }
167
+
168
+ function toggleCustom(scope: ApiKeyScope): void {
169
+ setAccessMode('custom')
170
+ setSelectedPresets([])
171
+ setSelectedCustom((current) =>
172
+ current.includes(scope) ? current.filter((entry) => entry !== scope) : [...current, scope],
173
+ )
174
+ }
175
+
176
+ function isScopeChecked(scope: ApiKeyGranularScope): boolean {
177
+ if (accessMode === 'custom') {
178
+ return selectedCustom.includes(scope)
179
+ }
180
+
181
+ if (accessMode === 'presets') {
182
+ return presetExpandedScopes.has(scope)
183
+ }
184
+
185
+ return false
186
+ }
187
+
123
188
  return (
124
189
  <div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4">
125
- <div className="animate-slide-in w-full max-w-md rounded-md border border-border bg-surface-raised p-5">
190
+ <div className="animate-slide-in max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-md border border-border bg-surface-raised p-5">
126
191
  <form onSubmit={submit}>
127
192
  <h2 className="text-[15px] font-semibold text-ink">{title}</h2>
128
193
  <label className="mt-4 block">
@@ -139,6 +204,113 @@ function CreateKeyModal({
139
204
  />
140
205
  </label>
141
206
 
207
+ <fieldset className="mt-4">
208
+ <legend className="mb-2 text-[12px] font-medium text-ink">Access</legend>
209
+ <div className="space-y-2">
210
+ <label className="flex items-center gap-2 text-[13px] text-ink">
211
+ <input
212
+ type="radio"
213
+ name="access"
214
+ checked={accessMode === 'full'}
215
+ onChange={() => {
216
+ setAccessMode('full')
217
+ setSelectedPresets([])
218
+ setSelectedCustom([])
219
+ setShowCustom(false)
220
+ }}
221
+ />
222
+ Full access
223
+ </label>
224
+ <div className="rounded-md border border-border p-3">
225
+ <p className="mb-2 text-[11px] font-medium tracking-wide text-ink-muted uppercase">
226
+ Presets
227
+ </p>
228
+ <div className="space-y-1.5">
229
+ {PRESET_OPTIONS.map((scope) => (
230
+ <label key={scope} className="flex items-center gap-2 text-[13px] text-ink">
231
+ <input
232
+ type="checkbox"
233
+ checked={accessMode === 'presets' && selectedPresets.includes(scope)}
234
+ onChange={() => {
235
+ togglePreset(scope)
236
+ }}
237
+ />
238
+ {API_KEY_SCOPE_LABELS[scope]}
239
+ </label>
240
+ ))}
241
+ </div>
242
+ </div>
243
+ {accessMode !== 'presets' && (
244
+ <button
245
+ type="button"
246
+ onClick={() => {
247
+ setShowCustom((open) => !open)
248
+ if (!showCustom) {
249
+ setAccessMode('custom')
250
+ setSelectedPresets([])
251
+ }
252
+ }}
253
+ className="text-[12px] font-medium text-accent hover:underline"
254
+ >
255
+ {showCustom ? 'Hide custom scopes' : 'Custom scopes…'}
256
+ </button>
257
+ )}
258
+ {showScopeGrid && (
259
+ <div className="space-y-3 rounded-md border border-border p-3">
260
+ {accessMode === 'presets' && (
261
+ <p className="text-[11px] text-ink-muted">
262
+ Included in the selected preset{selectedPresets.length === 1 ? '' : 's'}.
263
+ </p>
264
+ )}
265
+ {API_KEY_SCOPE_GROUPS.map((group) => (
266
+ <div key={group.label}>
267
+ <p className="mb-1.5 text-[11px] font-medium tracking-wide text-ink-muted uppercase">
268
+ {group.label}
269
+ </p>
270
+ <div className="grid grid-cols-1 gap-1 sm:grid-cols-2">
271
+ {group.scopes.map((scope) => (
272
+ <label
273
+ key={scope}
274
+ className={`flex items-center gap-2 text-[12px] ${
275
+ accessMode === 'presets' ? 'text-ink-muted' : 'text-ink'
276
+ }`}
277
+ >
278
+ <input
279
+ type="checkbox"
280
+ checked={isScopeChecked(scope)}
281
+ disabled={accessMode === 'presets'}
282
+ onChange={() => {
283
+ toggleCustom(scope)
284
+ }}
285
+ />
286
+ {API_KEY_SCOPE_LABELS[scope]}
287
+ </label>
288
+ ))}
289
+ </div>
290
+ </div>
291
+ ))}
292
+ {accessMode === 'presets' && (
293
+ <button
294
+ type="button"
295
+ onClick={() => {
296
+ setAccessMode('custom')
297
+ setSelectedPresets([])
298
+ setSelectedCustom([...presetExpandedScopes])
299
+ setShowCustom(true)
300
+ }}
301
+ className="text-[12px] font-medium text-accent hover:underline"
302
+ >
303
+ Switch to custom scopes…
304
+ </button>
305
+ )}
306
+ </div>
307
+ )}
308
+ </div>
309
+ <p className="mt-2 text-[11px] text-ink-faint">
310
+ Write includes read for the same resource. Presets cover many scopes at once.
311
+ </p>
312
+ </fieldset>
313
+
142
314
  {createApiKey.error !== null && (
143
315
  <div className="mt-3">
144
316
  <ErrorPanel error={createApiKey.error} />
@@ -167,12 +339,6 @@ function CreateKeyModal({
167
339
  )
168
340
  }
169
341
 
170
- /**
171
- * The secret, shown once.
172
- *
173
- * Nothing can retrieve it afterwards, so this panel says so plainly instead of
174
- * leaving a reader to discover it by coming back for the value later.
175
- */
176
342
  function SecretOnce({
177
343
  apiKey,
178
344
  onDismiss,
@@ -202,6 +368,27 @@ function SecretOnce({
202
368
  )
203
369
  }
204
370
 
371
+ function formatScopeSummary(scopes: readonly ApiKeyScope[]): string {
372
+ if (scopes.length === 0) {
373
+ return 'Full access'
374
+ }
375
+
376
+ const presets = scopes.filter(isApiKeyPresetScope)
377
+ const granular = scopes.filter(isApiKeyGranularScope)
378
+
379
+ if (granular.length === 0) {
380
+ return presets.map((scope) => API_KEY_SCOPE_LABELS[scope]).join(', ')
381
+ }
382
+
383
+ if (presets.length === 0) {
384
+ return `Custom (${String(scopes.length)} scopes)`
385
+ }
386
+
387
+ return [...presets.map((scope) => API_KEY_SCOPE_LABELS[scope]), `Custom (${String(granular.length)})`].join(
388
+ ', ',
389
+ )
390
+ }
391
+
205
392
  function ApiKeyTable({
206
393
  keys,
207
394
  emptyMessage,
@@ -227,6 +414,7 @@ function ApiKeyTable({
227
414
  <thead>
228
415
  <tr className="border-b border-border bg-surface text-[11px] font-semibold tracking-wide text-ink-muted uppercase">
229
416
  <th className="px-4 py-2.5">Name</th>
417
+ <th className="px-4 py-2.5">Scopes</th>
230
418
  <th className="px-4 py-2.5">Prefix</th>
231
419
  <th className="px-4 py-2.5">Created</th>
232
420
  <th className="px-4 py-2.5">Last used</th>
@@ -243,10 +431,6 @@ function ApiKeyTable({
243
431
  )
244
432
  }
245
433
 
246
- /**
247
- * Revoking a key takes effect at once and cannot be undone, so this asks first,
248
- * the same two-step confirm `WebhooksPage` uses for removing a registration.
249
- */
250
434
  function ApiKeyRow({
251
435
  apiKey,
252
436
  onRevoke,
@@ -262,6 +446,7 @@ function ApiKeyRow({
262
446
  return (
263
447
  <tr className="border-b border-border last:border-0">
264
448
  <td className="px-4 py-3 font-medium">{apiKey.name}</td>
449
+ <td className="px-4 py-3 text-ink-muted">{formatScopeSummary(apiKey.scopes)}</td>
265
450
  <td className="px-4 py-3 font-mono text-[12px] text-ink-muted">{apiKey.displayPrefix}</td>
266
451
  <td className="px-4 py-3 text-ink-muted">{formatDate(apiKey.createdAt, timezone)}</td>
267
452
  <td className="px-4 py-3 text-ink-muted">