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.
Files changed (141) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/elements/flow.mdx +6 -0
  3. package/site/content/docs/elements/gate.mdx +55 -0
  4. package/site/content/docs/elements/store.mdx +13 -15
  5. package/site/content/docs/elements/vault.mdx +3 -0
  6. package/site/content/docs/reference/client.mdx +3 -0
  7. package/site/content/docs/reference/fx.mdx +29 -18
  8. package/site/content/docs/reference/security.md +1 -1
  9. package/src/auth/api-key-sql.test.ts +47 -0
  10. package/src/auth/api-key-sql.ts +254 -0
  11. package/src/auth/api-keys.ts +273 -14
  12. package/src/auth/auth.test.ts +56 -0
  13. package/src/auth/config.ts +5 -5
  14. package/src/auth/index.ts +25 -1
  15. package/src/auth/schema.ts +8 -0
  16. package/src/cli/attach-host-console.test.ts +37 -0
  17. package/src/cli/attach-host-console.ts +6 -0
  18. package/src/compiler/effects-infer.ts +17 -2
  19. package/src/console/server/access.test.ts +116 -8
  20. package/src/console/server/access.ts +70 -23
  21. package/src/console/server/app.ts +1 -0
  22. package/src/console/server/dev-identities.test.ts +127 -0
  23. package/src/console/server/dev-identities.ts +229 -0
  24. package/src/console/server/flows-invoke.test.ts +21 -4
  25. package/src/console/server/flows.ts +113 -58
  26. package/src/console/server/gates.test.ts +24 -0
  27. package/src/console/server/gates.ts +8 -21
  28. package/src/console/server/index.ts +1 -0
  29. package/src/console/server/live.test.ts +26 -1
  30. package/src/console/server/live.ts +4 -2
  31. package/src/console/server/operator-db.ts +2 -0
  32. package/src/console/server/state.ts +29 -55
  33. package/src/console/ui-next/dist/assets/access-page-CAGHrA9H.js +4 -0
  34. package/src/console/ui-next/dist/assets/{agent-disclosure-C0X1fbWF.js → agent-disclosure-BP0Y0Sux.js} +1 -1
  35. package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
  36. package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
  37. package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
  38. package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
  39. package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
  40. package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
  41. package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
  42. package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
  43. package/src/console/ui-next/dist/assets/flows-page-B-OUtiAu.js +1 -0
  44. package/src/console/ui-next/dist/assets/{highlighted-json-CS_O8L-r.js → highlighted-json-Awq7gYdu.js} +1 -1
  45. package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
  46. package/src/console/ui-next/dist/assets/index-CGoZkILK.js +66 -0
  47. package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
  48. package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
  49. package/src/console/ui-next/dist/assets/observability-page-BDyXalNR.js +4 -0
  50. package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
  51. package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
  52. package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
  53. package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
  54. package/src/console/ui-next/dist/assets/skeleton-D-czQJT6.js +1 -0
  55. package/src/console/ui-next/dist/assets/store-page-Xh8Kn3rx.js +41 -0
  56. package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
  57. package/src/console/ui-next/dist/assets/tree-expand-toggle-DkOXA12R.js +54 -0
  58. package/src/console/ui-next/dist/assets/units-page-Dpk40kOQ.js +1 -0
  59. package/src/console/ui-next/dist/assets/{use-vault-list-BLbZhzyF.js → use-vault-list-uk4WVboC.js} +1 -1
  60. package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
  61. package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
  62. package/src/console/ui-next/dist/assets/vault-page-B1dB9Ft0.js +2 -0
  63. package/src/console/ui-next/dist/index.html +11 -9
  64. package/src/console/ui-next/index.html +3 -3
  65. package/src/console/ui-next/src/client.ts +167 -0
  66. package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
  67. package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
  68. package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
  69. package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
  70. package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
  71. package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
  72. package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
  73. package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
  74. package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
  75. package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
  76. package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
  77. package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
  78. package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
  79. package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
  80. package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
  81. package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
  82. package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
  83. package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
  84. package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
  85. package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
  86. package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
  87. package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
  88. package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
  89. package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
  90. package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -1
  91. package/src/console/ui-next/src/lib/document-meta.test.ts +1 -0
  92. package/src/console/ui-next/src/lib/document-meta.ts +2 -1
  93. package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
  94. package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
  95. package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
  96. package/src/console/ui-next/src/lib/shortcut.ts +3 -0
  97. package/src/console/ui-next/src/router.tsx +10 -1
  98. package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
  99. package/src/console/ui-next/ui-next-seed-store.ts +43 -15
  100. package/src/console/xss.gate.test.ts +1 -0
  101. package/src/elements/gate/declare.ts +2 -0
  102. package/src/elements/store/cache.ts +1 -1
  103. package/src/elements/store/runtime.ts +14 -0
  104. package/src/elements/store/schema-decl.ts +99 -41
  105. package/src/elements/store/sql-select.types.test.ts +71 -0
  106. package/src/elements/store/sql-session.ts +54 -32
  107. package/src/elements/store.ts +3 -0
  108. package/src/index.ts +1 -0
  109. package/src/kernel/api-key-host-persist.test.ts +143 -0
  110. package/src/kernel/app.ts +14 -2
  111. package/src/kernel/auth-resolve.ts +10 -5
  112. package/src/kernel/boot.test.ts +4 -4
  113. package/src/kernel/fx-auth-keys.test.ts +88 -0
  114. package/src/kernel/fx-auth-keys.ts +198 -0
  115. package/src/kernel/fx.test.ts +17 -1
  116. package/src/kernel/fx.ts +36 -18
  117. package/src/kernel/pipeline-api-key.test.ts +192 -7
  118. package/src/kernel/pipeline.ts +4 -2
  119. package/src/manifest/types.ts +9 -3
  120. package/src/release/build-lib.ts +1 -0
  121. package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +0 -1
  122. package/src/console/ui-next/dist/assets/call-pii-button-DEDCl_j3.js +0 -1
  123. package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +0 -1
  124. package/src/console/ui-next/dist/assets/confirm-sheet-3ptDzXbz.js +0 -1
  125. package/src/console/ui-next/dist/assets/explorer-empty-BvYrygyO.js +0 -1
  126. package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +0 -1
  127. package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +0 -1
  128. package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +0 -66
  129. package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +0 -2
  130. package/src/console/ui-next/dist/assets/link-COeyggt-.js +0 -1
  131. package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +0 -4
  132. package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +0 -18
  133. package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +0 -1
  134. package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
  135. package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +0 -1
  136. package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +0 -1
  137. package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +0 -41
  138. package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +0 -2
  139. package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +0 -54
  140. package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +0 -1
  141. 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
+ }