@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
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')
|
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
import { NavLink, Outlet } from 'react-router'
|
|
2
2
|
|
|
3
|
+
import type { NavItem } from '../../registry/contributions.ts'
|
|
4
|
+
import { useNavItems } from '../../registry/context.ts'
|
|
5
|
+
import { useVisibleNavItems } from '../../registry/visibleNav.ts'
|
|
6
|
+
|
|
3
7
|
/**
|
|
4
8
|
* Tabs across the account's own settings, from the mockup's `AccountNav`.
|
|
5
9
|
*
|
|
6
|
-
* Three of its five tabs are
|
|
7
|
-
*
|
|
10
|
+
* Three of its five tabs are core's. The rest come from `nav.account`, which is
|
|
11
|
+
* where a module's own account page announces itself: a module route mounts as
|
|
12
|
+
* a sibling of `/account` rather than a child of it, so without this the page
|
|
13
|
+
* would render with no tab strip and nothing anywhere pointing at it.
|
|
14
|
+
*
|
|
15
|
+
* Core numbers itself in hundreds, the same convention the shell's sidebar
|
|
16
|
+
* uses, so a module can land between two core tabs without core renumbering.
|
|
8
17
|
*/
|
|
9
18
|
|
|
10
|
-
const
|
|
11
|
-
{ to: '/account/profile', label: 'Profile' },
|
|
12
|
-
{ to: '/account/security', label: 'Security' },
|
|
13
|
-
{ to: '/account/preferences', label: 'Preferences' },
|
|
14
|
-
|
|
19
|
+
const CORE_TABS: readonly NavItem[] = [
|
|
20
|
+
{ id: 'profile', to: '/account/profile', label: 'Profile', order: 100 },
|
|
21
|
+
{ id: 'security', to: '/account/security', label: 'Security', order: 200 },
|
|
22
|
+
{ id: 'preferences', to: '/account/preferences', label: 'Preferences', order: 300 },
|
|
23
|
+
{ id: 'api-keys', to: '/account/api-keys', label: 'API keys', order: 400 },
|
|
24
|
+
]
|
|
15
25
|
|
|
16
26
|
function tabClass({ isActive }: { isActive: boolean }): string {
|
|
17
27
|
return [
|
|
@@ -23,11 +33,13 @@ function tabClass({ isActive }: { isActive: boolean }): string {
|
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
export function AccountLayout(): React.JSX.Element {
|
|
36
|
+
const tabs = useVisibleNavItems(CORE_TABS, useNavItems('account'))
|
|
37
|
+
|
|
26
38
|
return (
|
|
27
39
|
<div className="animate-fade-in mx-auto max-w-4xl">
|
|
28
40
|
<nav className="mb-6 flex flex-wrap gap-1 border-b border-border pb-3">
|
|
29
|
-
{
|
|
30
|
-
<NavLink key={tab.
|
|
41
|
+
{tabs.map((tab) => (
|
|
42
|
+
<NavLink key={tab.id} to={tab.to} className={tabClass}>
|
|
31
43
|
{tab.label}
|
|
32
44
|
</NavLink>
|
|
33
45
|
))}
|
|
@@ -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
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useSession } from '../../api/resources/session.ts'
|
|
2
|
+
import { ApiKeysPanel } from '../../components/ApiKeysPanel.tsx'
|
|
3
|
+
import { PageHeader } from '../../components/PageHeader.tsx'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Workspace keys, for shared agents and CI.
|
|
7
|
+
*
|
|
8
|
+
* `GET /v1/api-keys?kind=workspace` needs the admin role; the server answers
|
|
9
|
+
* `403` to anyone else. This page shows that plainly instead of rendering an
|
|
10
|
+
* empty table, the same shape `WebhooksPage` uses for the same reason.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export function ApiKeysPage(): React.JSX.Element {
|
|
14
|
+
const { session } = useSession()
|
|
15
|
+
const isAdmin = session?.role === 'owner' || session?.role === 'admin'
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className="animate-slide-in mx-auto max-w-4xl space-y-4">
|
|
19
|
+
<PageHeader
|
|
20
|
+
title="API keys"
|
|
21
|
+
description="Workspace keys for shared agents and CI. Personal keys live under Account → API keys."
|
|
22
|
+
/>
|
|
23
|
+
{isAdmin ? (
|
|
24
|
+
<ApiKeysPanel
|
|
25
|
+
kind="workspace"
|
|
26
|
+
namePlaceholder="CI pipeline"
|
|
27
|
+
createTitle="Create API key"
|
|
28
|
+
emptyMessage="No API keys yet."
|
|
29
|
+
/>
|
|
30
|
+
) : (
|
|
31
|
+
<MemberNotice />
|
|
32
|
+
)}
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function MemberNotice(): React.JSX.Element {
|
|
38
|
+
return (
|
|
39
|
+
<p className="rounded-md border border-border px-4 py-3 text-[13px] text-ink-muted">
|
|
40
|
+
Workspace keys are managed by workspace admins.
|
|
41
|
+
</p>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { AgentRun, McpTool } from '@kelpie/schemas'
|
|
|
3
3
|
import { useMemo, useState } from 'react'
|
|
4
4
|
import { Link } from 'react-router'
|
|
5
5
|
|
|
6
|
+
import { useTimezone } from '../../api/resources/account.ts'
|
|
6
7
|
import {
|
|
7
8
|
useAgentRuns,
|
|
8
9
|
useAgents,
|
|
@@ -107,8 +108,8 @@ export function McpPage(): React.JSX.Element {
|
|
|
107
108
|
</div>
|
|
108
109
|
<p className="mt-1 text-[12px] text-ink-muted">
|
|
109
110
|
Paste into your MCP client config, and replace the bearer token with a workspace key from{' '}
|
|
110
|
-
<Link to="/admin/
|
|
111
|
-
|
|
111
|
+
<Link to="/admin/api-keys" className="text-accent hover:underline">
|
|
112
|
+
API keys
|
|
112
113
|
</Link>
|
|
113
114
|
.
|
|
114
115
|
</p>
|
|
@@ -133,6 +134,7 @@ function RegisteredAgents(): React.JSX.Element {
|
|
|
133
134
|
const { records, isLoading, error } = useAgents()
|
|
134
135
|
const createAgent = useCreateAgent()
|
|
135
136
|
const deleteAgent = useDeleteAgent()
|
|
137
|
+
const timezone = useTimezone()
|
|
136
138
|
const [adding, setAdding] = useState(false)
|
|
137
139
|
const [name, setName] = useState('')
|
|
138
140
|
const [endpoint, setEndpoint] = useState('')
|
|
@@ -253,7 +255,7 @@ function RegisteredAgents(): React.JSX.Element {
|
|
|
253
255
|
<span>
|
|
254
256
|
{agent.lastRunAt === null
|
|
255
257
|
? 'Never run'
|
|
256
|
-
: `Last run ${formatRelativeTime(agent.lastRunAt)}`}
|
|
258
|
+
: `Last run ${formatRelativeTime(agent.lastRunAt, timezone)}`}
|
|
257
259
|
</span>
|
|
258
260
|
<button
|
|
259
261
|
type="button"
|
|
@@ -283,6 +285,7 @@ function RecentRuns(): React.JSX.Element {
|
|
|
283
285
|
const { records: runs, isLoading, error } = useAgentRuns()
|
|
284
286
|
const catalog = useAllAgentTasks()
|
|
285
287
|
const agents = useAgents()
|
|
288
|
+
const timezone = useTimezone()
|
|
286
289
|
|
|
287
290
|
const taskLabels = useMemo(
|
|
288
291
|
() => new Map(catalog.records.map((task) => [task.id, task.label])),
|
|
@@ -333,7 +336,7 @@ function RecentRuns(): React.JSX.Element {
|
|
|
333
336
|
<span className={run.status === 'failed' ? 'font-medium text-danger' : 'text-ink-muted'}>
|
|
334
337
|
{AGENT_RUN_STATUS_LABELS[run.status]}
|
|
335
338
|
</span>
|
|
336
|
-
<span className="text-ink-faint">{formatRelativeTime(run.updatedAt)}</span>
|
|
339
|
+
<span className="text-ink-faint">{formatRelativeTime(run.updatedAt, timezone)}</span>
|
|
337
340
|
</div>
|
|
338
341
|
</li>
|
|
339
342
|
))}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { WebhookDelivery, WebhookDeliveryStatus } from '@kelpie/schemas'
|
|
2
2
|
import { useState } from 'react'
|
|
3
3
|
|
|
4
|
+
import { useTimezone } from '../../api/resources/account.ts'
|
|
4
5
|
import { useWebhookDeliveries } from '../../api/resources/webhooks.ts'
|
|
5
6
|
import { Chip } from '../../components/Chip.tsx'
|
|
6
7
|
import type { ChipTone } from '../../components/Chip.tsx'
|
|
@@ -37,6 +38,7 @@ const STATUS_TONES: Readonly<Record<WebhookDeliveryStatus, ChipTone>> = {
|
|
|
37
38
|
export function WebhookDeliveries({ webhookId }: { readonly webhookId: string }): React.JSX.Element {
|
|
38
39
|
const [status, setStatus] = useState<StatusFilter>('all')
|
|
39
40
|
const [openPayloadId, setOpenPayloadId] = useState<string | null>(null)
|
|
41
|
+
const timezone = useTimezone()
|
|
40
42
|
const deliveries = useWebhookDeliveries(webhookId, {
|
|
41
43
|
status: status === 'all' ? undefined : status,
|
|
42
44
|
})
|
|
@@ -68,7 +70,7 @@ export function WebhookDeliveries({ webhookId }: { readonly webhookId: string })
|
|
|
68
70
|
className: 'w-32',
|
|
69
71
|
render: (delivery) => (
|
|
70
72
|
<span className="font-mono text-[12px] text-ink-muted">
|
|
71
|
-
{formatDateTime(delivery.createdAt)}
|
|
73
|
+
{formatDateTime(delivery.createdAt, timezone)}
|
|
72
74
|
</span>
|
|
73
75
|
),
|
|
74
76
|
},
|
|
@@ -80,7 +82,7 @@ export function WebhookDeliveries({ webhookId }: { readonly webhookId: string })
|
|
|
80
82
|
// reader is looking for. An em dash says "never" without claiming a time.
|
|
81
83
|
render: (delivery) => (
|
|
82
84
|
<span className="font-mono text-[12px] text-ink-muted">
|
|
83
|
-
{delivery.deliveredAt === null ? '—' : formatDateTime(delivery.deliveredAt)}
|
|
85
|
+
{delivery.deliveredAt === null ? '—' : formatDateTime(delivery.deliveredAt, timezone)}
|
|
84
86
|
</span>
|
|
85
87
|
),
|
|
86
88
|
},
|
|
@@ -7,6 +7,7 @@ import type { CreatedWebhook, Webhook, WebhookEvent, WebhookStatus } from '@kelp
|
|
|
7
7
|
import { useState } from 'react'
|
|
8
8
|
import type { FormEvent } from 'react'
|
|
9
9
|
|
|
10
|
+
import { useTimezone } from '../../api/resources/account.ts'
|
|
10
11
|
import { useSession } from '../../api/resources/session.ts'
|
|
11
12
|
import {
|
|
12
13
|
useCreateWebhook,
|
|
@@ -250,6 +251,7 @@ function WebhookRow({
|
|
|
250
251
|
const updateWebhook = useUpdateWebhook()
|
|
251
252
|
const deleteWebhook = useDeleteWebhook()
|
|
252
253
|
const rotateSecret = useRotateWebhookSecret()
|
|
254
|
+
const timezone = useTimezone()
|
|
253
255
|
const [isConfirmingRemoval, setIsConfirmingRemoval] = useState(false)
|
|
254
256
|
const [isShowingDeliveries, setIsShowingDeliveries] = useState(false)
|
|
255
257
|
const [isRotating, setIsRotating] = useState(false)
|
|
@@ -319,7 +321,7 @@ function WebhookRow({
|
|
|
319
321
|
|
|
320
322
|
<div className="mt-3 flex flex-wrap items-center gap-4 text-[11px] text-ink-faint">
|
|
321
323
|
<span className="font-mono">Secret {webhook.secretPrefix}</span>
|
|
322
|
-
<span>Last delivery: {describeLastDelivery(webhook)}</span>
|
|
324
|
+
<span>Last delivery: {describeLastDelivery(webhook, timezone)}</span>
|
|
323
325
|
<button
|
|
324
326
|
type="button"
|
|
325
327
|
aria-expanded={isShowingDeliveries}
|
|
@@ -434,10 +436,10 @@ function RotatePanel({
|
|
|
434
436
|
}
|
|
435
437
|
|
|
436
438
|
/** Both fields are null together until the first delivery settles. */
|
|
437
|
-
function describeLastDelivery(webhook: Webhook): string {
|
|
439
|
+
function describeLastDelivery(webhook: Webhook, timezone: string): string {
|
|
438
440
|
if (webhook.lastDeliveryAt === null || webhook.lastDeliveryStatus === null) {
|
|
439
441
|
return 'Never'
|
|
440
442
|
}
|
|
441
443
|
|
|
442
|
-
return `${formatDateTime(webhook.lastDeliveryAt)} (${webhook.lastDeliveryStatus})`
|
|
444
|
+
return `${formatDateTime(webhook.lastDeliveryAt, timezone)} (${webhook.lastDeliveryStatus})`
|
|
443
445
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Dashboard } from '@kelpie/schemas'
|
|
2
2
|
|
|
3
|
+
import { useTimezone } from '../../api/resources/account.ts'
|
|
3
4
|
import { useDashboard } from '../../api/resources/dashboard.ts'
|
|
4
5
|
import { useMembers } from '../../api/resources/members.ts'
|
|
5
6
|
import { useWorkspace } from '../../api/resources/workspace.ts'
|
|
@@ -20,7 +21,7 @@ import { ActivityFeed, AttentionList, DecisionsList, NotesList } from './section
|
|
|
20
21
|
|
|
21
22
|
/** The heading date, written in the workspace's own day rather than the browser's. */
|
|
22
23
|
function briefHeading(dashboard: Dashboard): string {
|
|
23
|
-
return new Intl.DateTimeFormat(
|
|
24
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
24
25
|
weekday: 'long',
|
|
25
26
|
month: 'short',
|
|
26
27
|
day: 'numeric',
|
|
@@ -32,6 +33,7 @@ export function DashboardPage(): React.JSX.Element {
|
|
|
32
33
|
const { dashboard, isLoading, error } = useDashboard()
|
|
33
34
|
const { workspace } = useWorkspace()
|
|
34
35
|
const { nameById } = useMembers()
|
|
36
|
+
const timezone = useTimezone()
|
|
35
37
|
|
|
36
38
|
return (
|
|
37
39
|
<div className="animate-fade-in">
|
|
@@ -46,6 +48,7 @@ export function DashboardPage(): React.JSX.Element {
|
|
|
46
48
|
workspaceId={workspace?.id}
|
|
47
49
|
workspaceName={workspace?.name ?? 'This workspace'}
|
|
48
50
|
nameById={nameById}
|
|
51
|
+
timezone={timezone}
|
|
49
52
|
/>
|
|
50
53
|
)}
|
|
51
54
|
</div>
|
|
@@ -57,11 +60,13 @@ function DashboardBody({
|
|
|
57
60
|
workspaceId,
|
|
58
61
|
workspaceName,
|
|
59
62
|
nameById,
|
|
63
|
+
timezone,
|
|
60
64
|
}: {
|
|
61
65
|
readonly dashboard: Dashboard
|
|
62
66
|
readonly workspaceId: string | undefined
|
|
63
67
|
readonly workspaceName: string
|
|
64
68
|
readonly nameById: ReadonlyMap<string, string>
|
|
69
|
+
readonly timezone: string
|
|
65
70
|
}): React.JSX.Element {
|
|
66
71
|
// `generatedAt` rather than a fresh `new Date()`: every relative time on the
|
|
67
72
|
// page is then measured from the same instant the counts were, so a tab left
|
|
@@ -97,11 +102,16 @@ function DashboardBody({
|
|
|
97
102
|
|
|
98
103
|
<div className="mb-8 grid gap-8 lg:grid-cols-2">
|
|
99
104
|
<AttentionList rows={attentionRows(dashboard)} />
|
|
100
|
-
<ActivityFeed
|
|
105
|
+
<ActivityFeed
|
|
106
|
+
activities={dashboard.recentActivity}
|
|
107
|
+
nameById={nameById}
|
|
108
|
+
now={now}
|
|
109
|
+
timezone={timezone}
|
|
110
|
+
/>
|
|
101
111
|
</div>
|
|
102
112
|
<div className="grid gap-8 lg:grid-cols-2">
|
|
103
|
-
<NotesList notes={dashboard.recentNotes} nameById={nameById} now={now} />
|
|
104
|
-
<DecisionsList decisions={dashboard.recentDecisions} nameById={nameById} />
|
|
113
|
+
<NotesList notes={dashboard.recentNotes} nameById={nameById} now={now} timezone={timezone} />
|
|
114
|
+
<DecisionsList decisions={dashboard.recentDecisions} nameById={nameById} timezone={timezone} />
|
|
105
115
|
</div>
|
|
106
116
|
</div>
|
|
107
117
|
)
|
|
@@ -106,10 +106,12 @@ export function ActivityFeed({
|
|
|
106
106
|
activities,
|
|
107
107
|
nameById,
|
|
108
108
|
now,
|
|
109
|
+
timezone,
|
|
109
110
|
}: {
|
|
110
111
|
readonly activities: readonly DashboardActivity[]
|
|
111
112
|
readonly nameById: ReadonlyMap<string, string>
|
|
112
113
|
readonly now: Date
|
|
114
|
+
readonly timezone: string
|
|
113
115
|
}): React.JSX.Element {
|
|
114
116
|
return (
|
|
115
117
|
<section>
|
|
@@ -145,7 +147,7 @@ export function ActivityFeed({
|
|
|
145
147
|
dateTime={activity.createdAt.toISOString()}
|
|
146
148
|
className="shrink-0 text-[11px] text-ink-faint"
|
|
147
149
|
>
|
|
148
|
-
{formatRelativeTime(activity.createdAt, now)}
|
|
150
|
+
{formatRelativeTime(activity.createdAt, timezone, now)}
|
|
149
151
|
</time>
|
|
150
152
|
</div>
|
|
151
153
|
<p className="text-[11px] leading-snug text-ink-faint">
|
|
@@ -184,10 +186,12 @@ export function NotesList({
|
|
|
184
186
|
notes,
|
|
185
187
|
nameById,
|
|
186
188
|
now,
|
|
189
|
+
timezone,
|
|
187
190
|
}: {
|
|
188
191
|
readonly notes: readonly DashboardNote[]
|
|
189
192
|
readonly nameById: ReadonlyMap<string, string>
|
|
190
193
|
readonly now: Date
|
|
194
|
+
readonly timezone: string
|
|
191
195
|
}): React.JSX.Element {
|
|
192
196
|
return (
|
|
193
197
|
<section>
|
|
@@ -211,7 +215,7 @@ export function NotesList({
|
|
|
211
215
|
<span>·</span>
|
|
212
216
|
<span>{note.authorId === null ? 'System' : (nameById.get(note.authorId) ?? 'Unknown')}</span>
|
|
213
217
|
<span>·</span>
|
|
214
|
-
<span>{formatRelativeTime(note.createdAt, now)}</span>
|
|
218
|
+
<span>{formatRelativeTime(note.createdAt, timezone, now)}</span>
|
|
215
219
|
</div>
|
|
216
220
|
</li>
|
|
217
221
|
))}
|
|
@@ -224,9 +228,11 @@ export function NotesList({
|
|
|
224
228
|
export function DecisionsList({
|
|
225
229
|
decisions,
|
|
226
230
|
nameById,
|
|
231
|
+
timezone,
|
|
227
232
|
}: {
|
|
228
233
|
readonly decisions: readonly DashboardDecision[]
|
|
229
234
|
readonly nameById: ReadonlyMap<string, string>
|
|
235
|
+
readonly timezone: string
|
|
230
236
|
}): React.JSX.Element {
|
|
231
237
|
return (
|
|
232
238
|
<section>
|
|
@@ -254,7 +260,7 @@ export function DecisionsList({
|
|
|
254
260
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-ink-faint">
|
|
255
261
|
<TargetLink target={decision} />
|
|
256
262
|
<span>·</span>
|
|
257
|
-
<span>{formatDate(decision.decidedAt)}</span>
|
|
263
|
+
<span>{formatDate(decision.decidedAt, timezone)}</span>
|
|
258
264
|
<span>·</span>
|
|
259
265
|
<span>
|
|
260
266
|
{decision.ownerId === null ? '—' : (nameById.get(decision.ownerId) ?? 'Unknown')}
|
|
@@ -3,6 +3,7 @@ import { useMemo } from 'react'
|
|
|
3
3
|
import { Link } from 'react-router'
|
|
4
4
|
|
|
5
5
|
import type { RecordListResult } from '../../api/resource.ts'
|
|
6
|
+
import { useTimezone } from '../../api/resources/account.ts'
|
|
6
7
|
import { useCompanies } from '../../api/resources/companies.ts'
|
|
7
8
|
import { usePeople } from '../../api/resources/people.ts'
|
|
8
9
|
import { DataTable } from '../../components/DataTable.tsx'
|
|
@@ -31,6 +32,7 @@ export interface SubmissionsTableProps {
|
|
|
31
32
|
export function SubmissionsTable({ form, submissions }: SubmissionsTableProps): React.JSX.Element {
|
|
32
33
|
const people = usePeople({ limit: MAX_PAGE })
|
|
33
34
|
const companies = useCompanies({ limit: MAX_PAGE })
|
|
35
|
+
const timezone = useTimezone()
|
|
34
36
|
|
|
35
37
|
const nameById = useMemo(
|
|
36
38
|
() => new Map([...people.records, ...companies.records].map((record) => [record.id, record.name])),
|
|
@@ -44,7 +46,7 @@ export function SubmissionsTable({ form, submissions }: SubmissionsTableProps):
|
|
|
44
46
|
className: 'w-40',
|
|
45
47
|
render: (submission) => (
|
|
46
48
|
<span className="font-mono text-[12px] text-ink-muted">
|
|
47
|
-
{formatDateTime(submission.submittedAt)}
|
|
49
|
+
{formatDateTime(submission.submittedAt, timezone)}
|
|
48
50
|
</span>
|
|
49
51
|
),
|
|
50
52
|
},
|
|
@@ -13,6 +13,7 @@ import type { HandbookPage } from '@kelpie/schemas'
|
|
|
13
13
|
import { useEffect, useMemo, useState } from 'react'
|
|
14
14
|
import { Link, useNavigate, useParams } from 'react-router'
|
|
15
15
|
|
|
16
|
+
import { useTimezone } from '../../api/resources/account.ts'
|
|
16
17
|
import {
|
|
17
18
|
useCreateHandbookPage,
|
|
18
19
|
useDeleteHandbookPage,
|
|
@@ -384,6 +385,7 @@ function HandbookEditor({
|
|
|
384
385
|
const [editing, setEditing] = useState(false)
|
|
385
386
|
const [title, setTitle] = useState(page.title)
|
|
386
387
|
const [body, setBody] = useState(page.body)
|
|
388
|
+
const timezone = useTimezone()
|
|
387
389
|
|
|
388
390
|
useEffect(() => {
|
|
389
391
|
setTitle(page.title)
|
|
@@ -415,7 +417,7 @@ function HandbookEditor({
|
|
|
415
417
|
<h1 className="text-[18px] font-semibold tracking-tight text-ink">{page.title}</h1>
|
|
416
418
|
)}
|
|
417
419
|
<div className="mt-1 text-[11px] text-ink-faint">
|
|
418
|
-
Updated {formatDateTime(page.updatedAt)}
|
|
420
|
+
Updated {formatDateTime(page.updatedAt, timezone)}
|
|
419
421
|
{authorName === undefined ? '' : ` · ${authorName}`}
|
|
420
422
|
</div>
|
|
421
423
|
</div>
|
package/src/registry/context.ts
CHANGED
|
@@ -4,7 +4,6 @@ import type { ComponentType } from 'react'
|
|
|
4
4
|
import type {
|
|
5
5
|
DashboardCard,
|
|
6
6
|
ExtensibleRecordType,
|
|
7
|
-
IntegrationProvider,
|
|
8
7
|
NavItem,
|
|
9
8
|
NavSlot,
|
|
10
9
|
RecordSidebarCard,
|
|
@@ -54,10 +53,6 @@ export function useDashboardCards(): readonly DashboardCard[] {
|
|
|
54
53
|
return useUiExtensions().dashboardCards()
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
export function useIntegrationProviders(): readonly IntegrationProvider[] {
|
|
58
|
-
return useUiExtensions().integrationProviders()
|
|
59
|
-
}
|
|
60
|
-
|
|
61
56
|
/**
|
|
62
57
|
* The component to render for an overridable: a module's replacement, or core's
|
|
63
58
|
* own. Core calls this instead of rendering its component directly.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensibleRecordType } from '@kelpie/schemas'
|
|
2
|
-
import type {
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* What a UI module may add to the shell, per the slot table in `modules.md`.
|
|
@@ -68,22 +68,3 @@ export interface DashboardCard {
|
|
|
68
68
|
readonly render: () => ReactNode
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
export const INTEGRATION_CATEGORIES = [
|
|
72
|
-
'messaging',
|
|
73
|
-
'identity',
|
|
74
|
-
'enrichment',
|
|
75
|
-
'email',
|
|
76
|
-
'calendar',
|
|
77
|
-
] as const
|
|
78
|
-
|
|
79
|
-
export type IntegrationCategory = (typeof INTEGRATION_CATEGORIES)[number]
|
|
80
|
-
|
|
81
|
-
/** A catalog entry on the admin integrations page. Core owns the page and the connection lifecycle. */
|
|
82
|
-
export interface IntegrationProvider {
|
|
83
|
-
readonly id: string
|
|
84
|
-
readonly name: string
|
|
85
|
-
readonly category: IntegrationCategory
|
|
86
|
-
readonly description: string
|
|
87
|
-
/** Rendered on the provider's own settings panel once connected. */
|
|
88
|
-
readonly settings?: ComponentType<Record<string, never>>
|
|
89
|
-
}
|
package/src/registry/registry.ts
CHANGED
|
@@ -3,7 +3,6 @@ import type { ComponentType } from 'react'
|
|
|
3
3
|
import type {
|
|
4
4
|
DashboardCard,
|
|
5
5
|
ExtensibleRecordType,
|
|
6
|
-
IntegrationProvider,
|
|
7
6
|
NavItem,
|
|
8
7
|
NavSlot,
|
|
9
8
|
RecordSidebarCard,
|
|
@@ -32,7 +31,6 @@ export interface UiModuleContext {
|
|
|
32
31
|
recordTab(objectType: ExtensibleRecordType, tab: RecordTab): void
|
|
33
32
|
recordSidebarCard(objectType: ExtensibleRecordType, card: RecordSidebarCard): void
|
|
34
33
|
dashboardCard(card: DashboardCard): void
|
|
35
|
-
integrationProvider(provider: IntegrationProvider): void
|
|
36
34
|
/** Replaces a core component. Prefer a slot; see `modules.md`. */
|
|
37
35
|
override<Props>(token: Overridable<Props>, component: ComponentType<Props>): void
|
|
38
36
|
}
|
|
@@ -49,7 +47,6 @@ export interface UiExtensions {
|
|
|
49
47
|
recordTabs(objectType: ExtensibleRecordType): readonly RecordTab[]
|
|
50
48
|
recordSidebarCards(objectType: ExtensibleRecordType): readonly RecordSidebarCard[]
|
|
51
49
|
dashboardCards(): readonly DashboardCard[]
|
|
52
|
-
integrationProviders(): readonly IntegrationProvider[]
|
|
53
50
|
componentFor<Props>(token: Overridable<Props>): ComponentType<Props>
|
|
54
51
|
}
|
|
55
52
|
|
|
@@ -73,7 +70,6 @@ interface Accumulator {
|
|
|
73
70
|
readonly recordTabs: Map<ExtensibleRecordType, RecordTab[]>
|
|
74
71
|
readonly recordSidebarCards: Map<ExtensibleRecordType, RecordSidebarCard[]>
|
|
75
72
|
readonly dashboardCards: DashboardCard[]
|
|
76
|
-
readonly integrationProviders: IntegrationProvider[]
|
|
77
73
|
readonly overrides: OverrideStore
|
|
78
74
|
}
|
|
79
75
|
|
|
@@ -138,11 +134,6 @@ function createModuleContext(
|
|
|
138
134
|
accumulator.dashboardCards.push(card)
|
|
139
135
|
},
|
|
140
136
|
|
|
141
|
-
integrationProvider(provider) {
|
|
142
|
-
claim(taken, module.id, 'admin.integrations.catalog', provider.id)
|
|
143
|
-
accumulator.integrationProviders.push(provider)
|
|
144
|
-
},
|
|
145
|
-
|
|
146
137
|
override(token, component) {
|
|
147
138
|
if (accumulator.overrides.has(token.key)) {
|
|
148
139
|
throw new UiModuleError(
|
|
@@ -167,7 +158,6 @@ export function registerUiModules(modules: readonly UiModule[]): UiExtensions {
|
|
|
167
158
|
recordTabs: new Map(),
|
|
168
159
|
recordSidebarCards: new Map(),
|
|
169
160
|
dashboardCards: [],
|
|
170
|
-
integrationProviders: [],
|
|
171
161
|
overrides: createOverrideStore(),
|
|
172
162
|
}
|
|
173
163
|
const taken = new Set<string>()
|
|
@@ -189,7 +179,6 @@ export function registerUiModules(modules: readonly UiModule[]): UiExtensions {
|
|
|
189
179
|
recordSidebarCards: (objectType) =>
|
|
190
180
|
inSlotOrder(accumulator.recordSidebarCards.get(objectType) ?? []),
|
|
191
181
|
dashboardCards: () => inSlotOrder(accumulator.dashboardCards),
|
|
192
|
-
integrationProviders: () => accumulator.integrationProviders,
|
|
193
182
|
componentFor: (token) => accumulator.overrides.get(token),
|
|
194
183
|
}
|
|
195
184
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useModuleSettings } from '../api/resources/moduleSettings.ts'
|
|
2
|
+
import type { NavItem } from './contributions.ts'
|
|
3
|
+
import { inSlotOrder } from './registry.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Core's nav items merged with a slot's module contributions, ordered, with the
|
|
7
|
+
* entries belonging to a disabled module removed.
|
|
8
|
+
*
|
|
9
|
+
* One hook rather than the rule written out at each nav surface. The shell's
|
|
10
|
+
* sidebar and the account tab strip both render a slot, and a workspace that
|
|
11
|
+
* switched a module off should stop seeing it in both. Two copies of this
|
|
12
|
+
* filter is exactly the sort of pair that drifts, and the symptom is a tab that
|
|
13
|
+
* survives its module being disabled and leads to a page answering 403.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A nav item whose id does not match its module id. Everything else looks itself
|
|
18
|
+
* up directly, which is what keeps a toggleable module's own nav entry working
|
|
19
|
+
* with no change here when one is added later.
|
|
20
|
+
*/
|
|
21
|
+
const NAV_ID_TO_MODULE_ID: Readonly<Record<string, string>> = {
|
|
22
|
+
fundraising: 'raises',
|
|
23
|
+
data: 'import-export',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useVisibleNavItems(
|
|
27
|
+
coreItems: readonly NavItem[],
|
|
28
|
+
moduleItems: readonly NavItem[],
|
|
29
|
+
): readonly NavItem[] {
|
|
30
|
+
const { settings } = useModuleSettings()
|
|
31
|
+
const disabledModuleIds = new Set(
|
|
32
|
+
settings.filter((setting) => !setting.enabled).map((setting) => setting.moduleId),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return inSlotOrder([...coreItems, ...moduleItems]).filter(
|
|
36
|
+
(item) => !disabledModuleIds.has(NAV_ID_TO_MODULE_ID[item.id] ?? item.id),
|
|
37
|
+
)
|
|
38
|
+
}
|