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,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Allow list (add one) + split rate fields — create / edit.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Cancel01Icon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
|
6
|
+
import { HugeiconsIcon } from "@hugeicons/react";
|
|
7
|
+
import { useState, type JSX, type KeyboardEvent } from "react";
|
|
8
|
+
import {
|
|
9
|
+
EXPLORER_COUNT_CLASS,
|
|
10
|
+
EXPLORER_ICON_BUTTON_BARE_CLASS,
|
|
11
|
+
EXPLORER_ICON_BUTTON_CLASS,
|
|
12
|
+
EXPLORER_ROW_CLASS,
|
|
13
|
+
EXPLORER_STRIP_TOKEN_CLASS,
|
|
14
|
+
EXPLORER_STRIP_TOKEN_IDLE_CLASS,
|
|
15
|
+
EXPLORER_STRIP_TOKEN_SELECTED_CLASS,
|
|
16
|
+
SECTION_HEAD_CLASS,
|
|
17
|
+
} from "@/components/explorer/explorer-chrome.ts";
|
|
18
|
+
import { Input } from "@/components/ui/input";
|
|
19
|
+
import { SHEET_CONTROL } from "@/components/ui/sheet-form.tsx";
|
|
20
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
21
|
+
import { cn } from "@/lib/utils.ts";
|
|
22
|
+
import {
|
|
23
|
+
ACCESS_RATE_UNIT_LABELS,
|
|
24
|
+
ACCESS_RATE_UNITS,
|
|
25
|
+
classifyAccessAllowEntry,
|
|
26
|
+
parseAccessAllowlist,
|
|
27
|
+
type AccessRateUnit,
|
|
28
|
+
} from "../lib/format-when.ts";
|
|
29
|
+
|
|
30
|
+
/** Props for {@link AccessAllowFields}. */
|
|
31
|
+
export interface AccessAllowFieldsProps {
|
|
32
|
+
readonly entries: readonly string[];
|
|
33
|
+
readonly rateMax: string;
|
|
34
|
+
readonly rateCount: string;
|
|
35
|
+
readonly rateUnit: AccessRateUnit;
|
|
36
|
+
readonly onEntries: (entries: readonly string[]) => void;
|
|
37
|
+
readonly onRateMax: (raw: string) => void;
|
|
38
|
+
readonly onRateCount: (raw: string) => void;
|
|
39
|
+
readonly onRateUnit: (unit: AccessRateUnit) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Add-one allow list and max / window rate.
|
|
44
|
+
*
|
|
45
|
+
* @param props - Entries + rate parts
|
|
46
|
+
*/
|
|
47
|
+
export function AccessAllowFields({
|
|
48
|
+
entries,
|
|
49
|
+
rateMax,
|
|
50
|
+
rateCount,
|
|
51
|
+
rateUnit,
|
|
52
|
+
onEntries,
|
|
53
|
+
onRateMax,
|
|
54
|
+
onRateCount,
|
|
55
|
+
onRateUnit,
|
|
56
|
+
}: AccessAllowFieldsProps): JSX.Element {
|
|
57
|
+
const [draft, setDraft] = useState("");
|
|
58
|
+
const parsed = parseAccessAllowlist(draft);
|
|
59
|
+
const draftInvalid = draft.trim().length > 0 && parsed.length === 0;
|
|
60
|
+
|
|
61
|
+
const addDraft = () => {
|
|
62
|
+
if (parsed.length === 0) return;
|
|
63
|
+
const seen = new Set(entries);
|
|
64
|
+
const merged = [...entries];
|
|
65
|
+
for (const entry of parsed) {
|
|
66
|
+
if (seen.has(entry)) continue;
|
|
67
|
+
seen.add(entry);
|
|
68
|
+
merged.push(entry);
|
|
69
|
+
}
|
|
70
|
+
onEntries(merged);
|
|
71
|
+
setDraft("");
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const onDraftKey = (event: KeyboardEvent<HTMLInputElement>) => {
|
|
75
|
+
if (event.key !== "Enter") return;
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
addDraft();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
<div className="border-b border-border/50" data-slot="access-allow-field">
|
|
83
|
+
<div className="flex items-stretch">
|
|
84
|
+
<p className="flex h-8 w-[5.5rem] shrink-0 items-center px-4 text-[10px] font-semibold tracking-[0.12em] text-muted-foreground uppercase">
|
|
85
|
+
Allow
|
|
86
|
+
</p>
|
|
87
|
+
<Input
|
|
88
|
+
value={draft}
|
|
89
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
90
|
+
onKeyDown={onDraftKey}
|
|
91
|
+
placeholder="203.0.113.4 or ci.example.com"
|
|
92
|
+
autoComplete="off"
|
|
93
|
+
aria-label="Add allow entry"
|
|
94
|
+
aria-invalid={draftInvalid}
|
|
95
|
+
flat
|
|
96
|
+
className={cn(SHEET_CONTROL, "min-w-0 flex-1 font-mono")}
|
|
97
|
+
/>
|
|
98
|
+
{entries.length > 0 ? (
|
|
99
|
+
<span className={cn(EXPLORER_COUNT_CLASS, "flex h-8 items-center pr-2")}>
|
|
100
|
+
{entries.length}
|
|
101
|
+
</span>
|
|
102
|
+
) : null}
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
className={cn(EXPLORER_ICON_BUTTON_CLASS, "h-8")}
|
|
106
|
+
aria-label="Add allow entry"
|
|
107
|
+
disabled={parsed.length === 0}
|
|
108
|
+
onClick={addDraft}
|
|
109
|
+
>
|
|
110
|
+
<HugeiconsIcon icon={PlusSignIcon} className="size-3.5" aria-hidden />
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
{draftInvalid ? (
|
|
114
|
+
<p className="border-t border-border/50 px-4 py-2 text-[11px] text-destructive">
|
|
115
|
+
Need an IP or host — 203.0.113.4, ::1, localhost, or ci.example.com.
|
|
116
|
+
</p>
|
|
117
|
+
) : entries.length === 0 ? (
|
|
118
|
+
<p className="border-t border-border/50 px-4 py-2 text-[11px] text-muted-foreground">
|
|
119
|
+
Any client. Add an IP or host.
|
|
120
|
+
</p>
|
|
121
|
+
) : (
|
|
122
|
+
<div role="list" aria-label="Allow list">
|
|
123
|
+
{entries.map((entry) => {
|
|
124
|
+
const kind = classifyAccessAllowEntry(entry);
|
|
125
|
+
return (
|
|
126
|
+
<div
|
|
127
|
+
key={entry}
|
|
128
|
+
role="listitem"
|
|
129
|
+
data-slot="access-allow-entry"
|
|
130
|
+
data-kind={kind ?? "invalid"}
|
|
131
|
+
className={cn(EXPLORER_ROW_CLASS, "last:border-b-0")}
|
|
132
|
+
>
|
|
133
|
+
<span
|
|
134
|
+
className={cn(
|
|
135
|
+
SECTION_HEAD_CLASS,
|
|
136
|
+
"w-[5.5rem] shrink-0 px-1.5",
|
|
137
|
+
kind == null && "text-destructive",
|
|
138
|
+
)}
|
|
139
|
+
>
|
|
140
|
+
{kind === "ip" ? "IP" : kind === "host" ? "Host" : "Bad"}
|
|
141
|
+
</span>
|
|
142
|
+
<span
|
|
143
|
+
className={cn(
|
|
144
|
+
"min-w-0 truncate font-mono text-[11px]",
|
|
145
|
+
kind == null && "text-destructive",
|
|
146
|
+
)}
|
|
147
|
+
>
|
|
148
|
+
{entry}
|
|
149
|
+
</span>
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
className={cn(
|
|
153
|
+
EXPLORER_ICON_BUTTON_BARE_CLASS,
|
|
154
|
+
"ml-auto opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
|
|
155
|
+
)}
|
|
156
|
+
aria-label={`Remove ${entry}`}
|
|
157
|
+
onClick={() => onEntries(entries.filter((item) => item !== entry))}
|
|
158
|
+
>
|
|
159
|
+
<HugeiconsIcon icon={Cancel01Icon} className="size-3" aria-hidden />
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
})}
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
<div className="flex items-stretch border-b border-border/50" data-slot="access-rate-field">
|
|
168
|
+
<p className="flex h-8 w-[5.5rem] shrink-0 items-center px-4 text-[10px] font-semibold tracking-[0.12em] text-muted-foreground uppercase">
|
|
169
|
+
Rate
|
|
170
|
+
</p>
|
|
171
|
+
<label className="flex min-w-0 flex-1 items-stretch border-r border-border/50">
|
|
172
|
+
<span className="flex h-8 shrink-0 items-center pl-1 text-[10px] font-semibold tracking-[0.08em] text-muted-foreground uppercase">
|
|
173
|
+
Max
|
|
174
|
+
</span>
|
|
175
|
+
<Input
|
|
176
|
+
value={rateMax}
|
|
177
|
+
onChange={(e) => onRateMax(e.target.value)}
|
|
178
|
+
placeholder="none"
|
|
179
|
+
inputMode="numeric"
|
|
180
|
+
autoComplete="off"
|
|
181
|
+
aria-label="Rate max"
|
|
182
|
+
flat
|
|
183
|
+
className={cn(SHEET_CONTROL, "min-w-0 flex-1 px-2 font-mono tabular-nums")}
|
|
184
|
+
/>
|
|
185
|
+
</label>
|
|
186
|
+
<label className="flex min-w-0 flex-1 items-stretch">
|
|
187
|
+
<span className="flex h-8 shrink-0 items-center pl-2 text-[10px] font-semibold tracking-[0.08em] text-muted-foreground uppercase">
|
|
188
|
+
Every
|
|
189
|
+
</span>
|
|
190
|
+
<Input
|
|
191
|
+
value={rateCount}
|
|
192
|
+
onChange={(e) => onRateCount(e.target.value)}
|
|
193
|
+
placeholder="1"
|
|
194
|
+
inputMode="numeric"
|
|
195
|
+
autoComplete="off"
|
|
196
|
+
aria-label="Rate window"
|
|
197
|
+
flat
|
|
198
|
+
className={cn(SHEET_CONTROL, "min-w-0 flex-1 px-2 font-mono tabular-nums")}
|
|
199
|
+
/>
|
|
200
|
+
</label>
|
|
201
|
+
<div className="flex h-8 shrink-0 items-stretch" role="group" aria-label="Rate unit">
|
|
202
|
+
{ACCESS_RATE_UNITS.map((unit) => (
|
|
203
|
+
<Tooltip key={unit}>
|
|
204
|
+
<TooltipTrigger
|
|
205
|
+
render={(props) => (
|
|
206
|
+
<button
|
|
207
|
+
{...props}
|
|
208
|
+
type="button"
|
|
209
|
+
aria-pressed={rateUnit === unit}
|
|
210
|
+
aria-label={ACCESS_RATE_UNIT_LABELS[unit]}
|
|
211
|
+
onClick={(event) => {
|
|
212
|
+
props.onClick?.(event);
|
|
213
|
+
onRateUnit(unit);
|
|
214
|
+
}}
|
|
215
|
+
className={cn(
|
|
216
|
+
EXPLORER_STRIP_TOKEN_CLASS,
|
|
217
|
+
"h-8 px-1.5 font-mono font-semibold",
|
|
218
|
+
rateUnit === unit
|
|
219
|
+
? EXPLORER_STRIP_TOKEN_SELECTED_CLASS
|
|
220
|
+
: EXPLORER_STRIP_TOKEN_IDLE_CLASS,
|
|
221
|
+
)}
|
|
222
|
+
>
|
|
223
|
+
{unit}
|
|
224
|
+
</button>
|
|
225
|
+
)}
|
|
226
|
+
/>
|
|
227
|
+
<TooltipContent side="bottom" className="text-[11px]">
|
|
228
|
+
{ACCESS_RATE_UNIT_LABELS[unit]}
|
|
229
|
+
</TooltipContent>
|
|
230
|
+
</Tooltip>
|
|
231
|
+
))}
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
</>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create an Access API key — issuer + attenuated scopes, secret once.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { CrownIcon, UserIcon } from "@hugeicons/core-free-icons";
|
|
6
|
+
import { useEffect, useMemo, useState, type JSX } from "react";
|
|
7
|
+
import type { AccessCreateKeyInput, AccessUserRow } from "@/client.ts";
|
|
8
|
+
import {
|
|
9
|
+
EXPLORER_RAIL_ACTIVE_CLASS,
|
|
10
|
+
EXPLORER_RAIL_CLASS,
|
|
11
|
+
EXPLORER_ROW_CLASS,
|
|
12
|
+
EXPLORER_ROW_SELECTED_CLASS,
|
|
13
|
+
} from "@/components/explorer/explorer-chrome.ts";
|
|
14
|
+
import { Input } from "@/components/ui/input";
|
|
15
|
+
import {
|
|
16
|
+
SHEET_CONTROL,
|
|
17
|
+
SheetChoice,
|
|
18
|
+
SheetChoiceRow,
|
|
19
|
+
SheetError,
|
|
20
|
+
SheetFooterButton,
|
|
21
|
+
} from "@/components/ui/sheet-form.tsx";
|
|
22
|
+
import {
|
|
23
|
+
Sheet,
|
|
24
|
+
SheetContent,
|
|
25
|
+
SheetDescription,
|
|
26
|
+
SheetFooter,
|
|
27
|
+
SheetHeader,
|
|
28
|
+
SheetTitle,
|
|
29
|
+
} from "@/components/ui/sheet";
|
|
30
|
+
import { cn } from "@/lib/utils.ts";
|
|
31
|
+
import {
|
|
32
|
+
accessExpiresAt,
|
|
33
|
+
parseAccessDurationMs,
|
|
34
|
+
DEFAULT_ACCESS_RATE_UNIT,
|
|
35
|
+
parseAccessRateParts,
|
|
36
|
+
type AccessExpiryChoice,
|
|
37
|
+
type AccessRateUnit,
|
|
38
|
+
} from "../lib/format-when.ts";
|
|
39
|
+
import { AccessAllowFields } from "./access-allow-fields.tsx";
|
|
40
|
+
import { AccessExpiryFields } from "./access-expiry-fields.tsx";
|
|
41
|
+
import { AccessScopeField } from "./access-scope-field.tsx";
|
|
42
|
+
import { AccessSheetSearch } from "./access-sheet-search.tsx";
|
|
43
|
+
|
|
44
|
+
/** Props for {@link AccessCreateSheet}. */
|
|
45
|
+
export interface AccessCreateSheetProps {
|
|
46
|
+
readonly open: boolean;
|
|
47
|
+
readonly users: readonly AccessUserRow[];
|
|
48
|
+
readonly userScopes: readonly string[];
|
|
49
|
+
readonly operatorScopes: readonly string[];
|
|
50
|
+
readonly pending: boolean;
|
|
51
|
+
readonly error: string | null;
|
|
52
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
53
|
+
readonly onSubmit: (input: AccessCreateKeyInput) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create sheet — plane, issuer, name, expiry, allow, rate, scopes.
|
|
58
|
+
*
|
|
59
|
+
* @param props - Open + catalog + submit
|
|
60
|
+
*/
|
|
61
|
+
export function AccessCreateSheet({
|
|
62
|
+
open,
|
|
63
|
+
users,
|
|
64
|
+
userScopes,
|
|
65
|
+
operatorScopes,
|
|
66
|
+
pending,
|
|
67
|
+
error,
|
|
68
|
+
onOpenChange,
|
|
69
|
+
onSubmit,
|
|
70
|
+
}: AccessCreateSheetProps): JSX.Element {
|
|
71
|
+
const [plane, setPlane] = useState<"user" | "operator">("user");
|
|
72
|
+
const [name, setName] = useState("");
|
|
73
|
+
const [creatorUserId, setCreatorUserId] = useState("");
|
|
74
|
+
const [scopes, setScopes] = useState<readonly string[]>([]);
|
|
75
|
+
const [expires, setExpires] = useState<AccessExpiryChoice>("never");
|
|
76
|
+
const [customExpires, setCustomExpires] = useState("");
|
|
77
|
+
const [allowEntries, setAllowEntries] = useState<readonly string[]>([]);
|
|
78
|
+
const [rateMax, setRateMax] = useState("");
|
|
79
|
+
const [rateCount, setRateCount] = useState("");
|
|
80
|
+
const [rateUnit, setRateUnit] = useState<AccessRateUnit>(DEFAULT_ACCESS_RATE_UNIT);
|
|
81
|
+
const [issuerQuery, setIssuerQuery] = useState("");
|
|
82
|
+
|
|
83
|
+
const issuer = users.find((user) => user.id === creatorUserId) ?? null;
|
|
84
|
+
const visibleUsers = useMemo(() => {
|
|
85
|
+
const q = issuerQuery.trim().toLowerCase();
|
|
86
|
+
const matched =
|
|
87
|
+
q.length === 0
|
|
88
|
+
? users
|
|
89
|
+
: users.filter(
|
|
90
|
+
(user) =>
|
|
91
|
+
user.name.toLowerCase().includes(q) ||
|
|
92
|
+
user.id.toLowerCase().includes(q) ||
|
|
93
|
+
user.email.toLowerCase().includes(q),
|
|
94
|
+
);
|
|
95
|
+
if (issuer && !matched.some((user) => user.id === issuer.id)) {
|
|
96
|
+
return [issuer, ...matched];
|
|
97
|
+
}
|
|
98
|
+
return matched;
|
|
99
|
+
}, [users, issuerQuery, issuer]);
|
|
100
|
+
const grantable = useMemo(() => {
|
|
101
|
+
if (plane === "operator") return operatorScopes;
|
|
102
|
+
if (issuer == null) return [];
|
|
103
|
+
if (issuer.scopes.length > 0) return issuer.scopes;
|
|
104
|
+
return userScopes;
|
|
105
|
+
}, [plane, userScopes, operatorScopes, issuer]);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (!open) {
|
|
109
|
+
setPlane("user");
|
|
110
|
+
setName("");
|
|
111
|
+
setCreatorUserId("");
|
|
112
|
+
setScopes([]);
|
|
113
|
+
setExpires("never");
|
|
114
|
+
setCustomExpires("");
|
|
115
|
+
setAllowEntries([]);
|
|
116
|
+
setRateMax("");
|
|
117
|
+
setRateCount("");
|
|
118
|
+
setRateUnit(DEFAULT_ACCESS_RATE_UNIT);
|
|
119
|
+
setIssuerQuery("");
|
|
120
|
+
}
|
|
121
|
+
}, [open]);
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
setScopes((prev) => {
|
|
125
|
+
const kept = prev.filter((scope) => grantable.includes(scope));
|
|
126
|
+
if (kept.length > 0) return kept;
|
|
127
|
+
const first = grantable[0];
|
|
128
|
+
return first ? [first] : [];
|
|
129
|
+
});
|
|
130
|
+
}, [grantable]);
|
|
131
|
+
|
|
132
|
+
const customMs = parseAccessDurationMs(customExpires);
|
|
133
|
+
const parsedRate = parseAccessRateParts(rateMax, rateCount, rateUnit);
|
|
134
|
+
const ready =
|
|
135
|
+
name.trim().length > 0 &&
|
|
136
|
+
scopes.length > 0 &&
|
|
137
|
+
!pending &&
|
|
138
|
+
parsedRate !== undefined &&
|
|
139
|
+
(plane === "operator" || creatorUserId.length > 0) &&
|
|
140
|
+
(expires !== "custom" || customMs > 0);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<Sheet open={open} modal={false} disablePointerDismissal onOpenChange={onOpenChange}>
|
|
144
|
+
<SheetContent
|
|
145
|
+
side="right"
|
|
146
|
+
showOverlay={false}
|
|
147
|
+
className="gap-0 p-0 data-[side=right]:sm:max-w-md"
|
|
148
|
+
data-slot="access-create-sheet"
|
|
149
|
+
>
|
|
150
|
+
<SheetHeader className="gap-1 border-b border-border/50">
|
|
151
|
+
<SheetTitle className="text-sm">Create API key</SheetTitle>
|
|
152
|
+
<SheetDescription className="text-[11px]">
|
|
153
|
+
{plane === "user"
|
|
154
|
+
? "User-plane keys require an issuer. The key cannot exceed that user's grants."
|
|
155
|
+
: "Operator keys inherit the actor ceiling. Absence is impossible — only grantable scopes appear."}
|
|
156
|
+
</SheetDescription>
|
|
157
|
+
</SheetHeader>
|
|
158
|
+
|
|
159
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
|
160
|
+
<div
|
|
161
|
+
className="flex items-stretch border-b border-border/50"
|
|
162
|
+
data-slot="access-name-field"
|
|
163
|
+
>
|
|
164
|
+
<p className="flex h-8 w-[5.5rem] shrink-0 items-center px-4 text-[10px] font-semibold tracking-[0.12em] text-muted-foreground uppercase">
|
|
165
|
+
Name
|
|
166
|
+
</p>
|
|
167
|
+
<Input
|
|
168
|
+
value={name}
|
|
169
|
+
onChange={(e) => setName(e.target.value)}
|
|
170
|
+
placeholder="CI deploy"
|
|
171
|
+
autoComplete="off"
|
|
172
|
+
aria-label="Key name"
|
|
173
|
+
flat
|
|
174
|
+
className={cn(SHEET_CONTROL, "min-w-0 flex-1")}
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<SheetChoiceRow label="Plane">
|
|
179
|
+
<SheetChoice
|
|
180
|
+
active={plane === "user"}
|
|
181
|
+
icon={UserIcon}
|
|
182
|
+
onClick={() => {
|
|
183
|
+
setPlane("user");
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
user
|
|
187
|
+
</SheetChoice>
|
|
188
|
+
<SheetChoice
|
|
189
|
+
active={plane === "operator"}
|
|
190
|
+
icon={CrownIcon}
|
|
191
|
+
onClick={() => {
|
|
192
|
+
setPlane("operator");
|
|
193
|
+
setCreatorUserId("");
|
|
194
|
+
setIssuerQuery("");
|
|
195
|
+
}}
|
|
196
|
+
>
|
|
197
|
+
operator
|
|
198
|
+
</SheetChoice>
|
|
199
|
+
</SheetChoiceRow>
|
|
200
|
+
|
|
201
|
+
<AccessExpiryFields
|
|
202
|
+
expires={expires}
|
|
203
|
+
customExpires={customExpires}
|
|
204
|
+
onExpires={setExpires}
|
|
205
|
+
onCustomExpires={setCustomExpires}
|
|
206
|
+
/>
|
|
207
|
+
<AccessAllowFields
|
|
208
|
+
entries={allowEntries}
|
|
209
|
+
rateMax={rateMax}
|
|
210
|
+
rateCount={rateCount}
|
|
211
|
+
rateUnit={rateUnit}
|
|
212
|
+
onEntries={setAllowEntries}
|
|
213
|
+
onRateMax={setRateMax}
|
|
214
|
+
onRateCount={setRateCount}
|
|
215
|
+
onRateUnit={setRateUnit}
|
|
216
|
+
/>
|
|
217
|
+
|
|
218
|
+
{plane === "user" ? (
|
|
219
|
+
<div className="border-b border-border/50" data-slot="access-issuer-field">
|
|
220
|
+
<div className="flex items-stretch">
|
|
221
|
+
<p className="flex h-8 w-[5.5rem] shrink-0 items-center px-4 text-[10px] font-semibold tracking-[0.12em] text-muted-foreground uppercase">
|
|
222
|
+
Issuer
|
|
223
|
+
</p>
|
|
224
|
+
{users.length > 0 ? (
|
|
225
|
+
<AccessSheetSearch
|
|
226
|
+
value={issuerQuery}
|
|
227
|
+
placeholder="Search users"
|
|
228
|
+
label="Search users"
|
|
229
|
+
onChange={setIssuerQuery}
|
|
230
|
+
/>
|
|
231
|
+
) : null}
|
|
232
|
+
</div>
|
|
233
|
+
{users.length === 0 ? (
|
|
234
|
+
<p className="border-t border-border/50 px-4 py-3 text-[11px] text-muted-foreground">
|
|
235
|
+
No users on this plane.
|
|
236
|
+
</p>
|
|
237
|
+
) : visibleUsers.length === 0 ? (
|
|
238
|
+
<p className="border-t border-border/50 px-4 py-3 text-[11px] text-muted-foreground">
|
|
239
|
+
No users match.
|
|
240
|
+
</p>
|
|
241
|
+
) : (
|
|
242
|
+
<div className="max-h-56 overflow-y-auto">
|
|
243
|
+
{visibleUsers.map((user) => {
|
|
244
|
+
const selected = user.id === creatorUserId;
|
|
245
|
+
return (
|
|
246
|
+
<button
|
|
247
|
+
key={user.id}
|
|
248
|
+
type="button"
|
|
249
|
+
data-slot="access-creator-user"
|
|
250
|
+
aria-pressed={selected}
|
|
251
|
+
className={cn(EXPLORER_ROW_CLASS, selected && EXPLORER_ROW_SELECTED_CLASS)}
|
|
252
|
+
onClick={() => setCreatorUserId(user.id)}
|
|
253
|
+
>
|
|
254
|
+
<span
|
|
255
|
+
className={cn(
|
|
256
|
+
EXPLORER_RAIL_CLASS,
|
|
257
|
+
selected && EXPLORER_RAIL_ACTIVE_CLASS,
|
|
258
|
+
)}
|
|
259
|
+
/>
|
|
260
|
+
<span className="min-w-0 truncate">{user.name}</span>
|
|
261
|
+
<span className="ml-auto shrink-0 font-mono text-[10px] text-muted-foreground">
|
|
262
|
+
{user.id}
|
|
263
|
+
</span>
|
|
264
|
+
</button>
|
|
265
|
+
);
|
|
266
|
+
})}
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
) : null}
|
|
271
|
+
|
|
272
|
+
<AccessScopeField
|
|
273
|
+
scopes={grantable}
|
|
274
|
+
selected={scopes}
|
|
275
|
+
empty={
|
|
276
|
+
plane === "user" && creatorUserId.length === 0
|
|
277
|
+
? "Select an issuer to see grantable scopes."
|
|
278
|
+
: plane === "user"
|
|
279
|
+
? "This user has no grants."
|
|
280
|
+
: "No grantable scopes on this plane."
|
|
281
|
+
}
|
|
282
|
+
onChange={setScopes}
|
|
283
|
+
/>
|
|
284
|
+
|
|
285
|
+
{error ? <SheetError slot="access-create-error">{error}</SheetError> : null}
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
<SheetFooter>
|
|
289
|
+
<SheetFooterButton split onClick={() => onOpenChange(false)} disabled={pending}>
|
|
290
|
+
Cancel
|
|
291
|
+
</SheetFooterButton>
|
|
292
|
+
<SheetFooterButton
|
|
293
|
+
variant="default"
|
|
294
|
+
disabled={!ready}
|
|
295
|
+
data-slot="access-create-submit"
|
|
296
|
+
onClick={() => {
|
|
297
|
+
if (parsedRate === undefined) return;
|
|
298
|
+
const expiresAt = accessExpiresAt(expires, Date.now(), customExpires);
|
|
299
|
+
onSubmit({
|
|
300
|
+
plane,
|
|
301
|
+
name: name.trim(),
|
|
302
|
+
scopes,
|
|
303
|
+
...(plane === "user" ? { creatorUserId } : {}),
|
|
304
|
+
...(expiresAt != null ? { expiresAt } : {}),
|
|
305
|
+
...(parsedRate != null ? { rateLimit: parsedRate } : {}),
|
|
306
|
+
...(allowEntries.length > 0 ? { ipAllowlist: allowEntries } : {}),
|
|
307
|
+
});
|
|
308
|
+
}}
|
|
309
|
+
>
|
|
310
|
+
{pending ? "Creating…" : "Create"}
|
|
311
|
+
</SheetFooterButton>
|
|
312
|
+
</SheetFooter>
|
|
313
|
+
</SheetContent>
|
|
314
|
+
</Sheet>
|
|
315
|
+
);
|
|
316
|
+
}
|