okengine 0.15.2 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/site/content/docs/elements/flow.mdx +6 -0
- package/site/content/docs/elements/gate.mdx +55 -0
- package/site/content/docs/elements/store.mdx +13 -15
- package/site/content/docs/elements/vault.mdx +3 -0
- package/site/content/docs/reference/client.mdx +3 -0
- package/site/content/docs/reference/fx.mdx +29 -18
- package/site/content/docs/reference/security.md +1 -1
- package/src/auth/api-key-sql.test.ts +47 -0
- package/src/auth/api-key-sql.ts +254 -0
- package/src/auth/api-keys.ts +273 -14
- package/src/auth/auth.test.ts +56 -0
- package/src/auth/config.ts +5 -5
- package/src/auth/index.ts +25 -1
- package/src/auth/schema.ts +8 -0
- package/src/cli/attach-host-console.test.ts +37 -0
- package/src/cli/attach-host-console.ts +6 -0
- package/src/compiler/effects-infer.ts +17 -2
- package/src/console/server/access.test.ts +116 -8
- package/src/console/server/access.ts +70 -23
- package/src/console/server/app.ts +1 -0
- package/src/console/server/dev-identities.test.ts +127 -0
- package/src/console/server/dev-identities.ts +229 -0
- package/src/console/server/flows-invoke.test.ts +21 -4
- package/src/console/server/flows.ts +113 -58
- package/src/console/server/gates.test.ts +24 -0
- package/src/console/server/gates.ts +8 -21
- package/src/console/server/index.ts +1 -0
- package/src/console/server/live.test.ts +26 -1
- package/src/console/server/live.ts +4 -2
- package/src/console/server/operator-db.ts +2 -0
- package/src/console/server/state.ts +29 -55
- package/src/console/ui-next/dist/assets/access-page-CAGHrA9H.js +4 -0
- package/src/console/ui-next/dist/assets/{agent-disclosure-C0X1fbWF.js → agent-disclosure-BP0Y0Sux.js} +1 -1
- package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
- package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
- package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
- package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
- package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
- package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
- package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-B-OUtiAu.js +1 -0
- package/src/console/ui-next/dist/assets/{highlighted-json-CS_O8L-r.js → highlighted-json-Awq7gYdu.js} +1 -1
- package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
- package/src/console/ui-next/dist/assets/index-CGoZkILK.js +66 -0
- package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
- package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
- package/src/console/ui-next/dist/assets/observability-page-BDyXalNR.js +4 -0
- package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
- package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
- package/src/console/ui-next/dist/assets/skeleton-D-czQJT6.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-Xh8Kn3rx.js +41 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
- package/src/console/ui-next/dist/assets/tree-expand-toggle-DkOXA12R.js +54 -0
- package/src/console/ui-next/dist/assets/units-page-Dpk40kOQ.js +1 -0
- package/src/console/ui-next/dist/assets/{use-vault-list-BLbZhzyF.js → use-vault-list-uk4WVboC.js} +1 -1
- package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
- package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
- package/src/console/ui-next/dist/assets/vault-page-B1dB9Ft0.js +2 -0
- package/src/console/ui-next/dist/index.html +11 -9
- package/src/console/ui-next/index.html +3 -3
- package/src/console/ui-next/src/client.ts +167 -0
- package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
- package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
- package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
- package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
- package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
- package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
- package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
- package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
- package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
- package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
- package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
- package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
- package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
- package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
- package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
- package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
- package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
- package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
- package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
- package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
- package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
- package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
- package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
- package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
- package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -1
- package/src/console/ui-next/src/lib/document-meta.test.ts +1 -0
- package/src/console/ui-next/src/lib/document-meta.ts +2 -1
- package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
- package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
- package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
- package/src/console/ui-next/src/lib/shortcut.ts +3 -0
- package/src/console/ui-next/src/router.tsx +10 -1
- package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
- package/src/console/ui-next/ui-next-seed-store.ts +43 -15
- package/src/console/xss.gate.test.ts +1 -0
- package/src/elements/gate/declare.ts +2 -0
- package/src/elements/store/cache.ts +1 -1
- package/src/elements/store/runtime.ts +14 -0
- package/src/elements/store/schema-decl.ts +99 -41
- package/src/elements/store/sql-select.types.test.ts +71 -0
- package/src/elements/store/sql-session.ts +54 -32
- package/src/elements/store.ts +3 -0
- package/src/index.ts +1 -0
- package/src/kernel/api-key-host-persist.test.ts +143 -0
- package/src/kernel/app.ts +14 -2
- package/src/kernel/auth-resolve.ts +10 -5
- package/src/kernel/boot.test.ts +4 -4
- package/src/kernel/fx-auth-keys.test.ts +88 -0
- package/src/kernel/fx-auth-keys.ts +198 -0
- package/src/kernel/fx.test.ts +17 -1
- package/src/kernel/fx.ts +36 -18
- package/src/kernel/pipeline-api-key.test.ts +192 -7
- package/src/kernel/pipeline.ts +4 -2
- package/src/manifest/types.ts +9 -3
- package/src/release/build-lib.ts +1 -0
- package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +0 -1
- package/src/console/ui-next/dist/assets/call-pii-button-DEDCl_j3.js +0 -1
- package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +0 -1
- package/src/console/ui-next/dist/assets/confirm-sheet-3ptDzXbz.js +0 -1
- package/src/console/ui-next/dist/assets/explorer-empty-BvYrygyO.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +0 -1
- package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +0 -1
- package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +0 -66
- package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +0 -2
- package/src/console/ui-next/dist/assets/link-COeyggt-.js +0 -1
- package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +0 -4
- package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +0 -18
- package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +0 -1
- package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +0 -41
- package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +0 -2
- package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +0 -54
- package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +0 -1
- package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +0 -2
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access key inspector — grant, bounds, usage, working call.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { CrownIcon, Key01Icon, UserIcon } from "@hugeicons/core-free-icons";
|
|
6
|
+
import { HugeiconsIcon } from "@hugeicons/react";
|
|
7
|
+
import { useMemo, type JSX, type ReactNode } from "react";
|
|
8
|
+
import type { AccessKeyBlastPayload, AccessKeyRow } from "@/client.ts";
|
|
9
|
+
import { CopyInlineButton } from "@/components/explorer/copy-inline-button.tsx";
|
|
10
|
+
import { DetailHeader } from "@/components/explorer/detail-header.tsx";
|
|
11
|
+
import {
|
|
12
|
+
EXPLORER_COUNT_CLASS,
|
|
13
|
+
EXPLORER_ROW_CLASS,
|
|
14
|
+
EXPLORER_STRIP_CLASS,
|
|
15
|
+
EXPLORER_STRIP_TOKEN_CLASS,
|
|
16
|
+
EXPLORER_STRIP_TOKEN_IDLE_CLASS,
|
|
17
|
+
SECTION_HEAD_CLASS,
|
|
18
|
+
explorerIconInk,
|
|
19
|
+
} from "@/components/explorer/explorer-chrome.ts";
|
|
20
|
+
import { Button } from "@/components/ui/button";
|
|
21
|
+
import { ToolbarTip } from "@/components/ui/toolbar-tip.tsx";
|
|
22
|
+
import { httpMethodBadgeClass, httpMethodRailClass } from "@/features/flows/traces/http-method.ts";
|
|
23
|
+
import { cn } from "@/lib/utils.ts";
|
|
24
|
+
import {
|
|
25
|
+
classifyAccessAllowEntry,
|
|
26
|
+
formatAccessExpiry,
|
|
27
|
+
formatAccessWhen,
|
|
28
|
+
} from "../lib/format-when.ts";
|
|
29
|
+
import { groupAccessScopes } from "../lib/scope-groups.ts";
|
|
30
|
+
|
|
31
|
+
const CALL_REQUEST = `GET /secure
|
|
32
|
+
Authorization: Bearer <secret>`;
|
|
33
|
+
const CALL_RESPONSE = `{ "ok": true }`;
|
|
34
|
+
|
|
35
|
+
/** Props for {@link AccessDetail}. */
|
|
36
|
+
export interface AccessDetailProps {
|
|
37
|
+
readonly keyRow: AccessKeyRow;
|
|
38
|
+
readonly blast: AccessKeyBlastPayload | undefined;
|
|
39
|
+
readonly now: number;
|
|
40
|
+
readonly leading?: ReactNode;
|
|
41
|
+
readonly onEdit: () => void;
|
|
42
|
+
readonly onRefresh: () => void;
|
|
43
|
+
readonly onRevoke: () => void;
|
|
44
|
+
readonly onRotate: () => void;
|
|
45
|
+
readonly rotatePending: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Right-pane key inspector.
|
|
50
|
+
*
|
|
51
|
+
* @param props - Selected key + actions
|
|
52
|
+
*/
|
|
53
|
+
export function AccessDetail({
|
|
54
|
+
keyRow: row,
|
|
55
|
+
blast,
|
|
56
|
+
now,
|
|
57
|
+
leading,
|
|
58
|
+
onEdit,
|
|
59
|
+
onRefresh,
|
|
60
|
+
onRevoke,
|
|
61
|
+
onRotate,
|
|
62
|
+
rotatePending,
|
|
63
|
+
}: AccessDetailProps): JSX.Element {
|
|
64
|
+
const revoked = row.revokedAt !== null;
|
|
65
|
+
const expired = row.expiresAt !== null && row.expiresAt <= now;
|
|
66
|
+
const lastUsed = blast?.lastUsedAt ?? row.lastUsedAt;
|
|
67
|
+
const calls = blast?.callVolume ?? 0;
|
|
68
|
+
const expiry = formatAccessExpiry(row.expiresAt, now);
|
|
69
|
+
const used = formatAccessWhen(lastUsed, now);
|
|
70
|
+
const rate = row.rateLimit ? `${row.rateLimit.max} / ${row.rateLimit.per}` : "none";
|
|
71
|
+
const groups = useMemo(() => groupAccessScopes(row.scopes), [row.scopes]);
|
|
72
|
+
const ceiling = row.creatorScopes.length;
|
|
73
|
+
const attenuated = ceiling > 0 && row.scopes.length < ceiling;
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div
|
|
77
|
+
className="flex h-full min-h-0 flex-col overflow-y-auto"
|
|
78
|
+
data-slot="access-detail"
|
|
79
|
+
aria-label="Access key detail"
|
|
80
|
+
aria-live="polite"
|
|
81
|
+
>
|
|
82
|
+
<DetailHeader
|
|
83
|
+
dataSlot="access-detail-header"
|
|
84
|
+
leading={leading}
|
|
85
|
+
icon={<HugeiconsIcon icon={Key01Icon} className="size-4" />}
|
|
86
|
+
title={row.name}
|
|
87
|
+
badge={
|
|
88
|
+
<>
|
|
89
|
+
<span className="inline-flex items-center gap-1 font-mono text-[10px] font-medium tracking-[0.08em] text-muted-foreground uppercase">
|
|
90
|
+
<HugeiconsIcon
|
|
91
|
+
icon={row.plane === "operator" ? CrownIcon : UserIcon}
|
|
92
|
+
className="size-3"
|
|
93
|
+
aria-hidden
|
|
94
|
+
/>
|
|
95
|
+
{row.plane}
|
|
96
|
+
</span>
|
|
97
|
+
{revoked ? (
|
|
98
|
+
<span className="font-mono text-[10px] text-destructive">revoked</span>
|
|
99
|
+
) : null}
|
|
100
|
+
{expired && !revoked ? (
|
|
101
|
+
<span className="font-mono text-[10px] text-amber-700 dark:text-amber-400">
|
|
102
|
+
expired
|
|
103
|
+
</span>
|
|
104
|
+
) : null}
|
|
105
|
+
{row.unused90d && !revoked && !expired ? (
|
|
106
|
+
<span className="font-mono text-[10px] text-amber-700 dark:text-amber-400">
|
|
107
|
+
unused
|
|
108
|
+
</span>
|
|
109
|
+
) : null}
|
|
110
|
+
</>
|
|
111
|
+
}
|
|
112
|
+
subtitle={
|
|
113
|
+
<>
|
|
114
|
+
<code className="min-w-0 truncate font-mono text-[11px] leading-none text-foreground/80">
|
|
115
|
+
{row.id}
|
|
116
|
+
</code>
|
|
117
|
+
<CopyInlineButton value={row.id} label={`Copy ${row.id}`} />
|
|
118
|
+
</>
|
|
119
|
+
}
|
|
120
|
+
actions={
|
|
121
|
+
<>
|
|
122
|
+
<ToolbarTip label="Rename or re-attenuate scopes" className="flex self-stretch">
|
|
123
|
+
<Button
|
|
124
|
+
type="button"
|
|
125
|
+
variant="ghost"
|
|
126
|
+
size="sm"
|
|
127
|
+
className="h-full rounded-none"
|
|
128
|
+
disabled={revoked}
|
|
129
|
+
onClick={onEdit}
|
|
130
|
+
>
|
|
131
|
+
Edit
|
|
132
|
+
</Button>
|
|
133
|
+
</ToolbarTip>
|
|
134
|
+
<ToolbarTip
|
|
135
|
+
label="Reset expiry from now. The secret stays the same."
|
|
136
|
+
className="flex self-stretch"
|
|
137
|
+
>
|
|
138
|
+
<Button
|
|
139
|
+
type="button"
|
|
140
|
+
variant="ghost"
|
|
141
|
+
size="sm"
|
|
142
|
+
className="h-full rounded-none"
|
|
143
|
+
data-slot="access-refresh"
|
|
144
|
+
disabled={revoked}
|
|
145
|
+
onClick={onRefresh}
|
|
146
|
+
>
|
|
147
|
+
Refresh
|
|
148
|
+
</Button>
|
|
149
|
+
</ToolbarTip>
|
|
150
|
+
<ToolbarTip
|
|
151
|
+
label="Mint a new secret. The current secret stops working after residual TTL."
|
|
152
|
+
className="flex self-stretch"
|
|
153
|
+
>
|
|
154
|
+
<Button
|
|
155
|
+
type="button"
|
|
156
|
+
variant="ghost"
|
|
157
|
+
size="sm"
|
|
158
|
+
className="h-full rounded-none"
|
|
159
|
+
disabled={rotatePending || revoked}
|
|
160
|
+
onClick={onRotate}
|
|
161
|
+
>
|
|
162
|
+
Rotate
|
|
163
|
+
</Button>
|
|
164
|
+
</ToolbarTip>
|
|
165
|
+
<ToolbarTip
|
|
166
|
+
label="Irreversible. Residual sessions may continue for the access TTL."
|
|
167
|
+
className="flex self-stretch"
|
|
168
|
+
>
|
|
169
|
+
<Button
|
|
170
|
+
type="button"
|
|
171
|
+
variant="destructive"
|
|
172
|
+
size="sm"
|
|
173
|
+
className="h-full rounded-none"
|
|
174
|
+
disabled={revoked}
|
|
175
|
+
onClick={onRevoke}
|
|
176
|
+
>
|
|
177
|
+
Revoke
|
|
178
|
+
</Button>
|
|
179
|
+
</ToolbarTip>
|
|
180
|
+
</>
|
|
181
|
+
}
|
|
182
|
+
/>
|
|
183
|
+
|
|
184
|
+
<PostureBanner
|
|
185
|
+
revoked={revoked}
|
|
186
|
+
expired={expired}
|
|
187
|
+
unused={row.unused90d}
|
|
188
|
+
residual={blast?.residualAccessNote}
|
|
189
|
+
/>
|
|
190
|
+
|
|
191
|
+
<div
|
|
192
|
+
className={EXPLORER_STRIP_CLASS}
|
|
193
|
+
data-slot="access-briefing"
|
|
194
|
+
role="group"
|
|
195
|
+
aria-label="Usage"
|
|
196
|
+
>
|
|
197
|
+
<StatusToken label="calls" value={String(calls)} />
|
|
198
|
+
<StatusToken
|
|
199
|
+
label="used"
|
|
200
|
+
value={used}
|
|
201
|
+
hint={lastUsed ? new Date(lastUsed).toISOString() : "No recorded call"}
|
|
202
|
+
warn={lastUsed == null && !revoked}
|
|
203
|
+
/>
|
|
204
|
+
<StatusToken
|
|
205
|
+
label="expires"
|
|
206
|
+
value={expiry}
|
|
207
|
+
hint={row.expiresAt ? new Date(row.expiresAt).toISOString() : "No expiry"}
|
|
208
|
+
warn={expired && !revoked}
|
|
209
|
+
/>
|
|
210
|
+
<StatusToken label="rate" value={rate} />
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<section className="shrink-0 border-b border-border/60" aria-label="Scopes">
|
|
214
|
+
<SectionStrip
|
|
215
|
+
title="Scopes"
|
|
216
|
+
meta={attenuated ? `${row.scopes.length} of ${ceiling}` : String(row.scopes.length)}
|
|
217
|
+
>
|
|
218
|
+
{row.scopes.length > 0 ? (
|
|
219
|
+
<CopyInlineButton value={row.scopes.join("\n")} label="Copy scopes" />
|
|
220
|
+
) : null}
|
|
221
|
+
</SectionStrip>
|
|
222
|
+
{groups.length === 0 ? (
|
|
223
|
+
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">No scopes granted.</p>
|
|
224
|
+
) : (
|
|
225
|
+
<ul data-slot="access-scopes">
|
|
226
|
+
{groups.map((band) => (
|
|
227
|
+
<li key={band.group} className={EXPLORER_ROW_CLASS} aria-label={band.group}>
|
|
228
|
+
<span className={cn(SECTION_HEAD_CLASS, "w-[5.5rem] shrink-0")}>{band.group}</span>
|
|
229
|
+
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2">
|
|
230
|
+
{band.items.map((item) => (
|
|
231
|
+
<span
|
|
232
|
+
key={item.scope}
|
|
233
|
+
title={item.scope}
|
|
234
|
+
className="font-mono text-[11px] text-foreground"
|
|
235
|
+
>
|
|
236
|
+
{item.action}
|
|
237
|
+
</span>
|
|
238
|
+
))}
|
|
239
|
+
</div>
|
|
240
|
+
<HoverCopy
|
|
241
|
+
value={band.items.map((item) => item.scope).join("\n")}
|
|
242
|
+
label={`Copy ${band.group} scopes`}
|
|
243
|
+
/>
|
|
244
|
+
</li>
|
|
245
|
+
))}
|
|
246
|
+
</ul>
|
|
247
|
+
)}
|
|
248
|
+
</section>
|
|
249
|
+
|
|
250
|
+
<section className="shrink-0 border-b border-border/60" aria-label="Key">
|
|
251
|
+
<SectionStrip title="Key" />
|
|
252
|
+
<dl data-slot="access-key-facts">
|
|
253
|
+
<FactRow
|
|
254
|
+
label="Issuer"
|
|
255
|
+
value={row.creatorId}
|
|
256
|
+
copy={row.creatorId}
|
|
257
|
+
copyLabel={`Copy ${row.creatorId}`}
|
|
258
|
+
/>
|
|
259
|
+
<FactRow
|
|
260
|
+
label="Created"
|
|
261
|
+
value={formatAccessWhen(row.createdAt, now)}
|
|
262
|
+
hint={new Date(row.createdAt).toISOString()}
|
|
263
|
+
/>
|
|
264
|
+
<FactRow
|
|
265
|
+
label="Expires"
|
|
266
|
+
value={expiry}
|
|
267
|
+
hint={row.expiresAt ? new Date(row.expiresAt).toISOString() : undefined}
|
|
268
|
+
warn={expired && !revoked}
|
|
269
|
+
/>
|
|
270
|
+
<FactRow label="Rate" value={rate} />
|
|
271
|
+
</dl>
|
|
272
|
+
</section>
|
|
273
|
+
|
|
274
|
+
<AllowSection entries={row.ipAllowlist} />
|
|
275
|
+
|
|
276
|
+
<UsageSection
|
|
277
|
+
sourceAddresses={blast?.sourceAddresses ?? []}
|
|
278
|
+
residual={blast?.residualAccessNote}
|
|
279
|
+
lastUsed={lastUsed}
|
|
280
|
+
now={now}
|
|
281
|
+
/>
|
|
282
|
+
|
|
283
|
+
<CallExample scopes={row.scopes} />
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function PostureBanner({
|
|
289
|
+
revoked,
|
|
290
|
+
expired,
|
|
291
|
+
unused,
|
|
292
|
+
residual,
|
|
293
|
+
}: {
|
|
294
|
+
readonly revoked: boolean;
|
|
295
|
+
readonly expired: boolean;
|
|
296
|
+
readonly unused: boolean;
|
|
297
|
+
readonly residual: string | undefined;
|
|
298
|
+
}): JSX.Element | null {
|
|
299
|
+
if (revoked) {
|
|
300
|
+
return (
|
|
301
|
+
<section
|
|
302
|
+
className={cn(EXPLORER_STRIP_CLASS, "border-destructive/25 bg-destructive/5")}
|
|
303
|
+
aria-label="Revoked"
|
|
304
|
+
role="status"
|
|
305
|
+
>
|
|
306
|
+
<p className="flex min-w-0 flex-1 items-center px-2 text-[12px] text-destructive">
|
|
307
|
+
Revoked
|
|
308
|
+
<span className="text-muted-foreground">
|
|
309
|
+
{" "}
|
|
310
|
+
· {residual ?? "This secret no longer authenticates."}
|
|
311
|
+
</span>
|
|
312
|
+
</p>
|
|
313
|
+
</section>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
if (expired) {
|
|
317
|
+
return (
|
|
318
|
+
<section
|
|
319
|
+
className={cn(EXPLORER_STRIP_CLASS, "border-amber-500/25 bg-amber-500/5")}
|
|
320
|
+
aria-label="Expired"
|
|
321
|
+
role="status"
|
|
322
|
+
>
|
|
323
|
+
<p className="flex min-w-0 flex-1 items-center px-2 text-[12px] text-amber-900 dark:text-amber-300">
|
|
324
|
+
Expired
|
|
325
|
+
{residual ? <span className="text-muted-foreground"> · {residual}</span> : null}
|
|
326
|
+
</p>
|
|
327
|
+
</section>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (unused) {
|
|
331
|
+
return (
|
|
332
|
+
<section
|
|
333
|
+
className={cn(EXPLORER_STRIP_CLASS, "border-amber-500/25 bg-amber-500/5")}
|
|
334
|
+
aria-label="Unused"
|
|
335
|
+
role="status"
|
|
336
|
+
>
|
|
337
|
+
<p className="flex min-w-0 flex-1 items-center px-2 text-[12px] text-amber-900 dark:text-amber-300">
|
|
338
|
+
No calls in 90 days
|
|
339
|
+
<span className="text-muted-foreground"> · rotate or revoke</span>
|
|
340
|
+
</p>
|
|
341
|
+
</section>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function AllowSection({ entries }: { readonly entries: readonly string[] }): JSX.Element {
|
|
348
|
+
return (
|
|
349
|
+
<section className="shrink-0 border-b border-border/60" aria-label="Allow">
|
|
350
|
+
<SectionStrip title="Allow" meta={entries.length > 0 ? String(entries.length) : "any"} />
|
|
351
|
+
{entries.length === 0 ? (
|
|
352
|
+
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">Any origin.</p>
|
|
353
|
+
) : (
|
|
354
|
+
<ul data-slot="access-allow">
|
|
355
|
+
{entries.map((entry) => {
|
|
356
|
+
const kind = classifyAccessAllowEntry(entry);
|
|
357
|
+
return (
|
|
358
|
+
<li key={entry} className={EXPLORER_ROW_CLASS}>
|
|
359
|
+
<span className={cn(SECTION_HEAD_CLASS, "w-[5.5rem] shrink-0")}>
|
|
360
|
+
{kind === "ip" ? "IP" : kind === "host" ? "Host" : "—"}
|
|
361
|
+
</span>
|
|
362
|
+
<span className="min-w-0 flex-1 truncate font-mono text-[11px]">{entry}</span>
|
|
363
|
+
<HoverCopy value={entry} label={`Copy ${entry}`} />
|
|
364
|
+
</li>
|
|
365
|
+
);
|
|
366
|
+
})}
|
|
367
|
+
</ul>
|
|
368
|
+
)}
|
|
369
|
+
</section>
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function UsageSection({
|
|
374
|
+
sourceAddresses,
|
|
375
|
+
residual,
|
|
376
|
+
lastUsed,
|
|
377
|
+
now,
|
|
378
|
+
}: {
|
|
379
|
+
readonly sourceAddresses: readonly string[];
|
|
380
|
+
readonly residual: string | undefined;
|
|
381
|
+
readonly lastUsed: number | null;
|
|
382
|
+
readonly now: number;
|
|
383
|
+
}): JSX.Element {
|
|
384
|
+
return (
|
|
385
|
+
<section className="shrink-0 border-b border-border/60" aria-label="Usage">
|
|
386
|
+
<SectionStrip title="Usage" meta={lastUsed ? formatAccessWhen(lastUsed, now) : "never"} />
|
|
387
|
+
{sourceAddresses.length === 0 ? (
|
|
388
|
+
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">No observed source IPs.</p>
|
|
389
|
+
) : (
|
|
390
|
+
<ul data-slot="access-sources">
|
|
391
|
+
{sourceAddresses.map((ip) => (
|
|
392
|
+
<li key={ip} className={EXPLORER_ROW_CLASS}>
|
|
393
|
+
<span className={cn(SECTION_HEAD_CLASS, "w-[5.5rem] shrink-0")}>IP</span>
|
|
394
|
+
<span className="min-w-0 flex-1 truncate font-mono text-[11px]">{ip}</span>
|
|
395
|
+
<HoverCopy value={ip} label={`Copy ${ip}`} />
|
|
396
|
+
</li>
|
|
397
|
+
))}
|
|
398
|
+
</ul>
|
|
399
|
+
)}
|
|
400
|
+
{residual ? (
|
|
401
|
+
<p className="border-t border-border/60 px-2 py-1.5 text-[11px] text-muted-foreground">
|
|
402
|
+
{residual}
|
|
403
|
+
</p>
|
|
404
|
+
) : null}
|
|
405
|
+
</section>
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function CallExample({ scopes }: { readonly scopes: readonly string[] }): JSX.Element {
|
|
410
|
+
return (
|
|
411
|
+
<section data-slot="access-call-example">
|
|
412
|
+
<SectionStrip title="Request">
|
|
413
|
+
<CopyInlineButton value={CALL_REQUEST} label="Copy request" />
|
|
414
|
+
</SectionStrip>
|
|
415
|
+
<div className="flex min-w-0 border-b border-border/60">
|
|
416
|
+
<div className={cn("w-1 shrink-0 self-stretch", httpMethodRailClass("GET"))} aria-hidden />
|
|
417
|
+
<div className="min-w-0 flex-1">
|
|
418
|
+
<div className="flex min-w-0 items-center gap-2 px-2.5 py-2">
|
|
419
|
+
<span
|
|
420
|
+
className={cn(
|
|
421
|
+
"shrink-0 font-mono text-[10px] font-semibold tracking-[0.08em] uppercase",
|
|
422
|
+
explorerIconInk(httpMethodBadgeClass("GET")),
|
|
423
|
+
)}
|
|
424
|
+
>
|
|
425
|
+
GET
|
|
426
|
+
</span>
|
|
427
|
+
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground select-all">
|
|
428
|
+
/secure
|
|
429
|
+
</span>
|
|
430
|
+
</div>
|
|
431
|
+
<div className="flex min-w-0 items-center gap-2 border-t border-border/60 px-2.5 py-2">
|
|
432
|
+
<span className="w-[7.5rem] shrink-0 truncate font-mono text-[11px] font-medium text-sky-600 dark:text-sky-400">
|
|
433
|
+
Authorization
|
|
434
|
+
</span>
|
|
435
|
+
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground">
|
|
436
|
+
Bearer <secret>
|
|
437
|
+
</span>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
|
|
442
|
+
<SectionStrip title="Response">
|
|
443
|
+
<CopyInlineButton value={CALL_RESPONSE} label="Copy response" />
|
|
444
|
+
</SectionStrip>
|
|
445
|
+
<div className="flex min-w-0 border-b border-border/60">
|
|
446
|
+
<div className="w-1 shrink-0 self-stretch bg-emerald-500" aria-hidden />
|
|
447
|
+
<p className="min-w-0 flex-1 px-2.5 py-2 font-mono text-[11px] text-foreground/80 select-all">
|
|
448
|
+
{CALL_RESPONSE}
|
|
449
|
+
</p>
|
|
450
|
+
</div>
|
|
451
|
+
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">
|
|
452
|
+
Seed the Bearer secret from create / rotate. The key is the issuer; gates see{" "}
|
|
453
|
+
{scopes.join(", ") || "its scopes"}.
|
|
454
|
+
</p>
|
|
455
|
+
</section>
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function SectionStrip({
|
|
460
|
+
title,
|
|
461
|
+
meta,
|
|
462
|
+
children,
|
|
463
|
+
}: {
|
|
464
|
+
readonly title: string;
|
|
465
|
+
readonly meta?: string;
|
|
466
|
+
readonly children?: ReactNode;
|
|
467
|
+
}): JSX.Element {
|
|
468
|
+
return (
|
|
469
|
+
<header className={cn(EXPLORER_STRIP_CLASS, "items-center gap-2 px-2")}>
|
|
470
|
+
<h3 className={cn(SECTION_HEAD_CLASS, "shrink-0 leading-none")}>{title}</h3>
|
|
471
|
+
{meta ? (
|
|
472
|
+
<span className={cn(EXPLORER_COUNT_CLASS, !children && "ml-auto")}>{meta}</span>
|
|
473
|
+
) : null}
|
|
474
|
+
{children ? <div className="ml-auto flex items-center">{children}</div> : null}
|
|
475
|
+
</header>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function StatusToken({
|
|
480
|
+
label,
|
|
481
|
+
value,
|
|
482
|
+
hint,
|
|
483
|
+
warn = false,
|
|
484
|
+
}: {
|
|
485
|
+
readonly label: string;
|
|
486
|
+
readonly value: string;
|
|
487
|
+
readonly hint?: string;
|
|
488
|
+
readonly warn?: boolean;
|
|
489
|
+
}): JSX.Element {
|
|
490
|
+
return (
|
|
491
|
+
<span
|
|
492
|
+
title={hint}
|
|
493
|
+
className={cn(EXPLORER_STRIP_TOKEN_CLASS, EXPLORER_STRIP_TOKEN_IDLE_CLASS, "gap-1.5")}
|
|
494
|
+
>
|
|
495
|
+
<span className="tracking-[0.08em] uppercase">{label}</span>
|
|
496
|
+
<span
|
|
497
|
+
className={cn(
|
|
498
|
+
"font-mono tabular-nums text-foreground",
|
|
499
|
+
warn && "text-amber-800 dark:text-amber-400",
|
|
500
|
+
)}
|
|
501
|
+
>
|
|
502
|
+
{value}
|
|
503
|
+
</span>
|
|
504
|
+
</span>
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function HoverCopy({
|
|
509
|
+
value,
|
|
510
|
+
label,
|
|
511
|
+
}: {
|
|
512
|
+
readonly value: string;
|
|
513
|
+
readonly label: string;
|
|
514
|
+
}): JSX.Element {
|
|
515
|
+
return (
|
|
516
|
+
<div className="opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
|
517
|
+
<CopyInlineButton value={value} label={label} />
|
|
518
|
+
</div>
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function FactRow({
|
|
523
|
+
label,
|
|
524
|
+
value,
|
|
525
|
+
hint,
|
|
526
|
+
copy,
|
|
527
|
+
copyLabel,
|
|
528
|
+
warn = false,
|
|
529
|
+
}: {
|
|
530
|
+
readonly label: string;
|
|
531
|
+
readonly value: string;
|
|
532
|
+
readonly hint?: string;
|
|
533
|
+
readonly copy?: string;
|
|
534
|
+
readonly copyLabel?: string;
|
|
535
|
+
readonly warn?: boolean;
|
|
536
|
+
}): JSX.Element {
|
|
537
|
+
return (
|
|
538
|
+
<div className={EXPLORER_ROW_CLASS}>
|
|
539
|
+
<dt className={cn(SECTION_HEAD_CLASS, "w-[5.5rem] shrink-0")}>{label}</dt>
|
|
540
|
+
<dd
|
|
541
|
+
className={cn(
|
|
542
|
+
"min-w-0 flex-1 truncate font-mono text-[11px]",
|
|
543
|
+
warn && "text-amber-800 dark:text-amber-400",
|
|
544
|
+
)}
|
|
545
|
+
title={hint}
|
|
546
|
+
>
|
|
547
|
+
{value}
|
|
548
|
+
</dd>
|
|
549
|
+
{copy ? <HoverCopy value={copy} label={copyLabel ?? `Copy ${label}`} /> : null}
|
|
550
|
+
</div>
|
|
551
|
+
);
|
|
552
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access key list — user / operator bands.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { CrownIcon, Key01Icon, UserIcon } from "@hugeicons/core-free-icons";
|
|
6
|
+
import { HugeiconsIcon } from "@hugeicons/react";
|
|
7
|
+
import type { JSX } from "react";
|
|
8
|
+
import type { AccessKeyRow } from "@/client.ts";
|
|
9
|
+
import {
|
|
10
|
+
EXPLORER_BAND_CLASS,
|
|
11
|
+
EXPLORER_BAND_HEADER_CLASS,
|
|
12
|
+
EXPLORER_BAND_LABEL_CLASS,
|
|
13
|
+
EXPLORER_COUNT_CLASS,
|
|
14
|
+
EXPLORER_ICON_CLASS,
|
|
15
|
+
EXPLORER_LIST_EMPTY_CLASS,
|
|
16
|
+
EXPLORER_RAIL_ACTIVE_CLASS,
|
|
17
|
+
EXPLORER_RAIL_CLASS,
|
|
18
|
+
EXPLORER_ROW_CLASS,
|
|
19
|
+
EXPLORER_ROW_SELECTED_CLASS,
|
|
20
|
+
} from "@/components/explorer/explorer-chrome.ts";
|
|
21
|
+
import type { ElementHugeIcon } from "@/lib/element-icons.ts";
|
|
22
|
+
import { cn } from "@/lib/utils.ts";
|
|
23
|
+
|
|
24
|
+
/** Props for {@link AccessList}. */
|
|
25
|
+
export interface AccessListProps {
|
|
26
|
+
readonly rows: readonly AccessKeyRow[];
|
|
27
|
+
readonly selectedKey: string | null;
|
|
28
|
+
readonly query: string;
|
|
29
|
+
readonly loading?: boolean;
|
|
30
|
+
readonly onSelect: (id: string) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Grouped key list. Empty bands stay visible so both planes remain taught.
|
|
35
|
+
*
|
|
36
|
+
* @param props - Filtered rows + selection
|
|
37
|
+
*/
|
|
38
|
+
export function AccessList({
|
|
39
|
+
rows,
|
|
40
|
+
selectedKey,
|
|
41
|
+
query,
|
|
42
|
+
loading = false,
|
|
43
|
+
onSelect,
|
|
44
|
+
}: AccessListProps): JSX.Element {
|
|
45
|
+
const user = rows.filter((row) => row.plane === "user");
|
|
46
|
+
const operator = rows.filter((row) => row.plane === "operator");
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div id="access-list" data-slot="access-list" className="min-h-0 flex-1 overflow-y-auto">
|
|
50
|
+
{loading && rows.length === 0 ? <p className={EXPLORER_LIST_EMPTY_CLASS}>Loading…</p> : null}
|
|
51
|
+
{!loading && rows.length === 0 && query.trim().length > 0 ? (
|
|
52
|
+
<p className={EXPLORER_LIST_EMPTY_CLASS}>No keys match.</p>
|
|
53
|
+
) : null}
|
|
54
|
+
<AccessBand
|
|
55
|
+
label="User"
|
|
56
|
+
icon={UserIcon}
|
|
57
|
+
rows={user}
|
|
58
|
+
selectedKey={selectedKey}
|
|
59
|
+
onSelect={onSelect}
|
|
60
|
+
/>
|
|
61
|
+
<AccessBand
|
|
62
|
+
label="Operator"
|
|
63
|
+
icon={CrownIcon}
|
|
64
|
+
rows={operator}
|
|
65
|
+
selectedKey={selectedKey}
|
|
66
|
+
onSelect={onSelect}
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function AccessBand(props: {
|
|
73
|
+
readonly label: string;
|
|
74
|
+
readonly icon: ElementHugeIcon;
|
|
75
|
+
readonly rows: readonly AccessKeyRow[];
|
|
76
|
+
readonly selectedKey: string | null;
|
|
77
|
+
readonly onSelect: (id: string) => void;
|
|
78
|
+
}): JSX.Element {
|
|
79
|
+
return (
|
|
80
|
+
<section className={EXPLORER_BAND_CLASS} aria-label={props.label}>
|
|
81
|
+
<div className={EXPLORER_BAND_HEADER_CLASS}>
|
|
82
|
+
<HugeiconsIcon
|
|
83
|
+
icon={props.icon}
|
|
84
|
+
className={cn(EXPLORER_ICON_CLASS, "text-muted-foreground")}
|
|
85
|
+
aria-hidden
|
|
86
|
+
/>
|
|
87
|
+
<span className={cn(EXPLORER_BAND_LABEL_CLASS, "flex-1")}>{props.label}</span>
|
|
88
|
+
<span className={EXPLORER_COUNT_CLASS}>{props.rows.length}</span>
|
|
89
|
+
</div>
|
|
90
|
+
{props.rows.map((row) => {
|
|
91
|
+
const selected = row.id === props.selectedKey;
|
|
92
|
+
const revoked = row.revokedAt !== null;
|
|
93
|
+
return (
|
|
94
|
+
<button
|
|
95
|
+
key={row.id}
|
|
96
|
+
type="button"
|
|
97
|
+
data-slot="access-key-row"
|
|
98
|
+
className={cn(EXPLORER_ROW_CLASS, selected && EXPLORER_ROW_SELECTED_CLASS)}
|
|
99
|
+
onClick={() => props.onSelect(row.id)}
|
|
100
|
+
>
|
|
101
|
+
<span className={cn(EXPLORER_RAIL_CLASS, selected && EXPLORER_RAIL_ACTIVE_CLASS)} />
|
|
102
|
+
<HugeiconsIcon
|
|
103
|
+
icon={Key01Icon}
|
|
104
|
+
className={cn(EXPLORER_ICON_CLASS, "text-muted-foreground")}
|
|
105
|
+
/>
|
|
106
|
+
<span
|
|
107
|
+
className={cn("min-w-0 truncate", revoked && "text-muted-foreground line-through")}
|
|
108
|
+
>
|
|
109
|
+
{row.name}
|
|
110
|
+
</span>
|
|
111
|
+
{revoked ? (
|
|
112
|
+
<span className="ml-auto text-[10px] text-muted-foreground">revoked</span>
|
|
113
|
+
) : row.unused90d ? (
|
|
114
|
+
<span className="ml-auto text-[10px] text-amber-700 dark:text-amber-400">unused</span>
|
|
115
|
+
) : (
|
|
116
|
+
<span className={cn("ml-auto tabular-nums text-[10px] text-muted-foreground")}>
|
|
117
|
+
{row.scopes.length}
|
|
118
|
+
</span>
|
|
119
|
+
)}
|
|
120
|
+
</button>
|
|
121
|
+
);
|
|
122
|
+
})}
|
|
123
|
+
</section>
|
|
124
|
+
);
|
|
125
|
+
}
|