@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.
- package/dist/{ApiExplorer-pYMZflL3.js → ApiExplorer-CCtczj3F.js} +15 -28
- package/dist/ApiExplorer-CCtczj3F.js.map +1 -0
- package/dist/ApiKeysView-B66TdzhH.js +1738 -0
- package/dist/ApiKeysView-B66TdzhH.js.map +1 -0
- package/dist/BackupsView-DmSMzAbT.js +368 -0
- package/dist/BackupsView-DmSMzAbT.js.map +1 -0
- package/dist/{BranchesView-D6Sz1a5f.js → BranchesView-DP1a87E-.js} +12 -8
- package/dist/BranchesView-DP1a87E-.js.map +1 -0
- package/dist/{CronJobsView-7hjgSYId.js → CronJobsView-TDErHTuK.js} +126 -60
- package/dist/CronJobsView-TDErHTuK.js.map +1 -0
- package/dist/{JSEditor-qiuwmcvk.js → JSEditor-B59pwg4c.js} +120 -74
- package/dist/JSEditor-B59pwg4c.js.map +1 -0
- package/dist/{LogsExplorer-Xq8K4oo6.js → LogsExplorer-BKM4yfP8.js} +48 -22
- package/dist/LogsExplorer-BKM4yfP8.js.map +1 -0
- package/dist/{RLSEditor-Djh43lBE.js → RLSEditor-BlLLV6xs.js} +237 -77
- package/dist/RLSEditor-BlLLV6xs.js.map +1 -0
- package/dist/{SQLEditor-TBw_K6CW.js → SQLEditor-1An7w0j6.js} +369 -294
- package/dist/SQLEditor-1An7w0j6.js.map +1 -0
- package/dist/{SchemaVisualizer-DtDzIUNy.js → SchemaVisualizer-DCLVfA5t.js} +20 -24
- package/dist/SchemaVisualizer-DCLVfA5t.js.map +1 -0
- package/dist/{StorageView-CMG1DOVX.js → StorageView-CbsHuCBf.js} +355 -225
- package/dist/StorageView-CbsHuCBf.js.map +1 -0
- package/dist/components/ApiExplorer/TryItPanel.d.ts +6 -1
- package/dist/components/ApiKeys/CreateApiKeyDialog.d.ts +21 -2
- package/dist/components/ApiKeys/scope-words.d.ts +37 -0
- package/dist/components/ApiKeys/scopes.d.ts +113 -0
- package/dist/components/AuthSimulationSelector.d.ts +5 -1
- package/dist/components/LogsExplorer/useLogTail.d.ts +9 -0
- package/dist/components/RLSEditor/policyListing.d.ts +44 -0
- package/dist/components/SchemaVisualizer/schema-visualizer.utils.d.ts +0 -1
- package/dist/components/SchemaVisualizer/useSchemaGraph.d.ts +6 -0
- package/dist/components/load-failure.d.ts +1 -1
- package/dist/components/useRunAsUsers.d.ts +25 -0
- package/dist/index.es.js +11 -11
- package/dist/{load-failure-C6OMJSGC.js → load-failure-Bd8lFIO7.js} +2 -2
- package/dist/load-failure-Bd8lFIO7.js.map +1 -0
- package/dist/useRunAsUsers-CLaAtXt6.js +122 -0
- package/dist/useRunAsUsers-CLaAtXt6.js.map +1 -0
- package/dist/utils/sql_utils.d.ts +90 -49
- package/package.json +9 -9
- package/dist/ApiExplorer-pYMZflL3.js.map +0 -1
- package/dist/ApiKeysView-qLSs47tw.js +0 -1239
- package/dist/ApiKeysView-qLSs47tw.js.map +0 -1
- package/dist/AuthSimulationSelector-BewmhMBw.js +0 -56
- package/dist/AuthSimulationSelector-BewmhMBw.js.map +0 -1
- package/dist/BackupsView-DYTAD6aW.js +0 -253
- package/dist/BackupsView-DYTAD6aW.js.map +0 -1
- package/dist/BranchesView-D6Sz1a5f.js.map +0 -1
- package/dist/CronJobsView-7hjgSYId.js.map +0 -1
- package/dist/JSEditor-qiuwmcvk.js.map +0 -1
- package/dist/LogsExplorer-Xq8K4oo6.js.map +0 -1
- package/dist/RLSEditor-Djh43lBE.js.map +0 -1
- package/dist/SQLEditor-TBw_K6CW.js.map +0 -1
- package/dist/SchemaVisualizer-DtDzIUNy.js.map +0 -1
- package/dist/StorageView-CMG1DOVX.js.map +0 -1
- package/dist/components/ApiKeys/permissions.d.ts +0 -66
- 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
|