@kelpie/ui 0.4.0 → 0.5.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.
- 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/api/resources/invites.d.ts +0 -2
- package/dist/api/resources/invites.d.ts.map +1 -1
- package/dist/api/resources/invites.js +5 -10
- package/dist/api/resources/invites.js.map +1 -1
- package/dist/api/resources/session.d.ts +17 -3
- package/dist/api/resources/session.d.ts.map +1 -1
- package/dist/api/resources/session.js +34 -6
- package/dist/api/resources/session.js.map +1 -1
- package/dist/app/KelpieApp.d.ts.map +1 -1
- package/dist/app/KelpieApp.js +5 -1
- package/dist/app/KelpieApp.js.map +1 -1
- package/dist/app/SessionGate.d.ts +6 -2
- package/dist/app/SessionGate.d.ts.map +1 -1
- package/dist/app/SessionGate.js +10 -3
- package/dist/app/SessionGate.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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.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/auth/ForgotPasswordPage.js +2 -2
- package/dist/pages/auth/ForgotPasswordPage.js.map +1 -1
- package/dist/pages/auth/SignUpPage.d.ts +5 -3
- package/dist/pages/auth/SignUpPage.d.ts.map +1 -1
- package/dist/pages/auth/SignUpPage.js +12 -6
- package/dist/pages/auth/SignUpPage.js.map +1 -1
- package/dist/pages/auth/VerifyEmailConfirmPage.d.ts +9 -0
- package/dist/pages/auth/VerifyEmailConfirmPage.d.ts.map +1 -0
- package/dist/pages/auth/VerifyEmailConfirmPage.js +58 -0
- package/dist/pages/auth/VerifyEmailConfirmPage.js.map +1 -0
- package/dist/pages/auth/VerifyEmailPendingPage.d.ts +10 -0
- package/dist/pages/auth/VerifyEmailPendingPage.d.ts.map +1 -0
- package/dist/pages/auth/VerifyEmailPendingPage.js +35 -0
- package/dist/pages/auth/VerifyEmailPendingPage.js.map +1 -0
- 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/api/resources/invites.ts +5 -15
- package/src/api/resources/session.ts +42 -6
- package/src/app/KelpieApp.tsx +11 -2
- package/src/app/SessionGate.tsx +11 -3
- 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/index.ts +0 -1
- 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/auth/ForgotPasswordPage.tsx +2 -2
- package/src/pages/auth/SignUpPage.tsx +12 -6
- package/src/pages/auth/VerifyEmailConfirmPage.tsx +81 -0
- package/src/pages/auth/VerifyEmailPendingPage.tsx +65 -0
- 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
|
@@ -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 },
|
package/src/index.ts
CHANGED
package/src/lib/dates.ts
CHANGED
|
@@ -4,17 +4,32 @@
|
|
|
4
4
|
*
|
|
5
5
|
* `now` is a parameter rather than a call to `new Date()` inside, because a
|
|
6
6
|
* function that reads the clock cannot be tested against a fixed expectation.
|
|
7
|
+
* `timezone` is a parameter for the same reason, and because two people in
|
|
8
|
+
* different zones should see the day change at different moments. Resolve it
|
|
9
|
+
* once with `resolveTimezone` and pass the result in.
|
|
10
|
+
*
|
|
11
|
+
* The locale is left undefined throughout, which asks `Intl` for the runtime's
|
|
12
|
+
* own — the browser's, for every real caller. Formatters are built per call
|
|
13
|
+
* rather than cached, matching `dayIn` on the server: the zone varies by
|
|
14
|
+
* caller, and a cache keyed on it would only grow.
|
|
7
15
|
*/
|
|
8
16
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
/** The account's own choice, falling back to the workspace's, then to UTC. */
|
|
18
|
+
export function resolveTimezone(
|
|
19
|
+
userTimezone: string | undefined,
|
|
20
|
+
workspaceTimezone: string | undefined,
|
|
21
|
+
): string {
|
|
22
|
+
return userTimezone ?? workspaceTimezone ?? 'UTC'
|
|
23
|
+
}
|
|
15
24
|
|
|
16
|
-
export function formatDateTime(value: Date): string {
|
|
17
|
-
return
|
|
25
|
+
export function formatDateTime(value: Date, timezone: string): string {
|
|
26
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
27
|
+
day: 'numeric',
|
|
28
|
+
month: 'short',
|
|
29
|
+
hour: 'numeric',
|
|
30
|
+
minute: '2-digit',
|
|
31
|
+
timeZone: timezone,
|
|
32
|
+
}).format(value)
|
|
18
33
|
}
|
|
19
34
|
|
|
20
35
|
function plural(count: number, unit: string): string {
|
|
@@ -28,11 +43,11 @@ function plural(count: number, unit: string): string {
|
|
|
28
43
|
* panel means the clocks disagree, and phrasing it as elapsed time would hide
|
|
29
44
|
* that.
|
|
30
45
|
*/
|
|
31
|
-
export function formatRelativeTime(value: Date, now: Date = new Date()): string {
|
|
46
|
+
export function formatRelativeTime(value: Date, timezone: string, now: Date = new Date()): string {
|
|
32
47
|
const elapsedMs = now.getTime() - value.getTime()
|
|
33
48
|
|
|
34
49
|
if (elapsedMs < 0) {
|
|
35
|
-
return formatDateTime(value)
|
|
50
|
+
return formatDateTime(value, timezone)
|
|
36
51
|
}
|
|
37
52
|
|
|
38
53
|
const seconds = Math.round(elapsedMs / 1000)
|
|
@@ -64,27 +79,46 @@ export function formatRelativeTime(value: Date, now: Date = new Date()): string
|
|
|
64
79
|
return months < 12 ? plural(months, 'month') : plural(Math.round(months / 12), 'year')
|
|
65
80
|
}
|
|
66
81
|
|
|
67
|
-
const DAY = new Intl.DateTimeFormat(
|
|
82
|
+
const DAY = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
|
|
68
83
|
|
|
69
84
|
/**
|
|
70
85
|
* A date-only field (`YYYY-MM-DD`), as the mockup writes one. Parsed at midday
|
|
71
86
|
* so no timezone west or east of UTC shifts it onto a neighbouring day.
|
|
87
|
+
*
|
|
88
|
+
* Takes no `timezone`: the value is already a specific calendar day, usually
|
|
89
|
+
* one the server resolved against the workspace's own zone, not an instant to
|
|
90
|
+
* re-project into whoever is looking at it.
|
|
72
91
|
*/
|
|
73
92
|
export function formatDay(iso: string): string {
|
|
74
93
|
return DAY.format(new Date(`${iso}T12:00:00`))
|
|
75
94
|
}
|
|
76
95
|
|
|
77
96
|
/** A moment whose time of day does not matter to the reader, e.g. when a decision was made. */
|
|
78
|
-
export function formatDate(value: Date): string {
|
|
79
|
-
return
|
|
97
|
+
export function formatDate(value: Date, timezone: string): string {
|
|
98
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
99
|
+
day: 'numeric',
|
|
100
|
+
month: 'short',
|
|
101
|
+
year: 'numeric',
|
|
102
|
+
timeZone: timezone,
|
|
103
|
+
}).format(value)
|
|
80
104
|
}
|
|
81
105
|
|
|
82
|
-
|
|
83
|
-
|
|
106
|
+
/** The year `value` falls in, within `timezone`, as digits — for comparison, not display. */
|
|
107
|
+
function yearIn(value: Date, timezone: string): string {
|
|
108
|
+
return new Intl.DateTimeFormat(undefined, { year: 'numeric', timeZone: timezone }).format(value)
|
|
109
|
+
}
|
|
84
110
|
|
|
85
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
111
|
+
/**
|
|
112
|
+
* The heading a timeline groups under. The year is dropped for the current one.
|
|
113
|
+
*
|
|
114
|
+
* "Current" is judged in `timezone`, not the machine's own: a moment near a
|
|
115
|
+
* year boundary is still December in one zone while already January somewhere
|
|
116
|
+
* else, and the heading should agree with whichever zone it is rendered in.
|
|
117
|
+
*/
|
|
118
|
+
export function monthLabel(value: Date, timezone: string, now: Date = new Date()): string {
|
|
119
|
+
return yearIn(value, timezone) === yearIn(now, timezone)
|
|
120
|
+
? new Intl.DateTimeFormat(undefined, { month: 'long', timeZone: timezone }).format(value)
|
|
121
|
+
: new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric', timeZone: timezone }).format(
|
|
122
|
+
value,
|
|
123
|
+
)
|
|
90
124
|
}
|
|
@@ -2,6 +2,7 @@ import type { Decision, RecordTargetType } from '@kelpie/schemas'
|
|
|
2
2
|
import { useMemo, useState } from 'react'
|
|
3
3
|
import { Link } from 'react-router'
|
|
4
4
|
|
|
5
|
+
import { useTimezone } from '../api/resources/account.ts'
|
|
5
6
|
import { useCompanies } from '../api/resources/companies.ts'
|
|
6
7
|
import { useDeals } from '../api/resources/deals.ts'
|
|
7
8
|
import { useDecisions } from '../api/resources/decisions.ts'
|
|
@@ -127,6 +128,7 @@ export function DecisionsPage(): React.JSX.Element {
|
|
|
127
128
|
const decisions = useDecisions({ term: term.trim().length > 0 ? term.trim() : undefined })
|
|
128
129
|
const directory = useTargetDirectory()
|
|
129
130
|
const members = useMembers()
|
|
131
|
+
const timezone = useTimezone()
|
|
130
132
|
|
|
131
133
|
const columns: readonly Column<Decision>[] = [
|
|
132
134
|
{
|
|
@@ -149,14 +151,16 @@ export function DecisionsPage(): React.JSX.Element {
|
|
|
149
151
|
{
|
|
150
152
|
key: 'decided',
|
|
151
153
|
header: 'Decided',
|
|
152
|
-
render: (decision) =>
|
|
154
|
+
render: (decision) => (
|
|
155
|
+
<span className="text-ink-muted">{formatDate(decision.decidedAt, timezone)}</span>
|
|
156
|
+
),
|
|
153
157
|
},
|
|
154
158
|
{
|
|
155
159
|
key: 'due',
|
|
156
160
|
header: 'By',
|
|
157
161
|
render: (decision) => (
|
|
158
162
|
<span className="text-ink-muted">
|
|
159
|
-
{decision.dueAt === null ? '—' : formatDate(decision.dueAt)}
|
|
163
|
+
{decision.dueAt === null ? '—' : formatDate(decision.dueAt, timezone)}
|
|
160
164
|
</span>
|
|
161
165
|
),
|
|
162
166
|
},
|
package/src/pages/HiringPage.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { useState } from 'react'
|
|
|
3
3
|
import type { FormEvent } from 'react'
|
|
4
4
|
import { useNavigate } from 'react-router'
|
|
5
5
|
|
|
6
|
+
import { useTimezone } from '../api/resources/account.ts'
|
|
6
7
|
import { useCreateRole, useRoles } from '../api/resources/roles.ts'
|
|
7
8
|
import { Chip } from '../components/Chip.tsx'
|
|
8
9
|
import { DataTable } from '../components/DataTable.tsx'
|
|
@@ -27,6 +28,7 @@ export function HiringPage(): React.JSX.Element {
|
|
|
27
28
|
const roles = useRoles({ term: term.trim().length > 0 ? term.trim() : undefined })
|
|
28
29
|
const counts = useRoleCandidateCounts(roles.records.map((role) => role.id))
|
|
29
30
|
const createRole = useCreateRole()
|
|
31
|
+
const timezone = useTimezone()
|
|
30
32
|
|
|
31
33
|
function reset(): void {
|
|
32
34
|
setAdding(false)
|
|
@@ -76,7 +78,7 @@ export function HiringPage(): React.JSX.Element {
|
|
|
76
78
|
{
|
|
77
79
|
key: 'created',
|
|
78
80
|
header: 'Created',
|
|
79
|
-
render: (role) => formatDate(role.createdAt),
|
|
81
|
+
render: (role) => formatDate(role.createdAt, timezone),
|
|
80
82
|
},
|
|
81
83
|
]
|
|
82
84
|
|
|
@@ -33,7 +33,7 @@ const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const
|
|
|
33
33
|
/** How many of a day's items a calendar cell shows before summarising the rest. */
|
|
34
34
|
const CELL_ITEM_LIMIT = 3
|
|
35
35
|
|
|
36
|
-
const MONTH_AND_YEAR = new Intl.DateTimeFormat(
|
|
36
|
+
const MONTH_AND_YEAR = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' })
|
|
37
37
|
|
|
38
38
|
export function PlanningPage(): React.JSX.Element {
|
|
39
39
|
const [view, setView] = useState<ViewMode>('list')
|
|
@@ -20,6 +20,7 @@ const CORE_TABS: readonly NavItem[] = [
|
|
|
20
20
|
{ id: 'profile', to: '/account/profile', label: 'Profile', order: 100 },
|
|
21
21
|
{ id: 'security', to: '/account/security', label: 'Security', order: 200 },
|
|
22
22
|
{ id: 'preferences', to: '/account/preferences', label: 'Preferences', order: 300 },
|
|
23
|
+
{ id: 'api-keys', to: '/account/api-keys', label: 'API keys', order: 400 },
|
|
23
24
|
]
|
|
24
25
|
|
|
25
26
|
function tabClass({ isActive }: { isActive: boolean }): string {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ApiKeysPanel } from '../../components/ApiKeysPanel.tsx'
|
|
2
|
+
import { PageHeader } from '../../components/PageHeader.tsx'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Personal keys: act as the signed-in member against the public API and MCP.
|
|
6
|
+
*
|
|
7
|
+
* No role gate. `GET /v1/api-keys?kind=personal` scopes itself to the caller —
|
|
8
|
+
* every member manages their own, and an admin cannot see or revoke a
|
|
9
|
+
* colleague's; removing their membership is how you cut that off.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function PersonalApiKeysPage(): React.JSX.Element {
|
|
13
|
+
return (
|
|
14
|
+
<div className="animate-slide-in space-y-4">
|
|
15
|
+
<PageHeader
|
|
16
|
+
title="API keys"
|
|
17
|
+
description="Personal keys act as you against the public API and MCP. Workspace keys live under Admin."
|
|
18
|
+
/>
|
|
19
|
+
<ApiKeysPanel
|
|
20
|
+
kind="personal"
|
|
21
|
+
namePlaceholder="Laptop Claude"
|
|
22
|
+
createTitle="Create personal API key"
|
|
23
|
+
emptyMessage="No personal API keys yet."
|
|
24
|
+
/>
|
|
25
|
+
</div>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -133,7 +133,7 @@ function PreferencesForm({
|
|
|
133
133
|
<div className="max-w-md">
|
|
134
134
|
<Field
|
|
135
135
|
label="Timezone"
|
|
136
|
-
hint="Stored on your account.
|
|
136
|
+
hint="Stored on your account. Dates across Kelpie use this zone."
|
|
137
137
|
>
|
|
138
138
|
<EntitySearch
|
|
139
139
|
options={zones.map((zone) => ({ id: zone, label: zone }))}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
useAccountSessions,
|
|
8
8
|
useChangePassword,
|
|
9
9
|
useRevokeAccountSession,
|
|
10
|
+
useTimezone,
|
|
10
11
|
} from '../../api/resources/account.ts'
|
|
11
12
|
import { PageHeader } from '../../components/PageHeader.tsx'
|
|
12
13
|
import { ErrorPanel, LoadingPanel } from '../../components/QueryState.tsx'
|
|
@@ -208,6 +209,8 @@ function SessionRow({
|
|
|
208
209
|
readonly onRevoke: () => void
|
|
209
210
|
readonly isRevoking: boolean
|
|
210
211
|
}): React.JSX.Element {
|
|
212
|
+
const timezone = useTimezone()
|
|
213
|
+
|
|
211
214
|
return (
|
|
212
215
|
<tr className="border-b border-border last:border-0">
|
|
213
216
|
<td className="px-4 py-3">
|
|
@@ -219,7 +222,7 @@ function SessionRow({
|
|
|
219
222
|
is on the wire and a session says where it is from once something can
|
|
220
223
|
tell. */}
|
|
221
224
|
<td className="px-4 py-3 text-ink-muted">{session.location ?? 'Unknown'}</td>
|
|
222
|
-
<td className="px-4 py-3 text-ink-muted">{formatDateTime(session.lastActiveAt)}</td>
|
|
225
|
+
<td className="px-4 py-3 text-ink-muted">{formatDateTime(session.lastActiveAt, timezone)}</td>
|
|
223
226
|
<td className="px-4 py-3 text-right">
|
|
224
227
|
{!session.current && (
|
|
225
228
|
<button
|