@kelpie/ui 0.4.0 → 0.4.1

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 (102) hide show
  1. package/dist/api/resources/account.d.ts +5 -0
  2. package/dist/api/resources/account.d.ts.map +1 -1
  3. package/dist/api/resources/account.js +11 -0
  4. package/dist/api/resources/account.js.map +1 -1
  5. package/dist/api/resources/apiKeys.d.ts +15 -0
  6. package/dist/api/resources/apiKeys.d.ts.map +1 -0
  7. package/dist/api/resources/apiKeys.js +50 -0
  8. package/dist/api/resources/apiKeys.js.map +1 -0
  9. package/dist/app/KelpieApp.d.ts.map +1 -1
  10. package/dist/app/KelpieApp.js +3 -1
  11. package/dist/app/KelpieApp.js.map +1 -1
  12. package/dist/components/ActivitiesPanel.d.ts.map +1 -1
  13. package/dist/components/ActivitiesPanel.js +10 -7
  14. package/dist/components/ActivitiesPanel.js.map +1 -1
  15. package/dist/components/ApiKeysPanel.d.ts +20 -0
  16. package/dist/components/ApiKeysPanel.d.ts.map +1 -0
  17. package/dist/components/ApiKeysPanel.js +70 -0
  18. package/dist/components/ApiKeysPanel.js.map +1 -0
  19. package/dist/components/DecisionsPanel.d.ts.map +1 -1
  20. package/dist/components/DecisionsPanel.js +3 -1
  21. package/dist/components/DecisionsPanel.js.map +1 -1
  22. package/dist/components/NotesPanel.d.ts.map +1 -1
  23. package/dist/components/NotesPanel.js +3 -1
  24. package/dist/components/NotesPanel.js.map +1 -1
  25. package/dist/components/Shell.d.ts.map +1 -1
  26. package/dist/components/Shell.js +1 -0
  27. package/dist/components/Shell.js.map +1 -1
  28. package/dist/lib/dates.d.ts +25 -5
  29. package/dist/lib/dates.d.ts.map +1 -1
  30. package/dist/lib/dates.js +49 -20
  31. package/dist/lib/dates.js.map +1 -1
  32. package/dist/pages/DecisionsPage.d.ts.map +1 -1
  33. package/dist/pages/DecisionsPage.js +4 -2
  34. package/dist/pages/DecisionsPage.js.map +1 -1
  35. package/dist/pages/HiringPage.d.ts.map +1 -1
  36. package/dist/pages/HiringPage.js +3 -1
  37. package/dist/pages/HiringPage.js.map +1 -1
  38. package/dist/pages/PlanningPage.js +1 -1
  39. package/dist/pages/PlanningPage.js.map +1 -1
  40. package/dist/pages/account/AccountLayout.d.ts.map +1 -1
  41. package/dist/pages/account/AccountLayout.js +1 -0
  42. package/dist/pages/account/AccountLayout.js.map +1 -1
  43. package/dist/pages/account/PersonalApiKeysPage.d.ts +9 -0
  44. package/dist/pages/account/PersonalApiKeysPage.d.ts.map +1 -0
  45. package/dist/pages/account/PersonalApiKeysPage.js +14 -0
  46. package/dist/pages/account/PersonalApiKeysPage.js.map +1 -0
  47. package/dist/pages/account/PreferencesPage.js +1 -1
  48. package/dist/pages/account/PreferencesPage.js.map +1 -1
  49. package/dist/pages/account/SecurityPage.d.ts.map +1 -1
  50. package/dist/pages/account/SecurityPage.js +3 -2
  51. package/dist/pages/account/SecurityPage.js.map +1 -1
  52. package/dist/pages/admin/ApiKeysPage.d.ts +9 -0
  53. package/dist/pages/admin/ApiKeysPage.d.ts.map +1 -0
  54. package/dist/pages/admin/ApiKeysPage.js +20 -0
  55. package/dist/pages/admin/ApiKeysPage.js.map +1 -0
  56. package/dist/pages/admin/McpPage.d.ts.map +1 -1
  57. package/dist/pages/admin/McpPage.js +6 -3
  58. package/dist/pages/admin/McpPage.js.map +1 -1
  59. package/dist/pages/admin/WebhookDeliveries.d.ts.map +1 -1
  60. package/dist/pages/admin/WebhookDeliveries.js +4 -2
  61. package/dist/pages/admin/WebhookDeliveries.js.map +1 -1
  62. package/dist/pages/admin/WebhooksPage.d.ts.map +1 -1
  63. package/dist/pages/admin/WebhooksPage.js +5 -3
  64. package/dist/pages/admin/WebhooksPage.js.map +1 -1
  65. package/dist/pages/dashboard/DashboardPage.d.ts.map +1 -1
  66. package/dist/pages/dashboard/DashboardPage.js +6 -4
  67. package/dist/pages/dashboard/DashboardPage.js.map +1 -1
  68. package/dist/pages/dashboard/sections.d.ts +6 -3
  69. package/dist/pages/dashboard/sections.d.ts.map +1 -1
  70. package/dist/pages/dashboard/sections.js +6 -6
  71. package/dist/pages/dashboard/sections.js.map +1 -1
  72. package/dist/pages/forms/SubmissionsTable.d.ts.map +1 -1
  73. package/dist/pages/forms/SubmissionsTable.js +3 -1
  74. package/dist/pages/forms/SubmissionsTable.js.map +1 -1
  75. package/dist/pages/handbook/HandbookPage.d.ts.map +1 -1
  76. package/dist/pages/handbook/HandbookPage.js +3 -1
  77. package/dist/pages/handbook/HandbookPage.js.map +1 -1
  78. package/package.json +2 -2
  79. package/src/api/resources/account.ts +13 -0
  80. package/src/api/resources/apiKeys.ts +60 -0
  81. package/src/app/KelpieApp.tsx +4 -0
  82. package/src/components/ActivitiesPanel.tsx +15 -4
  83. package/src/components/ApiKeysPanel.tsx +307 -0
  84. package/src/components/DecisionsPanel.tsx +5 -2
  85. package/src/components/NotesPanel.tsx +4 -1
  86. package/src/components/Shell.tsx +1 -0
  87. package/src/lib/dates.ts +54 -20
  88. package/src/pages/DecisionsPage.tsx +6 -2
  89. package/src/pages/HiringPage.tsx +3 -1
  90. package/src/pages/PlanningPage.tsx +1 -1
  91. package/src/pages/account/AccountLayout.tsx +1 -0
  92. package/src/pages/account/PersonalApiKeysPage.tsx +27 -0
  93. package/src/pages/account/PreferencesPage.tsx +1 -1
  94. package/src/pages/account/SecurityPage.tsx +4 -1
  95. package/src/pages/admin/ApiKeysPage.tsx +43 -0
  96. package/src/pages/admin/McpPage.tsx +7 -4
  97. package/src/pages/admin/WebhookDeliveries.tsx +4 -2
  98. package/src/pages/admin/WebhooksPage.tsx +5 -3
  99. package/src/pages/dashboard/DashboardPage.tsx +14 -4
  100. package/src/pages/dashboard/sections.tsx +9 -3
  101. package/src/pages/forms/SubmissionsTable.tsx +3 -1
  102. package/src/pages/handbook/HandbookPage.tsx +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kelpie/ui",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "React UI for Kelpie: components, pages, API client, and the extension registry an assembly composes modules through.",
