@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.
- package/dist/api/resources/account.d.ts +5 -0
- package/dist/api/resources/account.d.ts.map +1 -1
- package/dist/api/resources/account.js +11 -0
- package/dist/api/resources/account.js.map +1 -1
- package/dist/api/resources/apiKeys.d.ts +15 -0
- package/dist/api/resources/apiKeys.d.ts.map +1 -0
- package/dist/api/resources/apiKeys.js +50 -0
- package/dist/api/resources/apiKeys.js.map +1 -0
- package/dist/app/KelpieApp.d.ts.map +1 -1
- package/dist/app/KelpieApp.js +3 -1
- package/dist/app/KelpieApp.js.map +1 -1
- package/dist/components/ActivitiesPanel.d.ts.map +1 -1
- package/dist/components/ActivitiesPanel.js +10 -7
- package/dist/components/ActivitiesPanel.js.map +1 -1
- package/dist/components/ApiKeysPanel.d.ts +20 -0
- package/dist/components/ApiKeysPanel.d.ts.map +1 -0
- package/dist/components/ApiKeysPanel.js +70 -0
- package/dist/components/ApiKeysPanel.js.map +1 -0
- package/dist/components/DecisionsPanel.d.ts.map +1 -1
- package/dist/components/DecisionsPanel.js +3 -1
- package/dist/components/DecisionsPanel.js.map +1 -1
- package/dist/components/NotesPanel.d.ts.map +1 -1
- package/dist/components/NotesPanel.js +3 -1
- package/dist/components/NotesPanel.js.map +1 -1
- package/dist/components/Shell.d.ts.map +1 -1
- package/dist/components/Shell.js +1 -0
- package/dist/components/Shell.js.map +1 -1
- package/dist/lib/dates.d.ts +25 -5
- package/dist/lib/dates.d.ts.map +1 -1
- package/dist/lib/dates.js +49 -20
- package/dist/lib/dates.js.map +1 -1
- package/dist/pages/DecisionsPage.d.ts.map +1 -1
- package/dist/pages/DecisionsPage.js +4 -2
- package/dist/pages/DecisionsPage.js.map +1 -1
- package/dist/pages/HiringPage.d.ts.map +1 -1
- package/dist/pages/HiringPage.js +3 -1
- package/dist/pages/HiringPage.js.map +1 -1
- package/dist/pages/PlanningPage.js +1 -1
- package/dist/pages/PlanningPage.js.map +1 -1
- package/dist/pages/account/AccountLayout.d.ts.map +1 -1
- package/dist/pages/account/AccountLayout.js +1 -0
- package/dist/pages/account/AccountLayout.js.map +1 -1
- package/dist/pages/account/PersonalApiKeysPage.d.ts +9 -0
- package/dist/pages/account/PersonalApiKeysPage.d.ts.map +1 -0
- package/dist/pages/account/PersonalApiKeysPage.js +14 -0
- package/dist/pages/account/PersonalApiKeysPage.js.map +1 -0
- package/dist/pages/account/PreferencesPage.js +1 -1
- package/dist/pages/account/PreferencesPage.js.map +1 -1
- package/dist/pages/account/SecurityPage.d.ts.map +1 -1
- package/dist/pages/account/SecurityPage.js +3 -2
- package/dist/pages/account/SecurityPage.js.map +1 -1
- package/dist/pages/admin/ApiKeysPage.d.ts +9 -0
- package/dist/pages/admin/ApiKeysPage.d.ts.map +1 -0
- package/dist/pages/admin/ApiKeysPage.js +20 -0
- package/dist/pages/admin/ApiKeysPage.js.map +1 -0
- package/dist/pages/admin/McpPage.d.ts.map +1 -1
- package/dist/pages/admin/McpPage.js +6 -3
- package/dist/pages/admin/McpPage.js.map +1 -1
- package/dist/pages/admin/WebhookDeliveries.d.ts.map +1 -1
- package/dist/pages/admin/WebhookDeliveries.js +4 -2
- package/dist/pages/admin/WebhookDeliveries.js.map +1 -1
- package/dist/pages/admin/WebhooksPage.d.ts.map +1 -1
- package/dist/pages/admin/WebhooksPage.js +5 -3
- package/dist/pages/admin/WebhooksPage.js.map +1 -1
- package/dist/pages/dashboard/DashboardPage.d.ts.map +1 -1
- package/dist/pages/dashboard/DashboardPage.js +6 -4
- package/dist/pages/dashboard/DashboardPage.js.map +1 -1
- package/dist/pages/dashboard/sections.d.ts +6 -3
- package/dist/pages/dashboard/sections.d.ts.map +1 -1
- package/dist/pages/dashboard/sections.js +6 -6
- package/dist/pages/dashboard/sections.js.map +1 -1
- package/dist/pages/forms/SubmissionsTable.d.ts.map +1 -1
- package/dist/pages/forms/SubmissionsTable.js +3 -1
- package/dist/pages/forms/SubmissionsTable.js.map +1 -1
- package/dist/pages/handbook/HandbookPage.d.ts.map +1 -1
- package/dist/pages/handbook/HandbookPage.js +3 -1
- package/dist/pages/handbook/HandbookPage.js.map +1 -1
- package/package.json +2 -2
- package/src/api/resources/account.ts +13 -0
- package/src/api/resources/apiKeys.ts +60 -0
- package/src/app/KelpieApp.tsx +4 -0
- package/src/components/ActivitiesPanel.tsx +15 -4
- package/src/components/ApiKeysPanel.tsx +307 -0
- package/src/components/DecisionsPanel.tsx +5 -2
- package/src/components/NotesPanel.tsx +4 -1
- package/src/components/Shell.tsx +1 -0
- package/src/lib/dates.ts +54 -20
- package/src/pages/DecisionsPage.tsx +6 -2
- package/src/pages/HiringPage.tsx +3 -1
- package/src/pages/PlanningPage.tsx +1 -1
- package/src/pages/account/AccountLayout.tsx +1 -0
- package/src/pages/account/PersonalApiKeysPage.tsx +27 -0
- package/src/pages/account/PreferencesPage.tsx +1 -1
- package/src/pages/account/SecurityPage.tsx +4 -1
- package/src/pages/admin/ApiKeysPage.tsx +43 -0
- package/src/pages/admin/McpPage.tsx +7 -4
- package/src/pages/admin/WebhookDeliveries.tsx +4 -2
- package/src/pages/admin/WebhooksPage.tsx +5 -3
- package/src/pages/dashboard/DashboardPage.tsx +14 -4
- package/src/pages/dashboard/sections.tsx +9 -3
- package/src/pages/forms/SubmissionsTable.tsx +3 -1
- 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.
|
|
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.
|
|
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
|
+
}
|
package/src/app/KelpieApp.tsx
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
)
|
package/src/components/Shell.tsx
CHANGED
|
@@ -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 },
|