@rebasepro/studio 0.23.0 → 0.24.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 (57) hide show
  1. package/dist/{ApiExplorer-pYMZflL3.js → ApiExplorer-CCtczj3F.js} +15 -28
  2. package/dist/ApiExplorer-CCtczj3F.js.map +1 -0
  3. package/dist/ApiKeysView-B66TdzhH.js +1738 -0
  4. package/dist/ApiKeysView-B66TdzhH.js.map +1 -0
  5. package/dist/BackupsView-DmSMzAbT.js +368 -0
  6. package/dist/BackupsView-DmSMzAbT.js.map +1 -0
  7. package/dist/{BranchesView-D6Sz1a5f.js → BranchesView-DP1a87E-.js} +12 -8
  8. package/dist/BranchesView-DP1a87E-.js.map +1 -0
  9. package/dist/{CronJobsView-7hjgSYId.js → CronJobsView-TDErHTuK.js} +126 -60
  10. package/dist/CronJobsView-TDErHTuK.js.map +1 -0
  11. package/dist/{JSEditor-qiuwmcvk.js → JSEditor-B59pwg4c.js} +120 -74
  12. package/dist/JSEditor-B59pwg4c.js.map +1 -0
  13. package/dist/{LogsExplorer-Xq8K4oo6.js → LogsExplorer-BKM4yfP8.js} +48 -22
  14. package/dist/LogsExplorer-BKM4yfP8.js.map +1 -0
  15. package/dist/{RLSEditor-Djh43lBE.js → RLSEditor-BlLLV6xs.js} +237 -77
  16. package/dist/RLSEditor-BlLLV6xs.js.map +1 -0
  17. package/dist/{SQLEditor-TBw_K6CW.js → SQLEditor-1An7w0j6.js} +369 -294
  18. package/dist/SQLEditor-1An7w0j6.js.map +1 -0
  19. package/dist/{SchemaVisualizer-DtDzIUNy.js → SchemaVisualizer-DCLVfA5t.js} +20 -24
  20. package/dist/SchemaVisualizer-DCLVfA5t.js.map +1 -0
  21. package/dist/{StorageView-CMG1DOVX.js → StorageView-CbsHuCBf.js} +355 -225
  22. package/dist/StorageView-CbsHuCBf.js.map +1 -0
  23. package/dist/components/ApiExplorer/TryItPanel.d.ts +6 -1
  24. package/dist/components/ApiKeys/CreateApiKeyDialog.d.ts +21 -2
  25. package/dist/components/ApiKeys/scope-words.d.ts +37 -0
  26. package/dist/components/ApiKeys/scopes.d.ts +113 -0
  27. package/dist/components/AuthSimulationSelector.d.ts +5 -1
  28. package/dist/components/LogsExplorer/useLogTail.d.ts +9 -0
  29. package/dist/components/RLSEditor/policyListing.d.ts +44 -0
  30. package/dist/components/SchemaVisualizer/schema-visualizer.utils.d.ts +0 -1
  31. package/dist/components/SchemaVisualizer/useSchemaGraph.d.ts +6 -0
  32. package/dist/components/load-failure.d.ts +1 -1
  33. package/dist/components/useRunAsUsers.d.ts +25 -0
  34. package/dist/index.es.js +11 -11
  35. package/dist/{load-failure-C6OMJSGC.js → load-failure-Bd8lFIO7.js} +2 -2
  36. package/dist/load-failure-Bd8lFIO7.js.map +1 -0
  37. package/dist/useRunAsUsers-CLaAtXt6.js +122 -0
  38. package/dist/useRunAsUsers-CLaAtXt6.js.map +1 -0
  39. package/dist/utils/sql_utils.d.ts +90 -49
  40. package/package.json +9 -9
  41. package/dist/ApiExplorer-pYMZflL3.js.map +0 -1
  42. package/dist/ApiKeysView-qLSs47tw.js +0 -1239
  43. package/dist/ApiKeysView-qLSs47tw.js.map +0 -1
  44. package/dist/AuthSimulationSelector-BewmhMBw.js +0 -56
  45. package/dist/AuthSimulationSelector-BewmhMBw.js.map +0 -1
  46. package/dist/BackupsView-DYTAD6aW.js +0 -253
  47. package/dist/BackupsView-DYTAD6aW.js.map +0 -1
  48. package/dist/BranchesView-D6Sz1a5f.js.map +0 -1
  49. package/dist/CronJobsView-7hjgSYId.js.map +0 -1
  50. package/dist/JSEditor-qiuwmcvk.js.map +0 -1
  51. package/dist/LogsExplorer-Xq8K4oo6.js.map +0 -1
  52. package/dist/RLSEditor-Djh43lBE.js.map +0 -1
  53. package/dist/SQLEditor-TBw_K6CW.js.map +0 -1
  54. package/dist/SchemaVisualizer-DtDzIUNy.js.map +0 -1
  55. package/dist/StorageView-CMG1DOVX.js.map +0 -1
  56. package/dist/components/ApiKeys/permissions.d.ts +0 -66
  57. package/dist/load-failure-C6OMJSGC.js.map +0 -1
