@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.
Files changed (140) 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/api/resources/invites.d.ts +0 -2
  10. package/dist/api/resources/invites.d.ts.map +1 -1
  11. package/dist/api/resources/invites.js +5 -10
  12. package/dist/api/resources/invites.js.map +1 -1
  13. package/dist/api/resources/session.d.ts +17 -3
  14. package/dist/api/resources/session.d.ts.map +1 -1
  15. package/dist/api/resources/session.js +34 -6
  16. package/dist/api/resources/session.js.map +1 -1
  17. package/dist/app/KelpieApp.d.ts.map +1 -1
  18. package/dist/app/KelpieApp.js +5 -1
  19. package/dist/app/KelpieApp.js.map +1 -1
  20. package/dist/app/SessionGate.d.ts +6 -2
  21. package/dist/app/SessionGate.d.ts.map +1 -1
  22. package/dist/app/SessionGate.js +10 -3
  23. package/dist/app/SessionGate.js.map +1 -1
  24. package/dist/components/ActivitiesPanel.d.ts.map +1 -1
  25. package/dist/components/ActivitiesPanel.js +10 -7
  26. package/dist/components/ActivitiesPanel.js.map +1 -1
  27. package/dist/components/ApiKeysPanel.d.ts +20 -0
  28. package/dist/components/ApiKeysPanel.d.ts.map +1 -0
  29. package/dist/components/ApiKeysPanel.js +70 -0
  30. package/dist/components/ApiKeysPanel.js.map +1 -0
  31. package/dist/components/DecisionsPanel.d.ts.map +1 -1
  32. package/dist/components/DecisionsPanel.js +3 -1
  33. package/dist/components/DecisionsPanel.js.map +1 -1
  34. package/dist/components/NotesPanel.d.ts.map +1 -1
  35. package/dist/components/NotesPanel.js +3 -1
  36. package/dist/components/NotesPanel.js.map +1 -1
  37. package/dist/components/Shell.d.ts.map +1 -1
  38. package/dist/components/Shell.js +1 -0
  39. package/dist/components/Shell.js.map +1 -1
  40. package/dist/index.d.ts +1 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +1 -1
  43. package/dist/index.js.map +1 -1
  44. package/dist/lib/dates.d.ts +25 -5
  45. package/dist/lib/dates.d.ts.map +1 -1
  46. package/dist/lib/dates.js +49 -20
  47. package/dist/lib/dates.js.map +1 -1
  48. package/dist/pages/DecisionsPage.d.ts.map +1 -1
  49. package/dist/pages/DecisionsPage.js +4 -2
  50. package/dist/pages/DecisionsPage.js.map +1 -1
  51. package/dist/pages/HiringPage.d.ts.map +1 -1
  52. package/dist/pages/HiringPage.js +3 -1
  53. package/dist/pages/HiringPage.js.map +1 -1
  54. package/dist/pages/PlanningPage.js +1 -1
  55. package/dist/pages/PlanningPage.js.map +1 -1
  56. package/dist/pages/account/AccountLayout.d.ts.map +1 -1
  57. package/dist/pages/account/AccountLayout.js +1 -0
  58. package/dist/pages/account/AccountLayout.js.map +1 -1
  59. package/dist/pages/account/PersonalApiKeysPage.d.ts +9 -0
  60. package/dist/pages/account/PersonalApiKeysPage.d.ts.map +1 -0
  61. package/dist/pages/account/PersonalApiKeysPage.js +14 -0
  62. package/dist/pages/account/PersonalApiKeysPage.js.map +1 -0
  63. package/dist/pages/account/PreferencesPage.js +1 -1
  64. package/dist/pages/account/PreferencesPage.js.map +1 -1
  65. package/dist/pages/account/SecurityPage.d.ts.map +1 -1
  66. package/dist/pages/account/SecurityPage.js +3 -2
  67. package/dist/pages/account/SecurityPage.js.map +1 -1
  68. package/dist/pages/admin/ApiKeysPage.d.ts +9 -0
  69. package/dist/pages/admin/ApiKeysPage.d.ts.map +1 -0
  70. package/dist/pages/admin/ApiKeysPage.js +20 -0
  71. package/dist/pages/admin/ApiKeysPage.js.map +1 -0
  72. package/dist/pages/admin/McpPage.d.ts.map +1 -1
  73. package/dist/pages/admin/McpPage.js +6 -3
  74. package/dist/pages/admin/McpPage.js.map +1 -1
  75. package/dist/pages/admin/WebhookDeliveries.d.ts.map +1 -1
  76. package/dist/pages/admin/WebhookDeliveries.js +4 -2
  77. package/dist/pages/admin/WebhookDeliveries.js.map +1 -1
  78. package/dist/pages/admin/WebhooksPage.d.ts.map +1 -1
  79. package/dist/pages/admin/WebhooksPage.js +5 -3
  80. package/dist/pages/admin/WebhooksPage.js.map +1 -1
  81. package/dist/pages/auth/ForgotPasswordPage.js +2 -2
  82. package/dist/pages/auth/ForgotPasswordPage.js.map +1 -1
  83. package/dist/pages/auth/SignUpPage.d.ts +5 -3
  84. package/dist/pages/auth/SignUpPage.d.ts.map +1 -1
  85. package/dist/pages/auth/SignUpPage.js +12 -6
  86. package/dist/pages/auth/SignUpPage.js.map +1 -1
  87. package/dist/pages/auth/VerifyEmailConfirmPage.d.ts +9 -0
  88. package/dist/pages/auth/VerifyEmailConfirmPage.d.ts.map +1 -0
  89. package/dist/pages/auth/VerifyEmailConfirmPage.js +58 -0
  90. package/dist/pages/auth/VerifyEmailConfirmPage.js.map +1 -0
  91. package/dist/pages/auth/VerifyEmailPendingPage.d.ts +10 -0
  92. package/dist/pages/auth/VerifyEmailPendingPage.d.ts.map +1 -0
  93. package/dist/pages/auth/VerifyEmailPendingPage.js +35 -0
  94. package/dist/pages/auth/VerifyEmailPendingPage.js.map +1 -0
  95. package/dist/pages/dashboard/DashboardPage.d.ts.map +1 -1
  96. package/dist/pages/dashboard/DashboardPage.js +6 -4
  97. package/dist/pages/dashboard/DashboardPage.js.map +1 -1
  98. package/dist/pages/dashboard/sections.d.ts +6 -3
  99. package/dist/pages/dashboard/sections.d.ts.map +1 -1
  100. package/dist/pages/dashboard/sections.js +6 -6
  101. package/dist/pages/dashboard/sections.js.map +1 -1
  102. package/dist/pages/forms/SubmissionsTable.d.ts.map +1 -1
  103. package/dist/pages/forms/SubmissionsTable.js +3 -1
  104. package/dist/pages/forms/SubmissionsTable.js.map +1 -1
  105. package/dist/pages/handbook/HandbookPage.d.ts.map +1 -1
  106. package/dist/pages/handbook/HandbookPage.js +3 -1
  107. package/dist/pages/handbook/HandbookPage.js.map +1 -1
  108. package/package.json +2 -2
  109. package/src/api/resources/account.ts +13 -0
  110. package/src/api/resources/apiKeys.ts +60 -0
  111. package/src/api/resources/invites.ts +5 -15
  112. package/src/api/resources/session.ts +42 -6
  113. package/src/app/KelpieApp.tsx +11 -2
  114. package/src/app/SessionGate.tsx +11 -3
  115. package/src/components/ActivitiesPanel.tsx +15 -4
  116. package/src/components/ApiKeysPanel.tsx +307 -0
  117. package/src/components/DecisionsPanel.tsx +5 -2
  118. package/src/components/NotesPanel.tsx +4 -1
  119. package/src/components/Shell.tsx +1 -0
  120. package/src/index.ts +0 -1
  121. package/src/lib/dates.ts +54 -20
  122. package/src/pages/DecisionsPage.tsx +6 -2
  123. package/src/pages/HiringPage.tsx +3 -1
  124. package/src/pages/PlanningPage.tsx +1 -1
  125. package/src/pages/account/AccountLayout.tsx +1 -0
  126. package/src/pages/account/PersonalApiKeysPage.tsx +27 -0
  127. package/src/pages/account/PreferencesPage.tsx +1 -1
  128. package/src/pages/account/SecurityPage.tsx +4 -1
  129. package/src/pages/admin/ApiKeysPage.tsx +43 -0
  130. package/src/pages/admin/McpPage.tsx +7 -4
  131. package/src/pages/admin/WebhookDeliveries.tsx +4 -2
  132. package/src/pages/admin/WebhooksPage.tsx +5 -3
  133. package/src/pages/auth/ForgotPasswordPage.tsx +2 -2
  134. package/src/pages/auth/SignUpPage.tsx +12 -6
  135. package/src/pages/auth/VerifyEmailConfirmPage.tsx +81 -0
  136. package/src/pages/auth/VerifyEmailPendingPage.tsx +65 -0
  137. package/src/pages/dashboard/DashboardPage.tsx +14 -4
  138. package/src/pages/dashboard/sections.tsx +9 -3
  139. package/src/pages/forms/SubmissionsTable.tsx +3 -1
  140. 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
  )
