@kelpie/ui 0.3.1 → 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 +18 -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 +4 -16
- package/dist/components/Shell.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- 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 +16 -7
- 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/dist/registry/context.d.ts +1 -2
- package/dist/registry/context.d.ts.map +1 -1
- package/dist/registry/context.js +0 -3
- package/dist/registry/context.js.map +1 -1
- package/dist/registry/contributions.d.ts +1 -12
- package/dist/registry/contributions.d.ts.map +1 -1
- package/dist/registry/contributions.js +0 -7
- package/dist/registry/contributions.js.map +1 -1
- package/dist/registry/registry.d.ts +1 -3
- package/dist/registry/registry.d.ts.map +1 -1
- package/dist/registry/registry.js +0 -6
- package/dist/registry/registry.js.map +1 -1
- package/dist/registry/visibleNav.d.ts +3 -0
- package/dist/registry/visibleNav.d.ts.map +1 -0
- package/dist/registry/visibleNav.js +27 -0
- package/dist/registry/visibleNav.js.map +1 -0
- 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 +30 -1
- 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 +4 -22
- package/src/index.ts +1 -4
- 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 +21 -9
- 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/src/registry/context.ts +0 -5
- package/src/registry/contributions.ts +1 -20
- package/src/registry/registry.ts +0 -11
- package/src/registry/visibleNav.ts +38 -0
|
@@ -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
|
@@ -2,13 +2,12 @@ import { useEffect, useRef, useState } from 'react'
|
|
|
2
2
|
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router'
|
|
3
3
|
|
|
4
4
|
import { useAccount, useTheme } from '../api/resources/account.ts'
|
|
5
|
-
import { useModuleSettings } from '../api/resources/moduleSettings.ts'
|
|
6
5
|
import { useLogOut } from '../api/resources/session.ts'
|
|
7
6
|
import { initialsOf } from '../lib/names.ts'
|
|
8
7
|
import type { ThemePreference } from '../lib/theme.ts'
|
|
9
8
|
import { useNavItems } from '../registry/context.ts'
|
|
10
9
|
import type { NavItem } from '../registry/contributions.ts'
|
|
11
|
-
import {
|
|
10
|
+
import { useVisibleNavItems } from '../registry/visibleNav.ts'
|
|
12
11
|
import { ErrorBoundary } from './ErrorBoundary.tsx'
|
|
13
12
|
|
|
14
13
|
/**
|
|
@@ -24,6 +23,7 @@ const CORE_ADMIN_NAV: readonly NavItem[] = [
|
|
|
24
23
|
{ id: 'workspace', label: 'Workspace', to: '/admin/workspace', order: 100 },
|
|
25
24
|
{ id: 'team', label: 'Team', to: '/admin/team', order: 200 },
|
|
26
25
|
{ id: 'data', label: 'Data', to: '/admin/data', order: 300 },
|
|
26
|
+
{ id: 'api-keys', label: 'API keys', to: '/admin/api-keys', order: 350 },
|
|
27
27
|
{ id: 'webhooks', label: 'Webhooks', to: '/admin/webhooks', order: 400 },
|
|
28
28
|
{ id: 'mcp', label: 'MCP', to: '/admin/mcp', order: 500 },
|
|
29
29
|
{ id: 'modules', label: 'Modules', to: '/admin/modules', order: 600 },
|
|
@@ -51,16 +51,6 @@ const CORE_NAV: readonly NavItem[] = [
|
|
|
51
51
|
*/
|
|
52
52
|
const TOP_LEVEL_NAV_IDS: ReadonlySet<string> = new Set(['dashboard', 'handbook', 'planning', 'decisions'])
|
|
53
53
|
|
|
54
|
-
/**
|
|
55
|
-
* A nav item whose id does not match its module id. Everything else looks
|
|
56
|
-
* itself up directly, which is what keeps a toggleable module's own nav entry
|
|
57
|
-
* working with no change here when one is added later.
|
|
58
|
-
*/
|
|
59
|
-
const NAV_ID_TO_MODULE_ID: Readonly<Record<string, string>> = {
|
|
60
|
-
fundraising: 'raises',
|
|
61
|
-
data: 'import-export',
|
|
62
|
-
}
|
|
63
|
-
|
|
64
54
|
function linkClass({ isActive }: { isActive: boolean }): string {
|
|
65
55
|
return [
|
|
66
56
|
'block rounded-md px-2 py-1 text-[13px] transition-colors duration-100',
|
|
@@ -89,12 +79,6 @@ export function Shell(): React.JSX.Element {
|
|
|
89
79
|
const logOut = useLogOut()
|
|
90
80
|
const moduleNav = useNavItems('primary')
|
|
91
81
|
const moduleAdminNav = useNavItems('admin')
|
|
92
|
-
const { settings } = useModuleSettings()
|
|
93
|
-
const disabledModuleIds = new Set(
|
|
94
|
-
settings.filter((setting) => !setting.enabled).map((setting) => setting.moduleId),
|
|
95
|
-
)
|
|
96
|
-
const isModuleHidden = (navId: string): boolean =>
|
|
97
|
-
disabledModuleIds.has(NAV_ID_TO_MODULE_ID[navId] ?? navId)
|
|
98
82
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
99
83
|
// The same preference the account page writes, so this button and that page
|
|
100
84
|
// cannot disagree about which theme the account is on.
|
|
@@ -128,12 +112,10 @@ export function Shell(): React.JSX.Element {
|
|
|
128
112
|
}
|
|
129
113
|
}, [menuOpen])
|
|
130
114
|
|
|
131
|
-
const navItems =
|
|
115
|
+
const navItems = useVisibleNavItems(CORE_NAV, moduleNav)
|
|
132
116
|
const topLevelItems = navItems.filter((item) => TOP_LEVEL_NAV_IDS.has(item.id))
|
|
133
117
|
const crmItems = navItems.filter((item) => !TOP_LEVEL_NAV_IDS.has(item.id))
|
|
134
|
-
const adminItems =
|
|
135
|
-
(item) => !isModuleHidden(item.id),
|
|
136
|
-
)
|
|
118
|
+
const adminItems = useVisibleNavItems(CORE_ADMIN_NAV, moduleAdminNav)
|
|
137
119
|
|
|
138
120
|
return (
|
|
139
121
|
<div className="flex min-h-screen bg-surface">
|
package/src/index.ts
CHANGED
|
@@ -155,12 +155,10 @@ export type { DueBucketId, PlanAttention as PlanAttentionSummary } from './lib/p
|
|
|
155
155
|
export { applyTheme, getStoredTheme, resolveTheme, setStoredTheme, watchSystemTheme } from './lib/theme.ts'
|
|
156
156
|
export type { ThemePreference } from './lib/theme.ts'
|
|
157
157
|
|
|
158
|
-
export { EXTENSIBLE_RECORD_TYPES,
|
|
158
|
+
export { EXTENSIBLE_RECORD_TYPES, NAV_SLOTS } from './registry/contributions.ts'
|
|
159
159
|
export type {
|
|
160
160
|
DashboardCard,
|
|
161
161
|
ExtensibleRecordType,
|
|
162
|
-
IntegrationCategory,
|
|
163
|
-
IntegrationProvider,
|
|
164
162
|
NavItem,
|
|
165
163
|
NavSlot,
|
|
166
164
|
RecordContext,
|
|
@@ -177,7 +175,6 @@ export type { UiExtensions, UiModule, UiModuleContext } from './registry/registr
|
|
|
177
175
|
|
|
178
176
|
export {
|
|
179
177
|
useDashboardCards,
|
|
180
|
-
useIntegrationProviders,
|
|
181
178
|
useModuleRoutes,
|
|
182
179
|
useNavItems,
|
|
183
180
|
useOverridable,
|
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
|
},
|