@@ -0,0 +1,1738 @@
1
+ import { t as classifyLoadFailure } from "./load-failure-Bd8lFIO7.js";
2
+ import { t as LoadFailureView } from "./load-failure-view-CBTB_h-h.js";
3
+ import { useApiBase, useApiConfig, useRebaseClient, useSnackbarController, useStorageSources, useStudioCollectionRegistry, useTranslation } from "@rebasepro/app";
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
+ import { Alert, AlertCircleIcon, Button, CheckCircleIcon, Checkbox, Chip, CircularProgress, CopyIcon, DatabaseIcon, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, KeyRoundIcon, Label, MultiSelect, MultiSelectItem, PlusIcon, RefreshCwIcon, Select, SelectItem, ShieldIcon, Tab, Tabs, TagIcon, TextField, Tooltip, Trash2Icon, Typography, WrenchIcon, cls, defaultBorderMixin, iconSize } from "@rebasepro/ui";
6
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
+ import { ADMIN_ROLE, DEFAULT_STORAGE_SOURCE_KEY, KEY_MANAGEMENT_SCOPES, describeScope, hasAdminRole, isAdminScope, isBuiltInScope, isDataPlaneScope, parseScope, scopeGrantsAny, scopeTargets, summarizeScopes } from "@rebasepro/types";
8
+ //#region src/components/ApiKeys/scopes.ts
9
+ /**
10
+ * The pure half of the API-key panel: reading a key's scopes back as groups a
11
+ * person can scan, and turning the create dialog's picker into the scope list
12
+ * the server mints.
13
+ *
14
+ * The vocabulary itself — what a scope is, which ones take a target, which
15
+ * plane each belongs to — is `@rebasepro/types`' `scopes.ts`, the same module
16
+ * the server enforces with. Nothing here re-derives it; this file only shapes
17
+ * it for the screen, so it can be tested without rendering one.
18
+ *
19
+ * @module
20
+ */
21
+ /** The planes in the order the panel shows them. */
22
+ var SCOPE_PLANES = [
23
+ "data",
24
+ "admin",
25
+ "app"
26
+ ];
27
+ function planeOf(scope) {
28
+ if (isDataPlaneScope(scope)) return "data";
29
+ if (isAdminScope(scope)) return "admin";
30
+ return "app";
31
+ }
32
+ /**
33
+ * A key's scopes, grouped by plane and folded per scope: `data:read:posts` and
34
+ * `data:read:authors` read as one "Read data" row narrowed to two collections,
35
+ * and `data:read` beside them as the same row on every collection.
36
+ *
37
+ * Ordered the way the catalogue lists scopes; a scope the catalogue does not
38
+ * know (an app scope removed since the key was minted) keeps its raw name and
39
+ * lands at the end of the plane it would belong to.
40
+ */
41
+ function groupKeyScopes(scopes, catalogue = summarizeScopes()) {
42
+ const bases = [];
43
+ for (const entry of scopes) {
44
+ const base = parseScope(entry)?.scope ?? entry;
45
+ if (!bases.includes(base)) bases.push(base);
46
+ }
47
+ const position = (base) => {
48
+ const index = catalogue.findIndex((summary) => summary.scope === base);
49
+ return index < 0 ? catalogue.length + bases.indexOf(base) : index;
50
+ };
51
+ bases.sort((a, b) => position(a) - position(b));
52
+ const held = bases.map((base) => {
53
+ const summary = catalogue.find((entry) => entry.scope === base);
54
+ const parsed = parseScope(base);
55
+ const fallback = describeScope(base);
56
+ return {
57
+ scope: base,
58
+ label: summary?.label ?? fallback.label,
59
+ description: summary?.description ?? fallback.description,
60
+ plane: summary?.plane ?? planeOf(base),
61
+ ...summary?.target ? { targetKind: summary.target } : {},
62
+ targets: parsed ? scopeTargets(scopes, base) : "all"
63
+ };
64
+ });
65
+ return SCOPE_PLANES.map((plane) => ({
66
+ plane,
67
+ scopes: held.filter((entry) => entry.plane === plane)
68
+ })).filter((group) => group.scopes.length > 0);
69
+ }
70
+ /** Never offered: a key that manages keys could mint its own successor. */
71
+ function isKeyManagementScope(scope) {
72
+ return KEY_MANAGEMENT_SCOPES.includes(scope);
73
+ }
74
+ /**
75
+ * The catalogue entries a caller may put on a key: the ones they hold, on at
76
+ * least one target, minus key management. The server refuses anything else
77
+ * (`SCOPE_EXCEEDS_CREATOR`, `KEY_MANAGEMENT_SCOPE`), so offering it would only
78
+ * be a way to fail.
79
+ */
80
+ function grantableScopes(catalogue, held) {
81
+ return catalogue.filter((summary) => !isKeyManagementScope(summary.scope) && scopeGrantsAny(held, summary.scope));
82
+ }
83
+ /** `data:read` on everything — the narrowest useful key, and what the dialog opens with. */
84
+ function defaultSelection(grantable) {
85
+ return grantable.some((summary) => summary.scope === "data:read") ? { "data:read": { mode: "all" } } : {};
86
+ }
87
+ function cleanTargets(targets) {
88
+ const out = [];
89
+ for (const raw of targets) {
90
+ const target = raw.trim();
91
+ if (target && !/\s/.test(target) && !out.includes(target)) out.push(target);
92
+ }
93
+ return out;
94
+ }
95
+ /**
96
+ * The scope strings a selection mints, in catalogue order when one is given.
97
+ *
98
+ * A scope on every target is sent unqualified; one narrowed to targets is sent
99
+ * once per target. A scope narrowed to no target at all grants nothing and is
100
+ * left out — {@link incompleteScopes} is how the dialog says so.
101
+ */
102
+ function buildScopeList(selection, order = []) {
103
+ const chosen = Object.keys(selection);
104
+ const rank = (scope) => {
105
+ const index = order.indexOf(scope);
106
+ return index < 0 ? order.length + chosen.indexOf(scope) : index;
107
+ };
108
+ const out = [];
109
+ for (const scope of [...chosen].sort((a, b) => rank(a) - rank(b))) {
110
+ const choice = selection[scope];
111
+ if (choice.mode === "all") out.push(scope);
112
+ else for (const target of cleanTargets(choice.targets)) out.push(`${scope}:${target}`);
113
+ }
114
+ return out;
115
+ }
116
+ /** Scopes switched to "only some" with nothing chosen yet. */
117
+ function incompleteScopes(selection) {
118
+ return Object.entries(selection).filter(([, choice]) => choice.mode === "some" && cleanTargets(choice.targets).length === 0).map(([scope]) => scope);
119
+ }
120
+ /** Free text in a target field: commas or whitespace separate entries. */
121
+ function splitList(text) {
122
+ return cleanTargets(text.split(/[\s,]+/));
123
+ }
124
+ /**
125
+ * The roles a service key runs as beside `service`: the picked ones, then any
126
+ * typed ones, deduplicated. `service` itself is every service key's identity
127
+ * already, so listing it adds nothing and is dropped, as the server does.
128
+ */
129
+ function combineRoles(picked, typed) {
130
+ const out = [];
131
+ for (const role of [...picked, ...splitList(typed)]) {
132
+ const id = role.trim();
133
+ if (id && id !== "service" && !out.includes(id)) out.push(id);
134
+ }
135
+ return out;
136
+ }
137
+ var EXPIRY_CHOICES = [
138
+ "never",
139
+ "7d",
140
+ "30d",
141
+ "90d",
142
+ "1y"
143
+ ];
144
+ var EXPIRY_DAYS = {
145
+ "7d": 7,
146
+ "30d": 30,
147
+ "90d": 90,
148
+ "1y": 365
149
+ };
150
+ function isExpiryChoice(value) {
151
+ return EXPIRY_CHOICES.includes(value);
152
+ }
153
+ /** The `expires_at` an expiry choice sends: an ISO instant, or null for never. */
154
+ function expiresAtFor(choice, now = Date.now()) {
155
+ if (choice === "never") return null;
156
+ return new Date(now + EXPIRY_DAYS[choice] * 864e5).toISOString();
157
+ }
158
+ /** The `rate_limit` a rate-limit field sends: a positive integer, or null for the server default. */
159
+ function rateLimitFrom(text) {
160
+ const digits = text.replace(/\D/g, "");
161
+ if (!digits) return null;
162
+ const value = parseInt(digits, 10);
163
+ return value >= 1 ? value : null;
164
+ }
165
+ /** The minting refusals the dialog names, beside the server's own message. */
166
+ var KEY_ERROR_CODES = [
167
+ "INVALID_SCOPES",
168
+ "SCOPE_EXCEEDS_CREATOR",
169
+ "ROLE_EXCEEDS_CREATOR",
170
+ "UNKNOWN_SCOPE_TARGET",
171
+ "KEY_MANAGEMENT_SCOPE"
172
+ ];
173
+ function codeOf(error) {
174
+ if (typeof error !== "object" || error === null || !("code" in error)) return null;
175
+ return typeof error.code === "string" ? error.code : null;
176
+ }
177
+ function isKeyErrorCode(code) {
178
+ return code !== null && KEY_ERROR_CODES.includes(code);
179
+ }
180
+ function readKeyError(error) {
181
+ const code = codeOf(error);
182
+ return {
183
+ code: isKeyErrorCode(code) ? code : null,
184
+ message: error instanceof Error ? error.message : String(error)
185
+ };
186
+ }
187
+ /** The backend answered `403 PERSONAL_KEYS_DISABLED`: the app has not switched them on. */
188
+ function isPersonalKeysDisabled(error) {
189
+ return codeOf(error) === "PERSONAL_KEYS_DISABLED";
190
+ }
191
+ function isStringArray(value) {
192
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
193
+ }
194
+ function isPlane(value) {
195
+ return value === "data" || value === "admin" || value === "app";
196
+ }
197
+ function readScopeSummary(value) {
198
+ if (typeof value !== "object" || value === null) return null;
199
+ if (!("scope" in value) || typeof value.scope !== "string") return null;
200
+ if (!("plane" in value) || !isPlane(value.plane)) return null;
201
+ const label = "label" in value && typeof value.label === "string" ? value.label : value.scope;
202
+ const description = "description" in value && typeof value.description === "string" ? value.description : "";
203
+ const target = "target" in value && typeof value.target === "string" ? value.target : void 0;
204
+ return {
205
+ scope: value.scope,
206
+ label,
207
+ description,
208
+ plane: value.plane,
209
+ ...target ? { target } : {}
210
+ };
211
+ }
212
+ /**
213
+ * Read `GET /auth/scopes`. Null for anything that is not that shape — a backend
214
+ * older than scopes answers this route with something else, and offering a
215
+ * picker built from it would offer scopes the server does not know.
216
+ */
217
+ function readScopeListing(body) {
218
+ if (typeof body !== "object" || body === null) return null;
219
+ if (!("scopes" in body) || !Array.isArray(body.scopes)) return null;
220
+ if (!("held" in body) || !isStringArray(body.held)) return null;
221
+ const scopes = [];
222
+ for (const entry of body.scopes) {
223
+ const summary = readScopeSummary(entry);
224
+ if (summary) scopes.push(summary);
225
+ }
226
+ return {
227
+ scopes,
228
+ held: body.held
229
+ };
230
+ }
231
+ /** Function names out of `GET /functions` — `{ functions: [{ name }] }`. */
232
+ function functionNamesFrom(body) {
233
+ if (typeof body !== "object" || body === null || !("functions" in body) || !Array.isArray(body.functions)) return [];
234
+ const names = [];
235
+ for (const entry of body.functions) if (typeof entry === "object" && entry !== null && "name" in entry && typeof entry.name === "string" && entry.name) names.push(entry.name);
236
+ return names;
237
+ }
238
+ //#endregion
239
+ //#region src/components/ApiKeys/scope-words.ts
240
+ /**
241
+ * How the key panel words scopes: plane headings, "every collection", the
242
+ * title over a minting refusal. One place, so the detail panel, the create
243
+ * dialog and the secret confirmation cannot drift apart.
244
+ *
245
+ * Every string is a translation key. A built-in scope's label and description
246
+ * are keys too, derived from the scope (`data:read` →
247
+ * `studio_scope_data_read_label`); an app's own scopes are worded as the app
248
+ * declared them, in the server's catalogue (`GET /auth/scopes`).
249
+ *
250
+ * @module
251
+ */
252
+ /** `data:read` → `studio_scope_data_read_label`. */
253
+ function scopeLabelKey(scope) {
254
+ return `studio_scope_${scope.replace(/[^a-z0-9]/g, "_")}_label`;
255
+ }
256
+ /** `data:read` → `studio_scope_data_read_description`. */
257
+ function scopeDescriptionKey(scope) {
258
+ return `studio_scope_${scope.replace(/[^a-z0-9]/g, "_")}_description`;
259
+ }
260
+ /** The translation, or `fallback` when the catalogue has none — i18next answers a missing key with the key. */
261
+ function translatedOr(t, key, fallback) {
262
+ const text = t(key);
263
+ return text && text !== key ? text : fallback;
264
+ }
265
+ /**
266
+ * The catalogue in the panel's language. Built-in scopes are translated; an
267
+ * app scope keeps the label its app declared, and a built-in scope this panel
268
+ * has no key for (one the server added since) keeps the server's wording
269
+ * rather than rendering blank or as a key.
270
+ */
271
+ function localizeScopes(t, catalogue) {
272
+ return catalogue.map((summary) => isBuiltInScope(summary.scope) ? {
273
+ ...summary,
274
+ label: translatedOr(t, scopeLabelKey(summary.scope), summary.label),
275
+ description: translatedOr(t, scopeDescriptionKey(summary.scope), summary.description)
276
+ } : summary);
277
+ }
278
+ function planeLabel(t, plane) {
279
+ switch (plane) {
280
+ case "data": return t("studio_api_keys_plane_data");
281
+ case "admin": return t("studio_api_keys_plane_admin");
282
+ case "app": return t("studio_api_keys_plane_app");
283
+ }
284
+ }
285
+ function planeHint(t, plane) {
286
+ switch (plane) {
287
+ case "data": return t("studio_api_keys_plane_data_hint");
288
+ case "admin": return t("studio_api_keys_plane_admin_hint");
289
+ case "app": return t("studio_api_keys_plane_app_hint");
290
+ }
291
+ }
292
+ /** "Every collection", "Every storage source", "Every function", "Any project". */
293
+ function everyTargetLabel(t, targetKind) {
294
+ switch (targetKind) {
295
+ case "collection": return t("studio_api_keys_target_all_collection");
296
+ case "bucket": return t("studio_api_keys_target_all_bucket");
297
+ case "function": return t("studio_api_keys_target_all_function");
298
+ default: return t("studio_api_keys_target_all_other", { kind: targetKind });
299
+ }
300
+ }
301
+ /** One target as a person reads it: the default storage source is "Default bucket", not `(default)`. */
302
+ function targetLabel(t, targetKind, target) {
303
+ if (targetKind === "bucket" && target === DEFAULT_STORAGE_SOURCE_KEY) return t("studio_api_keys_bucket_default");
304
+ return target;
305
+ }
306
+ /** A held scope as one line: "Read data — posts, authors", "Read data — every collection", "Read server logs". */
307
+ function heldScopeLine(t, held) {
308
+ if (!held.targetKind && held.targets === "all") return held.label;
309
+ const reach = held.targets === "all" ? everyTargetLabel(t, held.targetKind ?? "") : held.targets.map((target) => targetLabel(t, held.targetKind, target)).join(", ");
310
+ return `${held.label} — ${reach}`;
311
+ }
312
+ /** The title over a refused create; the server's own message goes underneath. */
313
+ function keyErrorTitle(t, code) {
314
+ switch (code) {
315
+ case "INVALID_SCOPES": return t("studio_api_keys_error_invalid_scopes");
316
+ case "SCOPE_EXCEEDS_CREATOR": return t("studio_api_keys_error_scope_exceeds_creator");
317
+ case "ROLE_EXCEEDS_CREATOR": return t("studio_api_keys_error_role_exceeds_creator");
318
+ case "UNKNOWN_SCOPE_TARGET": return t("studio_api_keys_error_unknown_scope_target");
319
+ case "KEY_MANAGEMENT_SCOPE": return t("studio_api_keys_error_key_management_scope");
320
+ case null: return t("studio_api_keys_error_generic");
321
+ }
322
+ }
323
+ //#endregion
324
+ //#region src/components/ApiKeys/CreateApiKeyDialog.tsx
325
+ function SectionLabel({ children, hint }) {
326
+ return /* @__PURE__ */ jsxs("div", {
327
+ className: "flex items-baseline gap-2 mb-2",
328
+ children: [/* @__PURE__ */ jsx(Typography, {
329
+ variant: "label",
330
+ className: "text-2xs uppercase tracking-wider font-semibold text-surface-600 dark:text-surface-300",
331
+ gutterBottom: false,
332
+ children
333
+ }), hint && /* @__PURE__ */ jsx(Typography, {
334
+ variant: "caption",
335
+ color: "secondary",
336
+ className: "text-2xs",
337
+ gutterBottom: false,
338
+ children: hint
339
+ })]
340
+ });
341
+ }
342
+ function PlaneIcon({ plane, className }) {
343
+ return /* @__PURE__ */ jsx(plane === "data" ? DatabaseIcon : plane === "admin" ? WrenchIcon : TagIcon, {
344
+ size: iconSize.smallest,
345
+ className
346
+ });
347
+ }
348
+ function ScopeRow({ summary, choice, reach, options, draft, onToggle, onChoice, onDraft, first, t }) {
349
+ const id = `api-key-scope-${summary.scope.replace(/[^a-z0-9-]/gi, "-")}`;
350
+ const targetKind = summary.target;
351
+ const pickable = reach === "all" ? options : reach.map((value) => ({
352
+ value,
353
+ label: value
354
+ }));
355
+ return /* @__PURE__ */ jsxs("div", {
356
+ className: cls("flex flex-col gap-2 px-3 py-2.5", !first && cls("border-t", defaultBorderMixin)),
357
+ children: [/* @__PURE__ */ jsxs("div", {
358
+ className: "flex items-start gap-3",
359
+ children: [
360
+ /* @__PURE__ */ jsx("div", {
361
+ className: "-my-1.5 -ml-2 shrink-0",
362
+ children: /* @__PURE__ */ jsx(Checkbox, {
363
+ id,
364
+ size: "small",
365
+ checked: !!choice,
366
+ onCheckedChange: onToggle
367
+ })
368
+ }),
369
+ /* @__PURE__ */ jsxs(Label, {
370
+ htmlFor: id,
371
+ className: "flex-1 min-w-0 cursor-pointer",
372
+ children: [/* @__PURE__ */ jsx(Typography, {
373
+ variant: "body2",
374
+ component: "span",
375
+ gutterBottom: false,
376
+ className: "block text-[13px] font-medium",
377
+ children: summary.label
378
+ }), summary.description && /* @__PURE__ */ jsx(Typography, {
379
+ variant: "caption",
380
+ component: "span",
381
+ color: "secondary",
382
+ gutterBottom: false,
383
+ className: "block text-2xs leading-snug font-normal",
384
+ children: summary.description
385
+ })]
386
+ }),
387
+ /* @__PURE__ */ jsx(Typography, {
388
+ variant: "caption",
389
+ color: "disabled",
390
+ gutterBottom: false,
391
+ className: "font-mono text-2xs shrink-0 pt-0.5",
392
+ children: summary.scope
393
+ })
394
+ ]
395
+ }), choice && targetKind && /* @__PURE__ */ jsxs("div", {
396
+ className: "flex flex-wrap items-center gap-2 pl-9",
397
+ children: [reach === "all" && /* @__PURE__ */ jsx("div", {
398
+ className: "w-52 shrink-0",
399
+ children: /* @__PURE__ */ jsxs(Select, {
400
+ size: "small",
401
+ fullWidth: true,
402
+ position: "popper",
403
+ value: choice.mode,
404
+ "aria-label": t("studio_api_keys_target_mode"),
405
+ onValueChange: (mode) => onChoice(mode === "all" ? { mode: "all" } : {
406
+ mode: "some",
407
+ targets: choice.mode === "some" ? choice.targets : []
408
+ }),
409
+ renderValue: (mode) => mode === "all" ? everyTargetLabel(t, targetKind) : t("studio_api_keys_target_some"),
410
+ children: [/* @__PURE__ */ jsx(SelectItem, {
411
+ value: "all",
412
+ children: everyTargetLabel(t, targetKind)
413
+ }), /* @__PURE__ */ jsx(SelectItem, {
414
+ value: "some",
415
+ children: t("studio_api_keys_target_some")
416
+ })]
417
+ })
418
+ }), choice.mode === "some" && (pickable && pickable.length > 0 ? /* @__PURE__ */ jsx("div", {
419
+ className: "flex-1 min-w-[12rem]",
420
+ children: /* @__PURE__ */ jsx(MultiSelect, {
421
+ size: "small",
422
+ className: "w-full",
423
+ value: choice.targets,
424
+ placeholder: t("studio_api_keys_target_pick"),
425
+ onValueChange: (targets) => onChoice({
426
+ mode: "some",
427
+ targets
428
+ }),
429
+ children: pickable.map((option) => /* @__PURE__ */ jsx(MultiSelectItem, {
430
+ value: option.value,
431
+ children: option.label
432
+ }, option.value))
433
+ })
434
+ }) : /* @__PURE__ */ jsx(TextField, {
435
+ size: "small",
436
+ className: "flex-1 min-w-[12rem]",
437
+ "aria-label": t("studio_api_keys_target_typed_placeholder", { kind: targetKind }),
438
+ placeholder: t("studio_api_keys_target_typed_placeholder", { kind: targetKind }),
439
+ value: draft ?? (choice.mode === "some" ? choice.targets.join(", ") : ""),
440
+ onChange: (e) => {
441
+ onDraft(e.target.value);
442
+ onChoice({
443
+ mode: "some",
444
+ targets: splitList(e.target.value)
445
+ });
446
+ }
447
+ }))]
448
+ })]
449
+ });
450
+ }
451
+ function CreateApiKeyDialog({ kind, scopeListing, onRetryScopes, onClose, onCreated }) {
452
+ const client = useRebaseClient();
453
+ const { t } = useTranslation();
454
+ const collectionRegistry = useStudioCollectionRegistry();
455
+ const storageSources = useStorageSources();
456
+ const apiConfig = useApiConfig();
457
+ const apiBase = useApiBase();
458
+ const listing = scopeListing.status === "ready" ? scopeListing.listing : null;
459
+ /** Built-in scopes in the panel's language; app scopes as the app declared them. */
460
+ const catalogue = useMemo(() => listing ? localizeScopes(t, listing.scopes) : [], [listing, t]);
461
+ const grantable = useMemo(() => listing ? grantableScopes(catalogue, listing.held) : [], [listing, catalogue]);
462
+ const order = useMemo(() => catalogue.map((summary) => summary.scope), [catalogue]);
463
+ const [name, setName] = useState("");
464
+ /** Null until the person changes it: the default follows the catalogue once it arrives. */
465
+ const [chosen, setChosen] = useState(null);
466
+ const [drafts, setDrafts] = useState({});
467
+ const [pickedRoles, setPickedRoles] = useState([]);
468
+ const [typedRoles, setTypedRoles] = useState("");
469
+ const [roleOptions, setRoleOptions] = useState([]);
470
+ const [rolesUnavailable, setRolesUnavailable] = useState(false);
471
+ const [rateLimit, setRateLimit] = useState("");
472
+ const [expiry, setExpiry] = useState("never");
473
+ const [creating, setCreating] = useState(false);
474
+ const [error, setError] = useState(null);
475
+ const selection = chosen ?? defaultSelection(grantable);
476
+ const collections = useMemo(() => (collectionRegistry?.collections ?? []).map((col) => col.slug).filter((slug) => !!slug).sort((a, b) => a.localeCompare(b)), [collectionRegistry?.collections]);
477
+ const buckets = useMemo(() => {
478
+ const keys = Object.keys(storageSources.sources);
479
+ if (!keys.includes(DEFAULT_STORAGE_SOURCE_KEY)) keys.unshift(DEFAULT_STORAGE_SOURCE_KEY);
480
+ return keys.map((key) => ({
481
+ value: key,
482
+ label: storageSources.registry[key]?.label ?? (key === DEFAULT_STORAGE_SOURCE_KEY ? t("studio_api_keys_bucket_default") : key)
483
+ }));
484
+ }, [
485
+ storageSources.sources,
486
+ storageSources.registry,
487
+ t
488
+ ]);
489
+ /**
490
+ * Served functions, so a key can be narrowed to one by name. Best-effort:
491
+ * a backend that serves none, or refuses the index, leaves the free-text
492
+ * field — and the server refuses a name it does not serve.
493
+ */
494
+ const [functionNames, setFunctionNames] = useState([]);
495
+ useEffect(() => {
496
+ if (!apiBase) return;
497
+ let cancelled = false;
498
+ (async () => {
499
+ try {
500
+ const token = await apiConfig?.getAuthToken?.();
501
+ const res = await fetch(`${apiBase}/functions`, { headers: token ? { Authorization: `Bearer ${token}` } : void 0 });
502
+ if (!res.ok) return;
503
+ const names = functionNamesFrom(await res.json());
504
+ if (!cancelled) setFunctionNames(names);
505
+ } catch {}
506
+ })();
507
+ return () => {
508
+ cancelled = true;
509
+ };
510
+ }, [apiBase, apiConfig]);
511
+ const optionsFor = (summary) => {
512
+ switch (summary.target) {
513
+ case "collection": return collections.map((slug) => ({
514
+ value: slug,
515
+ label: slug
516
+ }));
517
+ case "bucket": return buckets;
518
+ case "function": return functionNames.map((fn) => ({
519
+ value: fn,
520
+ label: `${fn}()`
521
+ }));
522
+ default: return null;
523
+ }
524
+ };
525
+ useEffect(() => {
526
+ if (kind !== "service") return;
527
+ const admin = client?.admin;
528
+ if (!admin) {
529
+ setRolesUnavailable(true);
530
+ return;
531
+ }
532
+ let cancelled = false;
533
+ admin.listRoles().then((res) => {
534
+ if (!cancelled) setRoleOptions(res.roles);
535
+ }).catch(() => {
536
+ if (!cancelled) setRolesUnavailable(true);
537
+ });
538
+ return () => {
539
+ cancelled = true;
540
+ };
541
+ }, [client, kind]);
542
+ const toggle = (summary, on) => {
543
+ setChosen((current) => {
544
+ const next = { ...current ?? defaultSelection(grantable) };
545
+ if (!on) {
546
+ delete next[summary.scope];
547
+ return next;
548
+ }
549
+ const reach = listing ? scopeTargets(listing.held, summary.scope) : "all";
550
+ next[summary.scope] = reach === "all" ? { mode: "all" } : {
551
+ mode: "some",
552
+ targets: []
553
+ };
554
+ return next;
555
+ });
556
+ };
557
+ const setChoice = (scope, choice) => {
558
+ setChosen((current) => ({
559
+ ...current ?? defaultSelection(grantable),
560
+ [scope]: choice
561
+ }));
562
+ };
563
+ const scopes = buildScopeList(selection, order);
564
+ const incomplete = incompleteScopes(selection);
565
+ const roles = kind === "service" ? combineRoles(pickedRoles, typedRoles) : [];
566
+ const runsAsAdmin = hasAdminRole(roles);
567
+ const granted = groupKeyScopes(scopes, catalogue);
568
+ const labelOf = (scope) => catalogue.find((summary) => summary.scope === scope)?.label ?? scope;
569
+ const canSubmit = !!listing && !!name.trim() && scopes.length > 0 && incomplete.length === 0 && !creating;
570
+ const submitBlockedReason = !name.trim() ? t("studio_api_keys_block_name") : scopes.length === 0 ? t("studio_api_keys_block_scopes") : incomplete.length > 0 ? t("studio_api_keys_block_targets") : "";
571
+ const handleCreate = async () => {
572
+ if (!canSubmit || !client) return;
573
+ setCreating(true);
574
+ setError(null);
575
+ try {
576
+ const expires_at = expiresAtFor(expiry);
577
+ const created = kind === "service" ? await client.apiKeys?.createKey({
578
+ name: name.trim(),
579
+ scopes,
580
+ roles,
581
+ rate_limit: rateLimitFrom(rateLimit),
582
+ expires_at
583
+ }) : await client.personalKeys?.createKey({
584
+ name: name.trim(),
585
+ scopes,
586
+ expires_at
587
+ });
588
+ if (!created) {
589
+ setError({
590
+ code: null,
591
+ message: t("studio_api_keys_unsupported")
592
+ });
593
+ return;
594
+ }
595
+ onCreated(created.key);
596
+ } catch (e) {
597
+ setError(readKeyError(e));
598
+ } finally {
599
+ setCreating(false);
600
+ }
601
+ };
602
+ const planes = SCOPE_PLANES.map((plane) => ({
603
+ plane,
604
+ scopes: grantable.filter((summary) => summary.plane === plane)
605
+ })).filter((group) => group.scopes.length > 0);
606
+ return /* @__PURE__ */ jsxs(Dialog, {
607
+ open: true,
608
+ onOpenChange: (open) => {
609
+ if (!open && !creating) onClose();
610
+ },
611
+ maxWidth: "2xl",
612
+ children: [
613
+ /* @__PURE__ */ jsx(DialogTitle, {
614
+ variant: "subtitle1",
615
+ gutterBottom: false,
616
+ className: "font-semibold",
617
+ children: kind === "service" ? t("studio_api_keys_create_title") : t("studio_api_keys_create_personal_title")
618
+ }),
619
+ /* @__PURE__ */ jsxs(DialogContent, {
620
+ includeMargin: false,
621
+ className: "px-8 pt-2 pb-4 flex flex-col gap-6",
622
+ children: [
623
+ /* @__PURE__ */ jsx(Typography, {
624
+ variant: "body2",
625
+ color: "secondary",
626
+ gutterBottom: false,
627
+ className: "text-[13px] max-w-[62ch]",
628
+ children: kind === "service" ? t("studio_api_keys_create_intro") : t("studio_api_keys_create_personal_intro")
629
+ }),
630
+ /* @__PURE__ */ jsx(TextField, {
631
+ label: t("studio_api_keys_field_name"),
632
+ value: name,
633
+ onChange: (e) => setName(e.target.value),
634
+ placeholder: t("studio_api_keys_field_name_placeholder"),
635
+ size: "small",
636
+ autoFocus: true
637
+ }),
638
+ /* @__PURE__ */ jsxs("div", { children: [
639
+ /* @__PURE__ */ jsx(SectionLabel, {
640
+ hint: t("studio_api_keys_section_scopes_hint"),
641
+ children: t("studio_api_keys_scopes")
642
+ }),
643
+ scopeListing.status === "loading" && /* @__PURE__ */ jsx("div", {
644
+ className: "flex justify-center py-6",
645
+ children: /* @__PURE__ */ jsx(CircularProgress, { size: "small" })
646
+ }),
647
+ scopeListing.status === "failed" && /* @__PURE__ */ jsxs(Alert, {
648
+ color: "error",
649
+ size: "small",
650
+ action: /* @__PURE__ */ jsx(Button, {
651
+ size: "small",
652
+ variant: "text",
653
+ onClick: onRetryScopes,
654
+ children: t("retry")
655
+ }),
656
+ children: [/* @__PURE__ */ jsx(Typography, {
657
+ variant: "body2",
658
+ gutterBottom: false,
659
+ className: "font-medium",
660
+ children: t("studio_api_keys_scopes_read_failed")
661
+ }), scopeListing.message && /* @__PURE__ */ jsx(Typography, {
662
+ variant: "caption",
663
+ gutterBottom: false,
664
+ className: "block font-mono break-all",
665
+ children: scopeListing.message
666
+ })]
667
+ }),
668
+ listing && /* @__PURE__ */ jsxs("div", {
669
+ className: "flex flex-col gap-4",
670
+ children: [planes.map(({ plane, scopes: offered }) => /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("div", {
671
+ className: "flex items-center gap-2 mb-1.5",
672
+ children: [
673
+ /* @__PURE__ */ jsx(PlaneIcon, {
674
+ plane,
675
+ className: "text-surface-500 dark:text-surface-400"
676
+ }),
677
+ /* @__PURE__ */ jsx(Typography, {
678
+ variant: "body2",
679
+ gutterBottom: false,
680
+ className: "text-[13px] font-medium",
681
+ children: planeLabel(t, plane)
682
+ }),
683
+ /* @__PURE__ */ jsx(Typography, {
684
+ variant: "caption",
685
+ color: "secondary",
686
+ gutterBottom: false,
687
+ className: "text-2xs truncate",
688
+ children: planeHint(t, plane)
689
+ })
690
+ ]
691
+ }), /* @__PURE__ */ jsx("div", {
692
+ className: cls("rounded-lg border overflow-hidden", defaultBorderMixin),
693
+ children: offered.map((summary, index) => /* @__PURE__ */ jsx(ScopeRow, {
694
+ summary,
695
+ choice: selection[summary.scope],
696
+ reach: scopeTargets(listing.held, summary.scope),
697
+ options: optionsFor(summary),
698
+ draft: drafts[summary.scope],
699
+ onToggle: (on) => toggle(summary, on),
700
+ onChoice: (choice) => setChoice(summary.scope, choice),
701
+ onDraft: (text) => setDrafts((current) => ({
702
+ ...current,
703
+ [summary.scope]: text
704
+ })),
705
+ first: index === 0,
706
+ t
707
+ }, summary.scope))
708
+ })] }, plane)), /* @__PURE__ */ jsx(Typography, {
709
+ variant: "caption",
710
+ color: "secondary",
711
+ gutterBottom: false,
712
+ className: "text-2xs leading-snug max-w-[70ch]",
713
+ children: t("studio_api_keys_scopes_offered_note")
714
+ })]
715
+ })
716
+ ] }),
717
+ kind === "service" && /* @__PURE__ */ jsxs("div", { children: [
718
+ /* @__PURE__ */ jsx(SectionLabel, {
719
+ hint: t("studio_api_keys_roles_rows_hint"),
720
+ children: t("studio_api_keys_roles")
721
+ }),
722
+ /* @__PURE__ */ jsx(Typography, {
723
+ variant: "caption",
724
+ color: "secondary",
725
+ gutterBottom: false,
726
+ className: "block mb-3 text-2xs leading-snug max-w-[70ch]",
727
+ children: t("studio_api_keys_roles_explainer")
728
+ }),
729
+ /* @__PURE__ */ jsxs("div", {
730
+ className: "grid grid-cols-1 sm:grid-cols-2 gap-3 items-start",
731
+ children: [roleOptions.length > 0 && /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(MultiSelect, {
732
+ size: "small",
733
+ className: "w-full",
734
+ label: t("roles"),
735
+ value: pickedRoles,
736
+ onValueChange: setPickedRoles,
737
+ children: roleOptions.map((role) => /* @__PURE__ */ jsx(MultiSelectItem, {
738
+ value: role.id,
739
+ children: role.name === role.id ? role.id : `${role.name} (${role.id})`
740
+ }, role.id))
741
+ }) }), /* @__PURE__ */ jsx(TextField, {
742
+ size: "small",
743
+ label: t("studio_api_keys_roles_other"),
744
+ placeholder: t("studio_api_keys_roles_other_placeholder"),
745
+ value: typedRoles,
746
+ onChange: (e) => setTypedRoles(e.target.value)
747
+ })]
748
+ }),
749
+ rolesUnavailable && /* @__PURE__ */ jsx(Typography, {
750
+ variant: "caption",
751
+ color: "secondary",
752
+ gutterBottom: false,
753
+ className: "block mt-1.5 text-2xs",
754
+ children: t("studio_api_keys_roles_unavailable")
755
+ }),
756
+ runsAsAdmin && /* @__PURE__ */ jsx(Alert, {
757
+ color: "warning",
758
+ size: "small",
759
+ outerClassName: "mt-3",
760
+ children: t("studio_api_keys_roles_admin_warning")
761
+ })
762
+ ] }),
763
+ /* @__PURE__ */ jsxs("div", { children: [
764
+ /* @__PURE__ */ jsx(SectionLabel, { children: t("studio_api_keys_section_limits") }),
765
+ /* @__PURE__ */ jsxs("div", {
766
+ className: "grid grid-cols-1 sm:grid-cols-2 gap-3 items-end",
767
+ children: [/* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Select, {
768
+ label: t("studio_api_keys_stat_expires"),
769
+ value: expiry,
770
+ onValueChange: (value) => {
771
+ if (isExpiryChoice(value)) setExpiry(value);
772
+ },
773
+ size: "small",
774
+ fullWidth: true,
775
+ position: "popper",
776
+ renderValue: (value) => expiryLabel(t, isExpiryChoice(value) ? value : "never"),
777
+ children: EXPIRY_CHOICES.map((choice) => /* @__PURE__ */ jsx(SelectItem, {
778
+ value: choice,
779
+ children: expiryLabel(t, choice)
780
+ }, choice))
781
+ }) }), kind === "service" && /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx(Label, {
782
+ htmlFor: "api-key-rate-limit",
783
+ className: "block ml-3.5 mb-1 text-text-secondary dark:text-text-secondary-dark",
784
+ children: t("studio_api_keys_stat_rate_limit")
785
+ }), /* @__PURE__ */ jsx(TextField, {
786
+ id: "api-key-rate-limit",
787
+ value: rateLimit,
788
+ onChange: (e) => setRateLimit(e.target.value.replace(/\D/g, "")),
789
+ placeholder: "1000",
790
+ size: "small",
791
+ endAdornment: /* @__PURE__ */ jsx(Typography, {
792
+ variant: "caption",
793
+ color: "secondary",
794
+ component: "span",
795
+ gutterBottom: false,
796
+ className: "text-2xs whitespace-nowrap",
797
+ children: t("studio_api_keys_rate_limit_unit")
798
+ })
799
+ })] })]
800
+ }),
801
+ kind === "service" && /* @__PURE__ */ jsxs(Typography, {
802
+ variant: "caption",
803
+ color: "secondary",
804
+ gutterBottom: false,
805
+ className: "block mt-1.5 text-2xs",
806
+ children: [
807
+ t("studio_api_keys_rate_limit_hint"),
808
+ " ",
809
+ t("studio_api_keys_rate_limit_scope")
810
+ ]
811
+ })
812
+ ] }),
813
+ listing && /* @__PURE__ */ jsxs("div", {
814
+ className: cls("rounded-lg border px-3 py-2.5 bg-surface-field", defaultBorderMixin),
815
+ children: [
816
+ /* @__PURE__ */ jsx(Typography, {
817
+ variant: "label",
818
+ gutterBottom: false,
819
+ className: "text-2xs uppercase tracking-wider font-semibold text-surface-600 dark:text-surface-300",
820
+ children: t("studio_api_keys_summary_title")
821
+ }),
822
+ granted.length === 0 ? /* @__PURE__ */ jsx(Typography, {
823
+ variant: "body2",
824
+ color: "secondary",
825
+ gutterBottom: false,
826
+ className: "mt-1.5 text-[13px]",
827
+ children: t("studio_api_keys_summary_empty")
828
+ }) : /* @__PURE__ */ jsxs("div", {
829
+ className: "mt-1.5 flex flex-col gap-1",
830
+ children: [granted.flatMap((group) => group.scopes.map((held) => /* @__PURE__ */ jsxs("div", {
831
+ className: "flex items-start gap-2",
832
+ children: [/* @__PURE__ */ jsx(PlaneIcon, {
833
+ plane: group.plane,
834
+ className: "mt-[3px] shrink-0 text-surface-500 dark:text-surface-400"
835
+ }), /* @__PURE__ */ jsx(Typography, {
836
+ variant: "body2",
837
+ gutterBottom: false,
838
+ className: "text-[13px] leading-snug",
839
+ children: heldScopeLine(t, held)
840
+ })]
841
+ }, held.scope))), kind === "service" && roles.length > 0 && /* @__PURE__ */ jsxs("div", {
842
+ className: "flex items-start gap-2",
843
+ children: [/* @__PURE__ */ jsx(ShieldIcon, {
844
+ size: iconSize.smallest,
845
+ className: cls("mt-[3px] shrink-0", runsAsAdmin ? "text-amber-600 dark:text-amber-400" : "text-surface-500 dark:text-surface-400")
846
+ }), /* @__PURE__ */ jsx(Typography, {
847
+ variant: "body2",
848
+ gutterBottom: false,
849
+ className: "text-[13px] leading-snug",
850
+ children: t("studio_api_keys_summary_roles", { roles: ["service", ...roles].join(", ") })
851
+ })]
852
+ })]
853
+ }),
854
+ incomplete.length > 0 && /* @__PURE__ */ jsx(Typography, {
855
+ variant: "caption",
856
+ color: "secondary",
857
+ gutterBottom: false,
858
+ className: "block mt-2 text-2xs",
859
+ children: incomplete.map((scope) => t("studio_api_keys_target_missing", { scope: labelOf(scope) })).join(" ")
860
+ })
861
+ ]
862
+ }),
863
+ error && /* @__PURE__ */ jsxs(Alert, {
864
+ color: "error",
865
+ size: "small",
866
+ children: [/* @__PURE__ */ jsx(Typography, {
867
+ variant: "body2",
868
+ gutterBottom: false,
869
+ className: "font-medium",
870
+ children: keyErrorTitle(t, error.code)
871
+ }), /* @__PURE__ */ jsx(Typography, {
872
+ variant: "caption",
873
+ gutterBottom: false,
874
+ className: "block leading-snug",
875
+ children: error.message
876
+ })]
877
+ })
878
+ ]
879
+ }),
880
+ /* @__PURE__ */ jsxs(DialogActions, { children: [/* @__PURE__ */ jsx(Button, {
881
+ variant: "text",
882
+ onClick: onClose,
883
+ disabled: creating,
884
+ children: t("cancel")
885
+ }), /* @__PURE__ */ jsx(Tooltip, {
886
+ title: submitBlockedReason,
887
+ children: /* @__PURE__ */ jsx("span", { children: /* @__PURE__ */ jsx(Button, {
888
+ color: "primary",
889
+ onClick: handleCreate,
890
+ disabled: !canSubmit,
891
+ startIcon: creating ? /* @__PURE__ */ jsx(CircularProgress, { size: "smallest" }) : void 0,
892
+ children: creating ? t("studio_api_keys_creating") : t("studio_api_keys_create")
893
+ }) })
894
+ })] })
895
+ ]
896
+ });
897
+ }
898
+ function expiryLabel(t, choice) {
899
+ switch (choice) {
900
+ case "never": return t("studio_api_keys_never");
901
+ case "7d": return t("studio_api_keys_expiry_7d");
902
+ case "30d": return t("studio_api_keys_expiry_30d");
903
+ case "90d": return t("studio_api_keys_expiry_90d");
904
+ case "1y": return t("studio_api_keys_expiry_1y");
905
+ }
906
+ }
907
+ //#endregion
908
+ //#region src/components/ApiKeys/ApiKeysView.tsx
909
+ /** Within a day, relative and in the panel's language; beyond it, the date. */
910
+ function formatRelative(iso, language) {
911
+ if (!iso) return "—";
912
+ const date = new Date(iso);
913
+ const diff = date.getTime() - Date.now();
914
+ const abs = Math.abs(diff);
915
+ try {
916
+ if (abs >= 864e5) return date.toLocaleDateString(language);
917
+ const format = new Intl.RelativeTimeFormat(language, {
918
+ numeric: "auto",
919
+ style: "short"
920
+ });
921
+ if (abs < 36e5) return format.format(Math.round(diff / 6e4), "minute");
922
+ return format.format(Math.round(diff / 36e5), "hour");
923
+ } catch {
924
+ return date.toLocaleDateString();
925
+ }
926
+ }
927
+ function isExpired(key) {
928
+ return !!(key.expires_at && new Date(key.expires_at) < /* @__PURE__ */ new Date());
929
+ }
930
+ function keyStatus(key) {
931
+ if (key.revoked_at) return {
932
+ kind: "revoked",
933
+ color: "text-red-500"
934
+ };
935
+ if (isExpired(key)) return {
936
+ kind: "expired",
937
+ color: "text-amber-500"
938
+ };
939
+ return {
940
+ kind: "active",
941
+ color: "text-emerald-500"
942
+ };
943
+ }
944
+ function statusLabel(t, kind) {
945
+ switch (kind) {
946
+ case "active": return t("studio_api_keys_status_active");
947
+ case "expired": return t("studio_api_keys_status_expired");
948
+ case "revoked": return t("studio_api_keys_status_revoked");
949
+ }
950
+ }
951
+ /**
952
+ * A service key that runs as `admin`: row-level security does not limit its
953
+ * reads, writes or deletes, and it passes `admin` checks in functions and
954
+ * storage. A personal key has no roles of its own.
955
+ */
956
+ function runsAsAdmin(key) {
957
+ return key.kind === "service" && hasAdminRole(key.roles);
958
+ }
959
+ /** One short phrase for a list row: the one scope a key holds, or how many. */
960
+ function scopeBrief(t, groups) {
961
+ const held = groups.flatMap((group) => group.scopes);
962
+ if (held.length === 0) return "—";
963
+ if (held.length === 1) return held[0].label;
964
+ return t("studio_api_keys_scopes_count", { count: held.length });
965
+ }
966
+ /**
967
+ * The scope catalogue — every scope this backend knows, worded, and what the
968
+ * signed-in account holds. The create dialog offers from it; the detail panel
969
+ * labels a key's scopes with it, and falls back to the built-in wording while
970
+ * it is loading or when this backend does not serve it.
971
+ */
972
+ function useScopeListing(client) {
973
+ const [state, setState] = useState({ status: "loading" });
974
+ const [attempt, setAttempt] = useState(0);
975
+ useEffect(() => {
976
+ const api = client?.personalKeys;
977
+ if (!api) {
978
+ setState({
979
+ status: "failed",
980
+ message: ""
981
+ });
982
+ return;
983
+ }
984
+ let cancelled = false;
985
+ setState({ status: "loading" });
986
+ (async () => {
987
+ try {
988
+ const listing = readScopeListing(await api.listScopes());
989
+ if (!cancelled) setState(listing ? {
990
+ status: "ready",
991
+ listing
992
+ } : {
993
+ status: "failed",
994
+ message: ""
995
+ });
996
+ } catch (e) {
997
+ if (!cancelled) setState({
998
+ status: "failed",
999
+ message: e instanceof Error ? e.message : String(e)
1000
+ });
1001
+ }
1002
+ })();
1003
+ return () => {
1004
+ cancelled = true;
1005
+ };
1006
+ }, [client, attempt]);
1007
+ return [state, useCallback(() => setAttempt((n) => n + 1), [])];
1008
+ }
1009
+ function ApiKeysView() {
1010
+ const client = useRebaseClient();
1011
+ const { t } = useTranslation();
1012
+ const [kind, setKind] = useState("service");
1013
+ const [scopeListing, retryScopes] = useScopeListing(client);
1014
+ return /* @__PURE__ */ jsx(KeysPane, {
1015
+ kind,
1016
+ tabs: /* @__PURE__ */ jsxs(Tabs, {
1017
+ value: kind,
1018
+ onValueChange: (value) => setKind(value === "personal" ? "personal" : "service"),
1019
+ variant: "boxy",
1020
+ className: "border-b border-hairline",
1021
+ children: [/* @__PURE__ */ jsx(Tab, {
1022
+ value: "service",
1023
+ children: t("studio_api_keys_tab_service")
1024
+ }), /* @__PURE__ */ jsx(Tab, {
1025
+ value: "personal",
1026
+ children: t("studio_api_keys_tab_personal")
1027
+ })]
1028
+ }),
1029
+ scopeListing,
1030
+ onRetryScopes: retryScopes
1031
+ }, kind);
1032
+ }
1033
+ function KeysPane({ kind, tabs, scopeListing, onRetryScopes }) {
1034
+ const client = useRebaseClient();
1035
+ const snackbar = useSnackbarController();
1036
+ const { t } = useTranslation();
1037
+ const [keys, setKeys] = useState([]);
1038
+ const [loading, setLoading] = useState(true);
1039
+ const [selectedId, setSelectedId] = useState(null);
1040
+ const [showCreate, setShowCreate] = useState(false);
1041
+ const [showSecret, setShowSecret] = useState(null);
1042
+ const [revoking, setRevoking] = useState(null);
1043
+ const [confirmRevoke, setConfirmRevoke] = useState(null);
1044
+ /** Why the key listing failed, classified — see `load-failure.ts`. */
1045
+ const [failure, setFailure] = useState(null);
1046
+ /** The backend answered PERSONAL_KEYS_DISABLED: a setting, not a failure. */
1047
+ const [personalOff, setPersonalOff] = useState(false);
1048
+ const catalogue = useMemo(() => localizeScopes(t, scopeListing.status === "ready" ? scopeListing.listing.scopes : summarizeScopes()), [t, scopeListing]);
1049
+ const clientRef = useRef(client);
1050
+ clientRef.current = client;
1051
+ const snackbarRef = useRef(snackbar);
1052
+ snackbarRef.current = snackbar;
1053
+ const keysApi = useCallback(() => {
1054
+ const c = clientRef.current;
1055
+ return kind === "service" ? c?.apiKeys : c?.personalKeys;
1056
+ }, [kind]);
1057
+ const loadKeys = useCallback(async () => {
1058
+ const api = keysApi();
1059
+ if (!api) {
1060
+ setLoading(false);
1061
+ return;
1062
+ }
1063
+ try {
1064
+ const res = await api.listKeys();
1065
+ setKeys(res.keys);
1066
+ setFailure(null);
1067
+ setPersonalOff(false);
1068
+ } catch (e) {
1069
+ if (kind === "personal" && isPersonalKeysDisabled(e)) {
1070
+ setPersonalOff(true);
1071
+ setFailure(null);
1072
+ } else setFailure(classifyLoadFailure(e));
1073
+ } finally {
1074
+ setLoading(false);
1075
+ }
1076
+ }, [keysApi, kind]);
1077
+ useEffect(() => {
1078
+ loadKeys();
1079
+ }, [loadKeys]);
1080
+ const handleRevoke = async (id) => {
1081
+ const api = keysApi();
1082
+ if (!api) return;
1083
+ setRevoking(id);
1084
+ try {
1085
+ await api.revokeKey(id);
1086
+ snackbarRef.current.open({
1087
+ type: "success",
1088
+ message: t("studio_api_keys_revoked")
1089
+ });
1090
+ await loadKeys();
1091
+ if (selectedId === id) setSelectedId(null);
1092
+ } catch (e) {
1093
+ snackbarRef.current.open({
1094
+ type: "error",
1095
+ message: e instanceof Error ? e.message : String(e)
1096
+ });
1097
+ } finally {
1098
+ setRevoking(null);
1099
+ }
1100
+ };
1101
+ const handleCreated = (keyWithSecret) => {
1102
+ setShowCreate(false);
1103
+ setShowSecret(keyWithSecret);
1104
+ loadKeys();
1105
+ };
1106
+ const selectedKey = keys.find((k) => k.id === selectedId);
1107
+ const activeKeys = keys.filter((k) => !k.revoked_at && !isExpired(k));
1108
+ const inactiveKeys = keys.filter((k) => k.revoked_at || isExpired(k));
1109
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1110
+ /* @__PURE__ */ jsxs("div", {
1111
+ className: "flex h-full w-full overflow-hidden bg-surface-card",
1112
+ children: [/* @__PURE__ */ jsxs("div", {
1113
+ className: cls("flex flex-col w-[340px] min-w-[280px] border-r h-full", defaultBorderMixin),
1114
+ children: [
1115
+ tabs,
1116
+ /* @__PURE__ */ jsxs("div", {
1117
+ className: cls("flex items-center justify-between px-4 py-2.5 border-b bg-surface-sheet min-h-[48px]", defaultBorderMixin),
1118
+ children: [/* @__PURE__ */ jsxs("div", {
1119
+ className: "flex items-center gap-2",
1120
+ children: [
1121
+ /* @__PURE__ */ jsx(KeyRoundIcon, {
1122
+ size: iconSize.smallest,
1123
+ className: "text-primary"
1124
+ }),
1125
+ /* @__PURE__ */ jsx(Typography, {
1126
+ variant: "subtitle2",
1127
+ className: "font-semibold",
1128
+ children: t("studio_tool_api_keys")
1129
+ }),
1130
+ /* @__PURE__ */ jsx(Chip, {
1131
+ size: "smallest",
1132
+ className: "bg-surface-raised text-surface-600 dark:text-surface-300",
1133
+ children: activeKeys.length
1134
+ })
1135
+ ]
1136
+ }), /* @__PURE__ */ jsxs("div", {
1137
+ className: "flex items-center gap-1",
1138
+ children: [/* @__PURE__ */ jsx(IconButton, {
1139
+ size: "small",
1140
+ onClick: loadKeys,
1141
+ title: t("studio_api_keys_refresh"),
1142
+ children: /* @__PURE__ */ jsx(RefreshCwIcon, { size: iconSize.smallest })
1143
+ }), /* @__PURE__ */ jsx(Button, {
1144
+ size: "small",
1145
+ color: "primary",
1146
+ disabled: personalOff,
1147
+ onClick: () => setShowCreate(true),
1148
+ startIcon: /* @__PURE__ */ jsx(PlusIcon, { size: iconSize.smallest }),
1149
+ children: t("studio_api_keys_new")
1150
+ })]
1151
+ })]
1152
+ }),
1153
+ /* @__PURE__ */ jsxs("div", {
1154
+ className: "flex-1 overflow-y-auto p-2 space-y-1",
1155
+ children: [
1156
+ loading && /* @__PURE__ */ jsx("div", {
1157
+ className: "flex items-center justify-center h-full",
1158
+ children: /* @__PURE__ */ jsx(CircularProgress, {})
1159
+ }),
1160
+ !loading && failure && /* @__PURE__ */ jsx(LoadFailureView, {
1161
+ failure,
1162
+ title: kind === "service" ? t("studio_api_keys_read_failed") : t("studio_api_keys_personal_read_failed"),
1163
+ deniedTitle: kind === "service" ? t("studio_api_keys_denied_title") : t("studio_api_keys_personal_denied_title"),
1164
+ deniedHint: kind === "service" ? t("studio_api_keys_denied_hint") : t("studio_api_keys_personal_denied_hint"),
1165
+ onRetry: loadKeys
1166
+ }),
1167
+ !loading && personalOff && /* @__PURE__ */ jsx(PersonalKeysOff, {}),
1168
+ !loading && !failure && !personalOff && activeKeys.length === 0 && inactiveKeys.length === 0 && /* @__PURE__ */ jsxs("div", {
1169
+ className: "flex flex-col items-center justify-center h-full gap-3 text-center p-6",
1170
+ children: [
1171
+ /* @__PURE__ */ jsx(KeyRoundIcon, {
1172
+ size: iconSize.medium,
1173
+ className: "text-surface-300 dark:text-surface-600"
1174
+ }),
1175
+ /* @__PURE__ */ jsx(Typography, {
1176
+ variant: "body2",
1177
+ color: "secondary",
1178
+ children: kind === "service" ? t("studio_api_keys_empty_title") : t("studio_api_keys_personal_empty_title")
1179
+ }),
1180
+ /* @__PURE__ */ jsx(Typography, {
1181
+ variant: "caption",
1182
+ color: "disabled",
1183
+ children: kind === "service" ? t("studio_api_keys_empty_hint") : t("studio_api_keys_personal_empty_hint")
1184
+ })
1185
+ ]
1186
+ }),
1187
+ !loading && activeKeys.map((key) => /* @__PURE__ */ jsx(KeyListItem, {
1188
+ apiKey: key,
1189
+ catalogue,
1190
+ selected: selectedId === key.id,
1191
+ onClick: () => setSelectedId(key.id)
1192
+ }, key.id)),
1193
+ !loading && inactiveKeys.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
1194
+ className: "px-2 pt-3 pb-1",
1195
+ children: /* @__PURE__ */ jsx(Typography, {
1196
+ variant: "caption",
1197
+ color: "disabled",
1198
+ className: "text-[10px] uppercase tracking-wider font-medium",
1199
+ children: t("studio_api_keys_inactive_heading")
1200
+ })
1201
+ }), inactiveKeys.map((key) => /* @__PURE__ */ jsx(KeyListItem, {
1202
+ apiKey: key,
1203
+ catalogue,
1204
+ selected: selectedId === key.id,
1205
+ onClick: () => setSelectedId(key.id)
1206
+ }, key.id))] })
1207
+ ]
1208
+ })
1209
+ ]
1210
+ }), /* @__PURE__ */ jsx("div", {
1211
+ className: "flex-1 flex flex-col min-w-0 h-full overflow-hidden",
1212
+ children: !selectedKey ? /* @__PURE__ */ jsx("div", {
1213
+ className: "flex items-center justify-center h-full",
1214
+ children: !personalOff && /* @__PURE__ */ jsx(Typography, {
1215
+ variant: "body2",
1216
+ color: "disabled",
1217
+ children: t("studio_api_keys_select_hint")
1218
+ })
1219
+ }) : /* @__PURE__ */ jsx(KeyDetail, {
1220
+ apiKey: selectedKey,
1221
+ catalogue,
1222
+ revoking: revoking === selectedKey.id,
1223
+ onRevoke: () => setConfirmRevoke(selectedKey)
1224
+ })
1225
+ })]
1226
+ }),
1227
+ /* @__PURE__ */ jsxs(Dialog, {
1228
+ open: confirmRevoke !== null,
1229
+ onOpenChange: (open) => {
1230
+ if (!open && !revoking) setConfirmRevoke(null);
1231
+ },
1232
+ children: [
1233
+ /* @__PURE__ */ jsx(DialogTitle, {
1234
+ hidden: true,
1235
+ children: t("studio_api_keys_revoke_confirmation")
1236
+ }),
1237
+ /* @__PURE__ */ jsxs(DialogContent, { children: [/* @__PURE__ */ jsx(Typography, {
1238
+ variant: "subtitle1",
1239
+ className: "font-semibold mb-2",
1240
+ children: t("studio_api_keys_revoke_title", { name: confirmRevoke?.name ?? "" })
1241
+ }), /* @__PURE__ */ jsx(Typography, {
1242
+ variant: "body2",
1243
+ color: "secondary",
1244
+ children: t("studio_api_keys_revoke_body")
1245
+ })] }),
1246
+ /* @__PURE__ */ jsxs(DialogActions, { children: [/* @__PURE__ */ jsx(Button, {
1247
+ variant: "text",
1248
+ onClick: () => setConfirmRevoke(null),
1249
+ disabled: revoking !== null,
1250
+ children: t("cancel")
1251
+ }), /* @__PURE__ */ jsx(Button, {
1252
+ color: "error",
1253
+ disabled: revoking !== null,
1254
+ startIcon: revoking !== null ? /* @__PURE__ */ jsx(CircularProgress, { size: "smallest" }) : /* @__PURE__ */ jsx(Trash2Icon, { size: iconSize.smallest }),
1255
+ onClick: async () => {
1256
+ if (!confirmRevoke) return;
1257
+ await handleRevoke(confirmRevoke.id);
1258
+ setConfirmRevoke(null);
1259
+ },
1260
+ children: t("studio_api_keys_revoke")
1261
+ })] })
1262
+ ]
1263
+ }),
1264
+ showCreate && /* @__PURE__ */ jsx(CreateApiKeyDialog, {
1265
+ kind,
1266
+ scopeListing,
1267
+ onRetryScopes,
1268
+ onClose: () => setShowCreate(false),
1269
+ onCreated: handleCreated
1270
+ }),
1271
+ showSecret && /* @__PURE__ */ jsx(SecretDisplayDialog, {
1272
+ keyWithSecret: showSecret,
1273
+ catalogue,
1274
+ onClose: () => setShowSecret(null)
1275
+ })
1276
+ ] });
1277
+ }
1278
+ /**
1279
+ * Not an error and not a refusal of this person: the app has not enabled
1280
+ * personal keys. Says what they are and where the switch is.
1281
+ */
1282
+ function PersonalKeysOff() {
1283
+ const { t } = useTranslation();
1284
+ return /* @__PURE__ */ jsxs("div", {
1285
+ className: "flex flex-col items-center justify-center h-full gap-3 text-center p-6",
1286
+ children: [
1287
+ /* @__PURE__ */ jsx(KeyRoundIcon, {
1288
+ size: iconSize.medium,
1289
+ className: "text-surface-300 dark:text-surface-600"
1290
+ }),
1291
+ /* @__PURE__ */ jsx(Typography, {
1292
+ variant: "body2",
1293
+ color: "secondary",
1294
+ children: t("studio_api_keys_personal_off_title")
1295
+ }),
1296
+ /* @__PURE__ */ jsx(Typography, {
1297
+ variant: "caption",
1298
+ color: "disabled",
1299
+ className: "max-w-[34ch]",
1300
+ children: t("studio_api_keys_personal_off_body")
1301
+ }),
1302
+ /* @__PURE__ */ jsx(Typography, {
1303
+ variant: "mono",
1304
+ component: "div",
1305
+ className: "text-[11px] px-2 py-1 rounded-md bg-surface-field",
1306
+ children: "auth: { personalKeys: true }"
1307
+ })
1308
+ ]
1309
+ });
1310
+ }
1311
+ /**
1312
+ * Marks a service key that runs as the admin role.
1313
+ *
1314
+ * Not cosmetic: such a key reads, changes and deletes every row its scopes
1315
+ * reach through the admin policies, and without this it is indistinguishable
1316
+ * in the list from a narrow one.
1317
+ */
1318
+ function AdminChip() {
1319
+ const { t } = useTranslation();
1320
+ return /* @__PURE__ */ jsx(Tooltip, {
1321
+ title: t("studio_api_keys_admin_chip_tooltip"),
1322
+ children: /* @__PURE__ */ jsx(Chip, {
1323
+ size: "smallest",
1324
+ colorScheme: "yellow",
1325
+ className: "shrink-0",
1326
+ icon: /* @__PURE__ */ jsx(ShieldIcon, { size: 10 }),
1327
+ children: ADMIN_ROLE
1328
+ })
1329
+ });
1330
+ }
1331
+ function KeyListItem({ apiKey, catalogue, selected, onClick }) {
1332
+ const { t } = useTranslation();
1333
+ const status = keyStatus(apiKey);
1334
+ return /* @__PURE__ */ jsxs("div", {
1335
+ onClick,
1336
+ className: cls("flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-all", selected ? "bg-primary/10 dark:bg-primary/15 ring-1 ring-primary/30" : "hover:bg-surface-hover"),
1337
+ children: [
1338
+ /* @__PURE__ */ jsx("div", { className: cls("w-2 h-2 rounded-full shrink-0", status.kind === "active" ? "bg-emerald-400" : status.kind === "expired" ? "bg-amber-400" : "bg-red-400") }),
1339
+ /* @__PURE__ */ jsxs("div", {
1340
+ className: "flex-1 min-w-0",
1341
+ children: [/* @__PURE__ */ jsxs("div", {
1342
+ className: "flex items-center gap-1.5 min-w-0",
1343
+ children: [/* @__PURE__ */ jsx(Typography, {
1344
+ variant: "body2",
1345
+ className: "truncate font-medium text-[13px]",
1346
+ children: apiKey.name
1347
+ }), runsAsAdmin(apiKey) && /* @__PURE__ */ jsx(AdminChip, {})]
1348
+ }), /* @__PURE__ */ jsxs(Typography, {
1349
+ variant: "caption",
1350
+ color: "secondary",
1351
+ className: "truncate text-[11px] font-mono",
1352
+ children: [apiKey.key_prefix, "•••"]
1353
+ })]
1354
+ }),
1355
+ /* @__PURE__ */ jsx("div", {
1356
+ className: "shrink-0",
1357
+ children: /* @__PURE__ */ jsx(Typography, {
1358
+ variant: "caption",
1359
+ color: "disabled",
1360
+ className: "text-[10px]",
1361
+ children: scopeBrief(t, groupKeyScopes(apiKey.scopes, catalogue))
1362
+ })
1363
+ })
1364
+ ]
1365
+ });
1366
+ }
1367
+ function KeyDetail({ apiKey, catalogue, revoking, onRevoke }) {
1368
+ const { t, i18n } = useTranslation();
1369
+ const language = i18n.language;
1370
+ const status = keyStatus(apiKey);
1371
+ const groups = groupKeyScopes(apiKey.scopes, catalogue);
1372
+ const service = apiKey.kind === "service";
1373
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1374
+ /* @__PURE__ */ jsxs("div", {
1375
+ className: cls("flex items-center justify-between px-5 py-3 border-b bg-surface-card min-h-[56px]", defaultBorderMixin),
1376
+ children: [/* @__PURE__ */ jsxs("div", {
1377
+ className: "flex items-center gap-3 min-w-0",
1378
+ children: [/* @__PURE__ */ jsx(KeyRoundIcon, {
1379
+ size: iconSize.small,
1380
+ className: "text-primary shrink-0"
1381
+ }), /* @__PURE__ */ jsxs("div", {
1382
+ className: "min-w-0",
1383
+ children: [/* @__PURE__ */ jsxs("div", {
1384
+ className: "flex items-center gap-2 min-w-0",
1385
+ children: [/* @__PURE__ */ jsx(Typography, {
1386
+ variant: "subtitle1",
1387
+ className: "font-semibold truncate",
1388
+ children: apiKey.name
1389
+ }), runsAsAdmin(apiKey) && /* @__PURE__ */ jsx(AdminChip, {})]
1390
+ }), /* @__PURE__ */ jsxs(Typography, {
1391
+ variant: "caption",
1392
+ color: "secondary",
1393
+ className: "font-mono text-[11px]",
1394
+ children: [apiKey.key_prefix, "•••"]
1395
+ })]
1396
+ })]
1397
+ }), /* @__PURE__ */ jsx("div", {
1398
+ className: "flex items-center gap-2 shrink-0",
1399
+ children: !apiKey.revoked_at && /* @__PURE__ */ jsx(Button, {
1400
+ size: "small",
1401
+ color: "error",
1402
+ variant: "outlined",
1403
+ onClick: onRevoke,
1404
+ disabled: revoking,
1405
+ startIcon: revoking ? /* @__PURE__ */ jsx(CircularProgress, { size: "smallest" }) : /* @__PURE__ */ jsx(Trash2Icon, { size: iconSize.smallest }),
1406
+ children: t("studio_api_keys_revoke")
1407
+ })
1408
+ })]
1409
+ }),
1410
+ /* @__PURE__ */ jsxs("div", {
1411
+ className: "px-5 py-4 bg-surface-sheet",
1412
+ children: [
1413
+ /* @__PURE__ */ jsxs("div", {
1414
+ className: "grid grid-cols-2 md:grid-cols-4 gap-3",
1415
+ children: [
1416
+ /* @__PURE__ */ jsx(StatCard, {
1417
+ label: t("studio_api_keys_stat_status"),
1418
+ value: statusLabel(t, status.kind),
1419
+ className: status.color
1420
+ }),
1421
+ /* @__PURE__ */ jsx(StatCard, {
1422
+ label: t("created"),
1423
+ value: formatRelative(apiKey.created_at, language)
1424
+ }),
1425
+ /* @__PURE__ */ jsx(StatCard, {
1426
+ label: t("studio_api_keys_stat_last_used"),
1427
+ value: formatRelative(apiKey.last_used_at, language)
1428
+ }),
1429
+ /* @__PURE__ */ jsx(StatCard, {
1430
+ label: t("studio_api_keys_stat_expires"),
1431
+ value: apiKey.expires_at ? formatRelative(apiKey.expires_at, language) : t("studio_api_keys_never")
1432
+ })
1433
+ ]
1434
+ }),
1435
+ /* @__PURE__ */ jsxs("div", {
1436
+ className: "grid grid-cols-2 md:grid-cols-3 gap-3 mt-3",
1437
+ children: [
1438
+ /* @__PURE__ */ jsx(StatCard, {
1439
+ label: t("studio_api_keys_stat_kind"),
1440
+ value: service ? t("studio_api_keys_kind_service") : t("studio_api_keys_kind_personal")
1441
+ }),
1442
+ /* @__PURE__ */ jsx(StatCard, {
1443
+ label: t("studio_api_keys_stat_rate_limit"),
1444
+ value: apiKey.rate_limit ? t("studio_api_keys_rate_limit_value", { limit: apiKey.rate_limit }) : t("studio_api_keys_rate_limit_default")
1445
+ }),
1446
+ service ? /* @__PURE__ */ jsx(StatCard, {
1447
+ label: t("studio_api_keys_stat_created_by"),
1448
+ value: apiKey.created_by,
1449
+ mono: true
1450
+ }) : /* @__PURE__ */ jsx(StatCard, {
1451
+ label: t("studio_api_keys_stat_owner"),
1452
+ value: apiKey.owner_uid ?? "—",
1453
+ mono: true
1454
+ })
1455
+ ]
1456
+ }),
1457
+ /* @__PURE__ */ jsx(Typography, {
1458
+ variant: "caption",
1459
+ color: "secondary",
1460
+ className: "block mt-2 text-[11px] leading-snug",
1461
+ children: t("studio_api_keys_rate_limit_scope")
1462
+ })
1463
+ ]
1464
+ }),
1465
+ /* @__PURE__ */ jsxs("div", {
1466
+ className: cls("flex items-center gap-2 px-5 py-2 border-y bg-surface-card", defaultBorderMixin),
1467
+ children: [/* @__PURE__ */ jsx(Typography, {
1468
+ variant: "subtitle2",
1469
+ className: "font-semibold text-[13px]",
1470
+ children: t("studio_api_keys_scopes")
1471
+ }), /* @__PURE__ */ jsx(Chip, {
1472
+ size: "smallest",
1473
+ className: "bg-surface-raised text-surface-600 dark:text-surface-300",
1474
+ children: apiKey.scopes.length
1475
+ })]
1476
+ }),
1477
+ /* @__PURE__ */ jsxs("div", {
1478
+ className: "flex-1 overflow-y-auto px-5 py-3 flex flex-col gap-5",
1479
+ children: [/* @__PURE__ */ jsx(RolesBlock, { apiKey }), groups.length === 0 ? /* @__PURE__ */ jsx(Typography, {
1480
+ variant: "body2",
1481
+ color: "disabled",
1482
+ children: t("studio_api_keys_no_scopes")
1483
+ }) : groups.map((group) => /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx(BlockHeading, {
1484
+ icon: /* @__PURE__ */ jsx(PlaneIcon, {
1485
+ plane: group.plane,
1486
+ className: "text-surface-500 dark:text-surface-400"
1487
+ }),
1488
+ label: planeLabel(t, group.plane),
1489
+ hint: planeHint(t, group.plane)
1490
+ }), /* @__PURE__ */ jsx("div", {
1491
+ className: "space-y-2",
1492
+ children: group.scopes.map((held) => /* @__PURE__ */ jsxs("div", {
1493
+ className: cls("flex items-center gap-3 px-3 py-2 rounded-lg border", defaultBorderMixin),
1494
+ children: [/* @__PURE__ */ jsxs("div", {
1495
+ className: "flex-1 min-w-0",
1496
+ children: [/* @__PURE__ */ jsxs("div", {
1497
+ className: "flex items-baseline gap-2 min-w-0",
1498
+ children: [/* @__PURE__ */ jsx(Typography, {
1499
+ variant: "body2",
1500
+ className: "text-[13px] font-medium truncate",
1501
+ children: held.label
1502
+ }), /* @__PURE__ */ jsx(Typography, {
1503
+ variant: "caption",
1504
+ color: "disabled",
1505
+ className: "font-mono text-[11px] shrink-0",
1506
+ children: held.scope
1507
+ })]
1508
+ }), held.description && /* @__PURE__ */ jsx(Typography, {
1509
+ variant: "caption",
1510
+ color: "secondary",
1511
+ className: "block text-[11px] leading-snug",
1512
+ children: held.description
1513
+ })]
1514
+ }), held.targetKind && /* @__PURE__ */ jsx("div", {
1515
+ className: "flex flex-wrap justify-end gap-1 shrink-0 max-w-[50%]",
1516
+ children: held.targets === "all" ? /* @__PURE__ */ jsx(Chip, {
1517
+ size: "smallest",
1518
+ className: "bg-surface-raised text-surface-600 dark:text-surface-300",
1519
+ children: everyTargetLabel(t, held.targetKind)
1520
+ }) : held.targets.map((target) => /* @__PURE__ */ jsx(Chip, {
1521
+ size: "smallest",
1522
+ className: "font-mono bg-surface-raised text-surface-700 dark:text-surface-200",
1523
+ children: targetLabel(t, held.targetKind, target)
1524
+ }, target))
1525
+ })]
1526
+ }, held.scope))
1527
+ })] }, group.plane))]
1528
+ })
1529
+ ] });
1530
+ }
1531
+ function BlockHeading({ icon, label, hint }) {
1532
+ return /* @__PURE__ */ jsxs("div", {
1533
+ className: "flex items-center gap-2 mb-2 min-w-0",
1534
+ children: [
1535
+ icon,
1536
+ /* @__PURE__ */ jsx(Typography, {
1537
+ variant: "body2",
1538
+ className: "text-[13px] font-medium shrink-0",
1539
+ children: label
1540
+ }),
1541
+ /* @__PURE__ */ jsx(Typography, {
1542
+ variant: "caption",
1543
+ color: "secondary",
1544
+ className: "text-[11px] truncate",
1545
+ children: hint
1546
+ })
1547
+ ]
1548
+ });
1549
+ }
1550
+ /**
1551
+ * Who the key runs as — which rows it reads. Scopes, below it, are which
1552
+ * endpoints it may call; the two are separate and both are shown.
1553
+ */
1554
+ function RolesBlock({ apiKey }) {
1555
+ const { t } = useTranslation();
1556
+ const shield = /* @__PURE__ */ jsx(ShieldIcon, {
1557
+ size: iconSize.smallest,
1558
+ className: "text-surface-500 dark:text-surface-400"
1559
+ });
1560
+ if (apiKey.kind === "personal") return /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx(BlockHeading, {
1561
+ icon: shield,
1562
+ label: t("studio_api_keys_roles"),
1563
+ hint: t("studio_api_keys_roles_rows_hint")
1564
+ }), /* @__PURE__ */ jsx(Typography, {
1565
+ variant: "caption",
1566
+ color: "secondary",
1567
+ className: "block text-[12px] leading-snug",
1568
+ children: t("studio_api_keys_personal_roles")
1569
+ })] });
1570
+ return /* @__PURE__ */ jsxs("div", { children: [
1571
+ /* @__PURE__ */ jsx(BlockHeading, {
1572
+ icon: shield,
1573
+ label: t("studio_api_keys_roles"),
1574
+ hint: t("studio_api_keys_roles_rows_hint")
1575
+ }),
1576
+ /* @__PURE__ */ jsxs("div", {
1577
+ className: "flex flex-wrap gap-1.5",
1578
+ children: [/* @__PURE__ */ jsx(Chip, {
1579
+ size: "small",
1580
+ className: "font-mono bg-surface-raised text-surface-600 dark:text-surface-300",
1581
+ children: "service"
1582
+ }), apiKey.roles.map((role) => /* @__PURE__ */ jsx(Chip, {
1583
+ size: "small",
1584
+ colorScheme: role === ADMIN_ROLE ? "yellow" : void 0,
1585
+ className: cls("font-mono", role !== ADMIN_ROLE && "bg-surface-raised text-surface-700 dark:text-surface-200"),
1586
+ children: role
1587
+ }, role))]
1588
+ }),
1589
+ runsAsAdmin(apiKey) ? /* @__PURE__ */ jsx(Alert, {
1590
+ color: "warning",
1591
+ size: "small",
1592
+ outerClassName: "mt-2",
1593
+ children: t("studio_api_keys_admin_bypasses_rls")
1594
+ }) : /* @__PURE__ */ jsx(Typography, {
1595
+ variant: "caption",
1596
+ color: "secondary",
1597
+ className: "block mt-1.5 text-[11px] leading-snug",
1598
+ children: apiKey.roles.length === 0 ? t("studio_api_keys_roles_service_only") : t("studio_api_keys_roles_service_plus")
1599
+ })
1600
+ ] });
1601
+ }
1602
+ function StatCard({ label, value, mono, className }) {
1603
+ return /* @__PURE__ */ jsxs("div", {
1604
+ className: cls("px-3 py-2 rounded-lg border bg-surface-card", defaultBorderMixin),
1605
+ children: [/* @__PURE__ */ jsx(Typography, {
1606
+ variant: "caption",
1607
+ color: "secondary",
1608
+ className: "text-[10px] uppercase tracking-wider font-medium",
1609
+ children: label
1610
+ }), /* @__PURE__ */ jsx(Typography, {
1611
+ variant: "body2",
1612
+ className: cls("mt-0.5 font-semibold text-[13px] truncate", mono && "font-mono", className),
1613
+ children: value
1614
+ })]
1615
+ });
1616
+ }
1617
+ function SecretDisplayDialog({ keyWithSecret, catalogue, onClose }) {
1618
+ const { t } = useTranslation();
1619
+ const snackbar = useSnackbarController();
1620
+ const [copied, setCopied] = useState(false);
1621
+ const granted = groupKeyScopes(keyWithSecret.scopes, catalogue).flatMap((group) => group.scopes);
1622
+ const handleCopy = async () => {
1623
+ try {
1624
+ await navigator.clipboard.writeText(keyWithSecret.key);
1625
+ setCopied(true);
1626
+ snackbar.open({
1627
+ type: "success",
1628
+ message: t("studio_api_keys_copied")
1629
+ });
1630
+ setTimeout(() => setCopied(false), 2e3);
1631
+ } catch {
1632
+ snackbar.open({
1633
+ type: "error",
1634
+ message: t("studio_api_keys_copy_failed")
1635
+ });
1636
+ }
1637
+ };
1638
+ return /* @__PURE__ */ jsxs(Dialog, {
1639
+ open: true,
1640
+ onOpenChange: (open) => {
1641
+ if (!open) onClose();
1642
+ },
1643
+ maxWidth: "md",
1644
+ children: [
1645
+ /* @__PURE__ */ jsx(DialogTitle, { children: /* @__PURE__ */ jsxs("div", {
1646
+ className: "flex items-center gap-2",
1647
+ children: [/* @__PURE__ */ jsx(CheckCircleIcon, {
1648
+ size: iconSize.small,
1649
+ className: "text-emerald-500"
1650
+ }), t("studio_api_keys_created_title")]
1651
+ }) }),
1652
+ /* @__PURE__ */ jsxs(DialogContent, { children: [
1653
+ /* @__PURE__ */ jsxs("div", {
1654
+ className: "p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50 mb-4",
1655
+ children: [/* @__PURE__ */ jsxs("div", {
1656
+ className: "flex items-center gap-2 mb-1",
1657
+ children: [/* @__PURE__ */ jsx(AlertCircleIcon, {
1658
+ size: iconSize.smallest,
1659
+ className: "text-amber-600 dark:text-amber-400"
1660
+ }), /* @__PURE__ */ jsx(Typography, {
1661
+ variant: "caption",
1662
+ className: "font-semibold text-amber-700 dark:text-amber-400",
1663
+ children: t("studio_api_keys_copy_now")
1664
+ })]
1665
+ }), /* @__PURE__ */ jsx(Typography, {
1666
+ variant: "caption",
1667
+ className: "text-amber-600 dark:text-amber-300",
1668
+ children: t("studio_api_keys_copy_now_hint")
1669
+ })]
1670
+ }),
1671
+ /* @__PURE__ */ jsxs("div", {
1672
+ className: cls("flex items-center gap-2 p-3 rounded-lg border bg-surface-sheet", defaultBorderMixin),
1673
+ children: [/* @__PURE__ */ jsx("code", {
1674
+ className: "flex-1 text-[12px] font-mono break-all text-surface-700 dark:text-surface-300 select-all",
1675
+ children: keyWithSecret.key
1676
+ }), /* @__PURE__ */ jsx(Tooltip, {
1677
+ title: copied ? t("copied") : t("copy"),
1678
+ children: /* @__PURE__ */ jsx(IconButton, {
1679
+ "aria-label": copied ? t("copied") : t("copy"),
1680
+ size: "small",
1681
+ onClick: handleCopy,
1682
+ children: copied ? /* @__PURE__ */ jsx(CheckCircleIcon, {
1683
+ size: iconSize.smallest,
1684
+ className: "text-emerald-500"
1685
+ }) : /* @__PURE__ */ jsx(CopyIcon, { size: iconSize.smallest })
1686
+ })
1687
+ })]
1688
+ }),
1689
+ /* @__PURE__ */ jsxs("div", {
1690
+ className: "mt-4 space-y-1",
1691
+ children: [
1692
+ /* @__PURE__ */ jsxs(Typography, {
1693
+ variant: "caption",
1694
+ color: "secondary",
1695
+ className: "block",
1696
+ children: [
1697
+ /* @__PURE__ */ jsx("strong", { children: t("studio_api_keys_name_label") }),
1698
+ " ",
1699
+ keyWithSecret.name
1700
+ ]
1701
+ }),
1702
+ /* @__PURE__ */ jsxs(Typography, {
1703
+ variant: "caption",
1704
+ color: "secondary",
1705
+ className: "block",
1706
+ children: [
1707
+ /* @__PURE__ */ jsx("strong", { children: t("studio_api_keys_access_label") }),
1708
+ " ",
1709
+ granted.length > 0 ? granted.map((held) => heldScopeLine(t, held)).join("; ") : "—"
1710
+ ]
1711
+ }),
1712
+ runsAsAdmin(keyWithSecret) && /* @__PURE__ */ jsxs(Typography, {
1713
+ variant: "caption",
1714
+ className: "flex items-center gap-1.5 text-amber-700 dark:text-amber-300",
1715
+ children: [/* @__PURE__ */ jsx(ShieldIcon, {
1716
+ size: iconSize.smallest,
1717
+ className: "shrink-0"
1718
+ }), /* @__PURE__ */ jsxs("span", { children: [
1719
+ /* @__PURE__ */ jsx("strong", { children: t("studio_api_keys_admin_granted") }),
1720
+ " — ",
1721
+ t("studio_api_keys_admin_granted_hint")
1722
+ ] })]
1723
+ })
1724
+ ]
1725
+ })
1726
+ ] }),
1727
+ /* @__PURE__ */ jsx(DialogActions, { children: /* @__PURE__ */ jsx(Button, {
1728
+ color: "primary",
1729
+ onClick: onClose,
1730
+ children: t("studio_api_keys_done")
1731
+ }) })
1732
+ ]
1733
+ });
1734
+ }
1735
+ //#endregion
1736
+ export { ApiKeysView };
1737
+
1738
+ //# sourceMappingURL=ApiKeysView-B66TdzhH.js.map