@@ -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
@@ -70,7 +70,6 @@ export {
70
70
  export type { DecisionFilters } from './api/resources/decisions.ts'
71
71
 
72
72
  export {
73
- resetUrlTemplate,
74
73
  useAcceptInvite,
75
74
  useConfirmPasswordReset,
76
75
  useCreateWorkspace,
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
- const DATE_TIME = new Intl.DateTimeFormat('en-AU', {
10
- day: 'numeric',
11
- month: 'short',
12
- hour: 'numeric',
13
- minute: '2-digit',
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 DATE_TIME.format(value)
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('en-AU', { day: 'numeric', month: 'short', year: 'numeric' })
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 DAY.format(value)
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
- const MONTH = new Intl.DateTimeFormat('en-AU', { month: 'long' })
83
- const MONTH_AND_YEAR = new Intl.DateTimeFormat('en-AU', { month: 'long', year: 'numeric' })
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
- /** The heading a timeline groups under. The year is dropped for the current one. */
86
- export function monthLabel(value: Date, now: Date = new Date()): string {
87
- return value.getFullYear() === now.getFullYear()
88
- ? MONTH.format(value)
89
- : MONTH_AND_YEAR.format(value)
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) => <span className="text-ink-muted">{formatDate(decision.decidedAt)}</span>,
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
  },
@@ -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('en-AU', { month: 'long', year: 'numeric' })
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. Kelpie does not format dates by it yet."
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