5
5
  "keywords": [
6
6
  "kelpie",
@@ -48,7 +48,7 @@
48
48
  "@dnd-kit/core": "^6.3.1",
49
49
  "@dnd-kit/sortable": "^10.0.0",
50
50
  "@dnd-kit/utilities": "^3.2.2",
51
- "@kelpie/schemas": "^0.4.0",
51
+ "@kelpie/schemas": "^0.4.1",
52
52
  "@tanstack/react-query": "^5.101.4",
53
53
  "react-markdown": "^10.1.0",
54
54
  "react-router": "^8.3.0",
@@ -18,11 +18,13 @@ import type {
18
18
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
19
19
  import { useEffect, useState } from 'react'
20
20
 
21
+ import { resolveTimezone } from '../../lib/dates.ts'
21
22
  import { applyTheme, getStoredTheme, setStoredTheme, watchSystemTheme } from '../../lib/theme.ts'
22
23
  import { useApiClient } from '../context.ts'
23
24
  import { toError } from '../errors.ts'
24
25
  import type { MutationResult } from '../resource.ts'
25
26
  import { asMutationResult } from './mutation.ts'
27
+ import { useWorkspace } from './workspace.ts'
26
28
 
27
29
  /**
28
30
  * The signed-in person, as the `/account/*` pages read and write them.
@@ -218,3 +220,14 @@ export function useTheme(): ThemeControl {
218
220
 
219
221
  return { theme, setTheme }
220
222
  }
223
+
224
+ /**
225
+ * The zone dates render in: the account's own choice, falling back to the
226
+ * workspace's while either query is still loading.
227
+ */
228
+ export function useTimezone(): string {
229
+ const { preferences } = useAccountPreferences()
230
+ const { workspace } = useWorkspace()
231
+
232
+ return resolveTimezone(preferences?.timezone, workspace?.timezone)
233
+ }
@@ -0,0 +1,60 @@
1
+ import { apiKeySchema, createApiKeyBody, createdApiKeySchema } from '@kelpie/schemas'
2
+ import type { ApiKey, ApiKeyKind, CreateApiKeyInput, CreatedApiKey } from '@kelpie/schemas'
3
+ import { useMutation, useQueryClient } from '@tanstack/react-query'
4
+
5
+ import { useApiClient } from '../context.ts'
6
+ import { createReadOnlyResourceHooks } from '../resource.ts'
7
+ import type { MutationResult, RecordListResult } from '../resource.ts'
8
+ import { asMutationResult } from './mutation.ts'
9
+
10
+ /**
11
+ * `/v1/api-keys`: workspace keys and personal keys, one endpoint told apart by
12
+ * `kind`. `kind` is required on every list request — the server answers `422`
13
+ * without it — so each page asks for exactly the one kind it manages.
14
+ */
15
+
16
+ const { useList } = createReadOnlyResourceHooks<ApiKey>({
17
+ name: 'apiKeys',
18
+ path: '/api-keys',
19
+ decode: apiKeySchema.parse,
20
+ })
21
+
22
+ export function useApiKeys(kind: ApiKeyKind): RecordListResult<ApiKey> {
23
+ return useList({ kind })
24
+ }
25
+
26
+ /**
27
+ * Mints a key.
28
+ *
29
+ * Written out rather than taken from `createResourceHooks`, because the `201`
30
+ * is the only response that ever carries the secret and the shared hook decodes
31
+ * with the schema that has no such field. The secret has to survive the decode:
32
+ * it cannot be fetched again, so a page that dropped it would have created a key
33
+ * nobody can use.
34
+ */
35
+ export function useCreateApiKey(): MutationResult<CreateApiKeyInput, CreatedApiKey> {
36
+ const client = useApiClient()
37
+ const queryClient = useQueryClient()
38
+ const mutation = useMutation({
39
+ mutationFn: (input: CreateApiKeyInput) =>
40
+ client.post('/api-keys', createApiKeyBody(input), createdApiKeySchema.parse),
41
+ onSettled: () => {
42
+ void queryClient.invalidateQueries({ queryKey: ['apiKeys', 'list'] })
43
+ },
44
+ })
45
+
46
+ return asMutationResult(mutation)
47
+ }
48
+
49
+ export function useRevokeApiKey(): MutationResult<string, void> {
50
+ const client = useApiClient()
51
+ const queryClient = useQueryClient()
52
+ const mutation = useMutation({
53
+ mutationFn: (id: string) => client.delete(`/api-keys/${id}`),
54
+ onSettled: () => {
55
+ void queryClient.invalidateQueries({ queryKey: ['apiKeys', 'list'] })
56
+ },
57
+ })
58
+
59
+ return asMutationResult(mutation)
60
+ }
@@ -27,9 +27,11 @@ import { RaiseDetail } from '../pages/RaiseDetail.tsx'
27
27
  import { RaiseStageSettingsPage } from '../pages/RaiseStageSettingsPage.tsx'
28
28
  import { RoleDetail } from '../pages/RoleDetail.tsx'
29
29
  import { AccountLayout } from '../pages/account/AccountLayout.tsx'
30
+ import { PersonalApiKeysPage } from '../pages/account/PersonalApiKeysPage.tsx'
30
31
  import { PreferencesPage } from '../pages/account/PreferencesPage.tsx'
31
32
  import { ProfilePage } from '../pages/account/ProfilePage.tsx'
32
33
  import { SecurityPage } from '../pages/account/SecurityPage.tsx'
34
+ import { ApiKeysPage } from '../pages/admin/ApiKeysPage.tsx'
33
35
  import { DataPage } from '../pages/admin/DataPage.tsx'
34
36
  import { McpPage } from '../pages/admin/McpPage.tsx'
35
37
  import { ModulesPage } from '../pages/admin/ModulesPage.tsx'
@@ -155,6 +157,7 @@ function AppRoutes(): React.JSX.Element {
155
157
  <Route path="admin/workspace" element={<WorkspaceSettingsPage />} />
156
158
  <Route path="admin/team" element={<TeamPage />} />
157
159
  <Route path="admin/data" element={<DataPage />} />
160
+ <Route path="admin/api-keys" element={<ApiKeysPage />} />
158
161
  <Route path="admin/mcp" element={<McpPage />} />
159
162
  <Route path="admin/webhooks" element={<WebhooksPage />} />
160
163
  <Route path="admin/modules" element={<ModulesPage />} />
@@ -164,6 +167,7 @@ function AppRoutes(): React.JSX.Element {
164
167
  <Route path="profile" element={<ProfilePage />} />
165
168
  <Route path="security" element={<SecurityPage />} />
166
169
  <Route path="preferences" element={<PreferencesPage />} />
170
+ <Route path="api-keys" element={<PersonalApiKeysPage />} />
167
171
  {accountRoutes.map((route) => (
168
172
  <Route
169
173
  key={route.path}
@@ -1,5 +1,6 @@
1
1
  import type { Activity, ActivityKind, RecordTargetType } from '@kelpie/schemas'
2
2
 
3
+ import { useTimezone } from '../api/resources/account.ts'
3
4
  import { useActivities } from '../api/resources/activities.ts'
4
5
  import { useMembers } from '../api/resources/members.ts'
5
6
  import { formatRelativeTime, monthLabel } from '../lib/dates.ts'
@@ -55,11 +56,15 @@ interface MonthGroup {
55
56
  }
56
57
 
57
58
  /** Consecutive runs, not a map: the list is already ordered, so a month never recurs. */
58
- function groupByMonth(activities: readonly Activity[], now: Date): readonly MonthGroup[] {
59
+ function groupByMonth(
60
+ activities: readonly Activity[],
61
+ timezone: string,
62
+ now: Date,
63
+ ): readonly MonthGroup[] {
59
64
  const groups: { label: string; items: Activity[] }[] = []
60
65
 
61
66
  for (const activity of activities) {
62
- const label = monthLabel(activity.createdAt, now)
67
+ const label = monthLabel(activity.createdAt, timezone, now)
63
68
  const last = groups.at(-1)
64
69
 
65
70
  if (last?.label === label) {
@@ -78,7 +83,8 @@ export function ActivitiesPanel({
78
83
  }: ActivitiesPanelProps): React.JSX.Element {
79
84
  const activities = useActivities({ targetType, targetId })
80
85
  const members = useMembers()
81
- const groups = groupByMonth(activities.records, new Date())
86
+ const timezone = useTimezone()
87
+ const groups = groupByMonth(activities.records, timezone, new Date())
82
88
 
83
89
  return (
84
90
  <section>
@@ -104,6 +110,7 @@ export function ActivitiesPanel({
104
110
  contextType={targetType}
105
111
  contextId={targetId}
106
112
  isLast={index === group.items.length - 1}
113
+ timezone={timezone}
107
114
  />
108
115
  ))}
109
116
  </ol>
@@ -137,6 +144,7 @@ export function LatestActivity({
137
144
  }: ActivitiesPanelProps): React.JSX.Element {
138
145
  const activities = useActivities({ targetType, targetId })
139
146
  const members = useMembers()
147
+ const timezone = useTimezone()
140
148
  const latest = activities.records[0]
141
149
 
142
150
  return (
@@ -158,6 +166,7 @@ export function LatestActivity({
158
166
  contextType={targetType}
159
167
  contextId={targetId}
160
168
  isLast
169
+ timezone={timezone}
161
170
  />
162
171
  </ol>
163
172
  )}
@@ -183,12 +192,14 @@ function ActivityEvent({
183
192
  contextType,
184
193
  contextId,
185
194
  isLast,
195
+ timezone,
186
196
  }: {
187
197
  readonly activity: Activity
188
198
  readonly actorName: string
189
199
  readonly contextType: RecordTargetType
190
200
  readonly contextId: string
191
201
  readonly isLast: boolean
202
+ readonly timezone: string
192
203
  }): React.JSX.Element {
193
204
  const isRolledUp = activity.targetType !== contextType || activity.targetId !== contextId
194
205
 
@@ -218,7 +229,7 @@ function ActivityEvent({
218
229
  dateTime={activity.createdAt.toISOString()}
219
230
  className="shrink-0 text-[11px] text-ink-faint"
220
231
  >
221
- {formatRelativeTime(activity.createdAt)}
232
+ {formatRelativeTime(activity.createdAt, timezone)}
222
233
  </time>
223
234
  </div>
224
235
  {isRolledUp && (
@@ -0,0 +1,307 @@
1
+ import type { ApiKey, ApiKeyKind, CreatedApiKey } from '@kelpie/schemas'
2
+ import { useState } from 'react'
3
+ import type { FormEvent } from 'react'
4
+
5
+ import { useTimezone } from '../api/resources/account.ts'
6
+ import { useApiKeys, useCreateApiKey, useRevokeApiKey } from '../api/resources/apiKeys.ts'
7
+ import { formatDate } from '../lib/dates.ts'
8
+ import { CopyButton } from './CopyButton.tsx'
9
+ import { ErrorPanel, LoadingPanel } from './QueryState.tsx'
10
+
11
+ /**
12
+ * The create/list/revoke flow shared by workspace keys (`/admin/api-keys`) and
13
+ * 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
+ */
21
+
22
+ export interface ApiKeysPanelProps {
23
+ readonly kind: ApiKeyKind
24
+ /** Shown in the create form's name field, e.g. "CI pipeline" or "Laptop Claude". */
25
+ readonly namePlaceholder: string
26
+ readonly createTitle: string
27
+ readonly emptyMessage: string
28
+ }
29
+
30
+ export function ApiKeysPanel({
31
+ kind,
32
+ namePlaceholder,
33
+ createTitle,
34
+ emptyMessage,
35
+ }: ApiKeysPanelProps): React.JSX.Element {
36
+ const { records, isLoading, error } = useApiKeys(kind)
37
+ const revokeApiKey = useRevokeApiKey()
38
+ const [showModal, setShowModal] = useState(false)
39
+ const [minted, setMinted] = useState<CreatedApiKey | null>(null)
40
+
41
+ return (
42
+ <div className="space-y-4">
43
+ <div className="flex justify-end">
44
+ <button
45
+ type="button"
46
+ onClick={() => {
47
+ setShowModal(true)
48
+ }}
49
+ className="shrink-0 rounded-md bg-accent px-3.5 py-2 text-[12px] font-semibold text-accent-fg transition hover:bg-accent-hover"
50
+ >
51
+ Create key
52
+ </button>
53
+ </div>
54
+
55
+ {minted !== null && (
56
+ <SecretOnce
57
+ apiKey={minted}
58
+ onDismiss={() => {
59
+ setMinted(null)
60
+ }}
61
+ />
62
+ )}
63
+
64
+ {error !== null && <ErrorPanel error={error} />}
65
+
66
+ {isLoading ? (
67
+ <LoadingPanel label="Loading API keys…" />
68
+ ) : (
69
+ <ApiKeyTable
70
+ keys={records}
71
+ emptyMessage={emptyMessage}
72
+ onRevoke={(id) => {
73
+ revokeApiKey.run(id)
74
+ }}
75
+ isRevoking={revokeApiKey.isPending}
76
+ />
77
+ )}
78
+
79
+ {revokeApiKey.error !== null && <ErrorPanel error={revokeApiKey.error} />}
80
+
81
+ {showModal && (
82
+ <CreateKeyModal
83
+ kind={kind}
84
+ title={createTitle}
85
+ namePlaceholder={namePlaceholder}
86
+ onCreated={(key) => {
87
+ setShowModal(false)
88
+ setMinted(key)
89
+ }}
90
+ onCancel={() => {
91
+ setShowModal(false)
92
+ }}
93
+ />
94
+ )}
95
+ </div>
96
+ )
97
+ }
98
+
99
+ function CreateKeyModal({
100
+ kind,
101
+ title,
102
+ namePlaceholder,
103
+ onCreated,
104
+ onCancel,
105
+ }: {
106
+ readonly kind: ApiKeyKind
107
+ readonly title: string
108
+ readonly namePlaceholder: string
109
+ readonly onCreated: (key: CreatedApiKey) => void
110
+ readonly onCancel: () => void
111
+ }): React.JSX.Element {
112
+ const createApiKey = useCreateApiKey()
113
+ const [name, setName] = useState('')
114
+
115
+ function submit(event: FormEvent): void {
116
+ event.preventDefault()
117
+ createApiKey
118
+ .runAsync({ name: name.trim(), kind })
119
+ .then(onCreated)
120
+ .catch(() => undefined)
121
+ }
122
+
123
+ return (
124
+ <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">
126
+ <form onSubmit={submit}>
127
+ <h2 className="text-[15px] font-semibold text-ink">{title}</h2>
128
+ <label className="mt-4 block">
129
+ <span className="mb-1.5 block text-[12px] font-medium">Name</span>
130
+ <input
131
+ value={name}
132
+ onChange={(event) => {
133
+ setName(event.target.value)
134
+ }}
135
+ placeholder={namePlaceholder}
136
+ required
137
+ autoFocus
138
+ className="w-full rounded-md border border-border bg-surface px-3 py-2 text-[13px] outline-none focus:border-accent focus:ring-2 focus:ring-accent/20"
139
+ />
140
+ </label>
141
+
142
+ {createApiKey.error !== null && (
143
+ <div className="mt-3">
144
+ <ErrorPanel error={createApiKey.error} />
145
+ </div>
146
+ )}
147
+
148
+ <div className="mt-4 flex justify-end gap-2">
149
+ <button
150
+ type="button"
151
+ onClick={onCancel}
152
+ className="rounded-md px-3 py-2 text-[12px] font-medium text-ink-muted hover:text-ink"
153
+ >
154
+ Cancel
155
+ </button>
156
+ <button
157
+ type="submit"
158
+ disabled={createApiKey.isPending}
159
+ className="rounded-md bg-accent px-3.5 py-2 text-[12px] font-semibold text-accent-fg hover:bg-accent-hover disabled:opacity-50"
160
+ >
161
+ {createApiKey.isPending ? 'Creating…' : 'Create'}
162
+ </button>
163
+ </div>
164
+ </form>
165
+ </div>
166
+ </div>
167
+ )
168
+ }
169
+
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
+ function SecretOnce({
177
+ apiKey,
178
+ onDismiss,
179
+ }: {
180
+ readonly apiKey: CreatedApiKey
181
+ readonly onDismiss: () => void
182
+ }): React.JSX.Element {
183
+ return (
184
+ <div className="rounded-md border border-accent/40 bg-accent-soft px-4 py-3">
185
+ <div className="flex items-start justify-between gap-3">
186
+ <div className="min-w-0">
187
+ <p className="text-[13px] font-medium text-ink">Copy this key now. It is not shown again.</p>
188
+ <code className="mt-2 block break-all rounded border border-border bg-surface px-3 py-2 font-mono text-[12px] text-ink">
189
+ {apiKey.secret}
190
+ </code>
191
+ </div>
192
+ <CopyButton value={apiKey.secret} label="Copy the API key" />
193
+ </div>
194
+ <button
195
+ type="button"
196
+ onClick={onDismiss}
197
+ className="mt-2 text-[12px] font-medium text-accent hover:underline"
198
+ >
199
+ I have copied it
200
+ </button>
201
+ </div>
202
+ )
203
+ }
204
+
205
+ function ApiKeyTable({
206
+ keys,
207
+ emptyMessage,
208
+ onRevoke,
209
+ isRevoking,
210
+ }: {
211
+ readonly keys: readonly ApiKey[]
212
+ readonly emptyMessage: string
213
+ readonly onRevoke: (id: string) => void
214
+ readonly isRevoking: boolean
215
+ }): React.JSX.Element {
216
+ if (keys.length === 0) {
217
+ return (
218
+ <p className="rounded-md border border-border px-4 py-10 text-center text-[13px] text-ink-faint">
219
+ {emptyMessage}
220
+ </p>
221
+ )
222
+ }
223
+
224
+ return (
225
+ <div className="overflow-hidden rounded-md border border-border">
226
+ <table className="w-full text-left text-[13px]">
227
+ <thead>
228
+ <tr className="border-b border-border bg-surface text-[11px] font-semibold tracking-wide text-ink-muted uppercase">
229
+ <th className="px-4 py-2.5">Name</th>
230
+ <th className="px-4 py-2.5">Prefix</th>
231
+ <th className="px-4 py-2.5">Created</th>
232
+ <th className="px-4 py-2.5">Last used</th>
233
+ <th className="px-4 py-2.5 text-right">Actions</th>
234
+ </tr>
235
+ </thead>
236
+ <tbody>
237
+ {keys.map((key) => (
238
+ <ApiKeyRow key={key.id} apiKey={key} onRevoke={onRevoke} isRevoking={isRevoking} />
239
+ ))}
240
+ </tbody>
241
+ </table>
242
+ </div>
243
+ )
244
+ }
245
+
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
+ function ApiKeyRow({
251
+ apiKey,
252
+ onRevoke,
253
+ isRevoking,
254
+ }: {
255
+ readonly apiKey: ApiKey
256
+ readonly onRevoke: (id: string) => void
257
+ readonly isRevoking: boolean
258
+ }): React.JSX.Element {
259
+ const [isConfirming, setIsConfirming] = useState(false)
260
+ const timezone = useTimezone()
261
+
262
+ return (
263
+ <tr className="border-b border-border last:border-0">
264
+ <td className="px-4 py-3 font-medium">{apiKey.name}</td>
265
+ <td className="px-4 py-3 font-mono text-[12px] text-ink-muted">{apiKey.displayPrefix}</td>
266
+ <td className="px-4 py-3 text-ink-muted">{formatDate(apiKey.createdAt, timezone)}</td>
267
+ <td className="px-4 py-3 text-ink-muted">
268
+ {apiKey.lastUsedAt === null ? 'Never' : formatDate(apiKey.lastUsedAt, timezone)}
269
+ </td>
270
+ <td className="px-4 py-3 text-right">
271
+ {isConfirming ? (
272
+ <span className="inline-flex items-center gap-3">
273
+ <button
274
+ type="button"
275
+ onClick={() => {
276
+ setIsConfirming(false)
277
+ }}
278
+ className="text-[12px] font-medium text-ink-muted hover:text-ink"
279
+ >
280
+ Cancel
281
+ </button>
282
+ <button
283
+ type="button"
284
+ disabled={isRevoking}
285
+ onClick={() => {
286
+ onRevoke(apiKey.id)
287
+ }}
288
+ className="text-[12px] font-medium text-danger hover:underline disabled:opacity-50"
289
+ >
290
+ {isRevoking ? 'Revoking…' : 'Revoke'}
291
+ </button>
292
+ </span>
293
+ ) : (
294
+ <button
295
+ type="button"
296
+ onClick={() => {
297
+ setIsConfirming(true)
298
+ }}
299
+ className="text-[12px] font-medium text-danger hover:underline"
300
+ >
301
+ Revoke
302
+ </button>
303
+ )}
304
+ </td>
305
+ </tr>
306
+ )
307
+ }
@@ -3,6 +3,7 @@ import { useState } from 'react'
3
3
  import type { FormEvent } from 'react'
4
4
  import { Link } from 'react-router'
5
5
 
6
+ import { useTimezone } from '../api/resources/account.ts'
6
7
  import { useCreateDecision, useDecisions, useDeleteDecision } from '../api/resources/decisions.ts'
7
8
  import { useMembers } from '../api/resources/members.ts'
8
9
  import { formatDate } from '../lib/dates.ts'
@@ -208,6 +209,8 @@ function DecisionItem({
208
209
  readonly ownerName: string | undefined
209
210
  readonly onRemove: () => void
210
211
  }): React.JSX.Element {
212
+ const timezone = useTimezone()
213
+
211
214
  return (
212
215
  <li className="border-b border-border px-4 py-3 last:border-0">
213
216
  <div className="flex items-start justify-between gap-2">
@@ -224,8 +227,8 @@ function DecisionItem({
224
227
  <p className="mt-1 text-[12px] text-ink-muted">{decision.rationale}</p>
225
228
  )}
226
229
  <div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-ink-faint">
227
- <span>Decided {formatDate(decision.decidedAt)}</span>
228
- {decision.dueAt !== null && <span>By {formatDate(decision.dueAt)}</span>}
230
+ <span>Decided {formatDate(decision.decidedAt, timezone)}</span>
231
+ {decision.dueAt !== null && <span>By {formatDate(decision.dueAt, timezone)}</span>}
229
232
  {ownerName !== undefined && <span>{ownerName}</span>}
230
233
  </div>
231
234
  </li>
@@ -2,6 +2,7 @@ import type { Note, RecordTargetType } from '@kelpie/schemas'
2
2
  import { useState } from 'react'
3
3
  import type { FormEvent } from 'react'
4
4
 
5
+ import { useTimezone } from '../api/resources/account.ts'
5
6
  import { useMembers } from '../api/resources/members.ts'
6
7
  import { useCreateNote, useNotes } from '../api/resources/notes.ts'
7
8
  import { formatDateTime } from '../lib/dates.ts'
@@ -149,6 +150,8 @@ function NoteItem({
149
150
  readonly note: Note
150
151
  readonly authorName: string
151
152
  }): React.JSX.Element {
153
+ const timezone = useTimezone()
154
+
152
155
  return (
153
156
  <li className="rounded-md border border-border bg-surface-raised px-3.5 py-3">
154
157
  <div className="flex items-start justify-between gap-2">
@@ -162,7 +165,7 @@ function NoteItem({
162
165
  <div className="mt-2 flex items-center gap-2 text-[11px] text-ink-faint">
163
166
  <span>{authorName}</span>
164
167
  <span>·</span>
165
- <span>{formatDateTime(note.createdAt)}</span>
168
+ <span>{formatDateTime(note.createdAt, timezone)}</span>
166
169
  </div>
167
170
  </li>
168
171
  )
@@ -23,6 +23,7 @@ const CORE_ADMIN_NAV: readonly NavItem[] = [
23
23
  { id: 'workspace', label: 'Workspace', to: '/admin/workspace', order: 100 },
24
24
  { id: 'team', label: 'Team', to: '/admin/team', order: 200 },
25
25
  { id: 'data', label: 'Data', to: '/admin/data', order: 300 },
26
+ { id: 'api-keys', label: 'API keys', to: '/admin/api-keys', order: 350 },
26
27
  { id: 'webhooks', label: 'Webhooks', to: '/admin/webhooks', order: 400 },
27
28
  { id: 'mcp', label: 'MCP', to: '/admin/mcp', order: 500 },
28
29
  { id: 'modules', label: 'Modules', to: '/admin/modules', order: 600 },