@agent-native/toolkit 0.227.0 → 0.228.0-nightly-20261111004142
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/agent-native.eject.json +8 -0
- package/dist/app/agent-page/AgentTabFrame.d.ts +2 -1
- package/dist/app/agent-page/AgentTabFrame.d.ts.map +1 -1
- package/dist/app/agent-page/AgentTabFrame.js +3 -2
- package/dist/app/agent-page/AgentTabFrame.js.map +1 -1
- package/dist/app/chat/AgentPanel.d.ts +15 -15
- package/dist/app/chat/AgentPanel.d.ts.map +1 -1
- package/dist/app/chat/AgentPanel.js.map +1 -1
- package/dist/app/chat/AgentSidebar.d.ts +7 -1
- package/dist/app/chat/AgentSidebar.d.ts.map +1 -1
- package/dist/app/chat/AgentSidebar.js +47 -16
- package/dist/app/chat/AgentSidebar.js.map +1 -1
- package/dist/app/chat/chat/surface-types.d.ts +26 -26
- package/dist/app/chat/chat/surface-types.d.ts.map +1 -1
- package/dist/app/extensions/EmbeddedExtension.d.ts.map +1 -1
- package/dist/app/extensions/EmbeddedExtension.js +6 -4
- package/dist/app/extensions/EmbeddedExtension.js.map +1 -1
- package/dist/app/extensions/ExtensionViewer.d.ts.map +1 -1
- package/dist/app/extensions/ExtensionViewer.js +6 -3
- package/dist/app/extensions/ExtensionViewer.js.map +1 -1
- package/dist/app/extensions/InlineExtensionFrame.d.ts.map +1 -1
- package/dist/app/extensions/InlineExtensionFrame.js +32 -28
- package/dist/app/extensions/InlineExtensionFrame.js.map +1 -1
- package/dist/app/extensions/use-extension-chat-submission.d.ts +14 -0
- package/dist/app/extensions/use-extension-chat-submission.d.ts.map +1 -0
- package/dist/app/extensions/use-extension-chat-submission.js +36 -0
- package/dist/app/extensions/use-extension-chat-submission.js.map +1 -0
- package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ar-SA.js +16 -0
- package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
- package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/de-DE.js +12 -0
- package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
- package/dist/app/i18n/catalogs/en-US.d.ts +12 -0
- package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/en-US.js +12 -0
- package/dist/app/i18n/catalogs/en-US.js.map +1 -1
- package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/es-ES.js +13 -0
- package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
- package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/fr-FR.js +13 -0
- package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
- package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/hi-IN.js +12 -0
- package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
- package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ja-JP.js +12 -0
- package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
- package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ko-KR.js +12 -0
- package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
- package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/pt-BR.js +13 -0
- package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
- package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/zh-CN.js +12 -0
- package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
- package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/zh-TW.js +12 -0
- package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
- package/dist/app/org/OrgSwitcher.d.ts.map +1 -1
- package/dist/app/org/OrgSwitcher.js +3 -2
- package/dist/app/org/OrgSwitcher.js.map +1 -1
- package/dist/app/resources/McpAccessSettings.d.ts +9 -2
- package/dist/app/resources/McpAccessSettings.d.ts.map +1 -1
- package/dist/app/resources/McpAccessSettings.js +264 -86
- package/dist/app/resources/McpAccessSettings.js.map +1 -1
- package/dist/app/settings/api-keys/ApiKeyDialogs.d.ts +6 -0
- package/dist/app/settings/api-keys/ApiKeyDialogs.d.ts.map +1 -1
- package/dist/app/settings/api-keys/ApiKeyDialogs.js +74 -2
- package/dist/app/settings/api-keys/ApiKeyDialogs.js.map +1 -1
- package/dist/app/settings/api-keys/ApiKeysSettingsPage.d.ts.map +1 -1
- package/dist/app/settings/api-keys/ApiKeysSettingsPage.js +23 -9
- package/dist/app/settings/api-keys/ApiKeysSettingsPage.js.map +1 -1
- package/dist/app/settings/api-keys/api-keys-client.d.ts +10 -1
- package/dist/app/settings/api-keys/api-keys-client.d.ts.map +1 -1
- package/dist/app/settings/api-keys/api-keys-client.js +19 -1
- package/dist/app/settings/api-keys/api-keys-client.js.map +1 -1
- package/dist/app/settings/shell/pages/mcp.d.ts +1 -1
- package/dist/app/settings/shell/pages/mcp.d.ts.map +1 -1
- package/dist/app/settings/shell/pages/mcp.js +26 -20
- package/dist/app/settings/shell/pages/mcp.js.map +1 -1
- package/dist/app/sharing/ShareButton.d.ts +2 -0
- package/dist/app/sharing/ShareButton.d.ts.map +1 -1
- package/dist/app/sharing/ShareButton.js +4 -3
- package/dist/app/sharing/ShareButton.js.map +1 -1
- package/dist/composer/TiptapComposer.js +3 -0
- package/dist/composer/TiptapComposer.js.map +1 -1
- package/dist/sharing/ShareControls.d.ts +2 -0
- package/dist/sharing/ShareControls.d.ts.map +1 -1
- package/dist/sharing/ShareControls.js +7 -2
- package/dist/sharing/ShareControls.js.map +1 -1
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/numbered-steps.d.ts +14 -0
- package/dist/ui/numbered-steps.d.ts.map +1 -0
- package/dist/ui/numbered-steps.js +19 -0
- package/dist/ui/numbered-steps.js.map +1 -0
- package/package.json +4 -4
- package/src/app/agent-page/AgentTabFrame.tsx +21 -10
- package/src/app/chat/AgentPanel.header.spec.ts +4 -1
- package/src/app/chat/AgentPanel.tsx +19 -14
- package/src/app/chat/AgentSidebar.spec.tsx +167 -0
- package/src/app/chat/AgentSidebar.tsx +170 -101
- package/src/app/chat/chat/surface-types.ts +35 -31
- package/src/app/extensions/EmbeddedExtension.height.spec.tsx +45 -0
- package/src/app/extensions/EmbeddedExtension.tsx +6 -3
- package/src/app/extensions/ExtensionViewer.spec.tsx +28 -0
- package/src/app/extensions/ExtensionViewer.tsx +6 -2
- package/src/app/extensions/InlineExtensionFrame.spec.tsx +23 -0
- package/src/app/extensions/InlineExtensionFrame.tsx +23 -15
- package/src/app/extensions/use-extension-chat-submission.tsx +101 -0
- package/src/app/i18n/catalogs/ar-SA.ts +17 -0
- package/src/app/i18n/catalogs/de-DE.ts +15 -0
- package/src/app/i18n/catalogs/en-US.ts +13 -0
- package/src/app/i18n/catalogs/es-ES.ts +16 -0
- package/src/app/i18n/catalogs/fr-FR.ts +16 -0
- package/src/app/i18n/catalogs/hi-IN.ts +13 -0
- package/src/app/i18n/catalogs/ja-JP.ts +15 -0
- package/src/app/i18n/catalogs/ko-KR.ts +14 -0
- package/src/app/i18n/catalogs/pt-BR.ts +15 -0
- package/src/app/i18n/catalogs/zh-CN.ts +13 -0
- package/src/app/i18n/catalogs/zh-TW.ts +13 -0
- package/src/app/integrations/IntegrationsPanel.spec.tsx +1 -1
- package/src/app/org/OrgSwitcher.spec.tsx +43 -0
- package/src/app/org/OrgSwitcher.tsx +29 -24
- package/src/app/resources/McpAccessSettings.spec.tsx +354 -278
- package/src/app/resources/McpAccessSettings.tsx +1012 -323
- package/src/app/settings/api-keys/ApiKeyDialogs.tsx +148 -1
- package/src/app/settings/api-keys/ApiKeysSettingsPage.spec.tsx +190 -0
- package/src/app/settings/api-keys/ApiKeysSettingsPage.tsx +40 -1
- package/src/app/settings/api-keys/api-keys-client.spec.ts +49 -0
- package/src/app/settings/api-keys/api-keys-client.ts +32 -0
- package/src/app/settings/shell/pages/app-group-pages.spec.tsx +31 -21
- package/src/app/settings/shell/pages/mcp.tsx +50 -42
- package/src/app/sharing/ShareButton.tsx +10 -3
- package/src/composer/TiptapComposer.tsx +2 -0
- package/src/composer/TiptapComposer.voice-toolbar.spec.tsx +16 -0
- package/src/sharing/ShareControls.tsx +44 -24
- package/src/ui/index.ts +1 -0
- package/src/ui/numbered-steps.tsx +104 -0
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from "@agent-native/toolkit/ui/select";
|
|
38
38
|
import { Skeleton } from "@agent-native/toolkit/ui/skeleton";
|
|
39
39
|
import { Spinner } from "@agent-native/toolkit/ui/spinner";
|
|
40
|
+
import { Textarea } from "@agent-native/toolkit/ui/textarea";
|
|
40
41
|
import {
|
|
41
42
|
IconAlertCircle,
|
|
42
43
|
IconExternalLink,
|
|
@@ -53,7 +54,11 @@ import {
|
|
|
53
54
|
} from "../../integrations/index.js";
|
|
54
55
|
import { effectText } from "../model/RemoveProviderDialog.js";
|
|
55
56
|
import { normalizeKeyName } from "../NewKeyMenu.js";
|
|
56
|
-
import {
|
|
57
|
+
import {
|
|
58
|
+
saveApiKeyValue,
|
|
59
|
+
updateApiKeyOrigins,
|
|
60
|
+
notifyKeysChanged,
|
|
61
|
+
} from "./api-keys-client.js";
|
|
57
62
|
import {
|
|
58
63
|
addableSuggestions,
|
|
59
64
|
addKeyTarget,
|
|
@@ -64,6 +69,26 @@ import {
|
|
|
64
69
|
const K = "agentChat.settingsApiKeys.";
|
|
65
70
|
const M = "agentChat.settingsModel.";
|
|
66
71
|
|
|
72
|
+
function parseExactOrigins(raw: string): string[] | null {
|
|
73
|
+
const origins = raw
|
|
74
|
+
.split(/\r?\n/)
|
|
75
|
+
.map((line) => line.trim())
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
for (const origin of origins) {
|
|
78
|
+
if (!URL.canParse(origin)) return null;
|
|
79
|
+
const url = new URL(origin);
|
|
80
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
81
|
+
if (
|
|
82
|
+
url.origin !== origin ||
|
|
83
|
+
url.username ||
|
|
84
|
+
url.password ||
|
|
85
|
+
(url.protocol !== "https:" && !(url.protocol === "http:" && local))
|
|
86
|
+
)
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return [...new Set(origins)];
|
|
90
|
+
}
|
|
91
|
+
|
|
67
92
|
function refreshKeys(queryClient: ReturnType<typeof useQueryClient>) {
|
|
68
93
|
return queryClient.invalidateQueries({ queryKey: ["action"] });
|
|
69
94
|
}
|
|
@@ -119,6 +144,98 @@ export function KeyValueDialog(props: KeyValueDialogProps) {
|
|
|
119
144
|
);
|
|
120
145
|
}
|
|
121
146
|
|
|
147
|
+
/** A saved custom key's policy can be approved without exposing its value. */
|
|
148
|
+
export function KeyOriginsDialog({
|
|
149
|
+
entry,
|
|
150
|
+
open,
|
|
151
|
+
onOpenChange,
|
|
152
|
+
}: {
|
|
153
|
+
entry: ApiKeyEntry;
|
|
154
|
+
open: boolean;
|
|
155
|
+
onOpenChange: (open: boolean) => void;
|
|
156
|
+
}) {
|
|
157
|
+
const t = useT();
|
|
158
|
+
const queryClient = useQueryClient();
|
|
159
|
+
const id = useId();
|
|
160
|
+
const [origins, setOrigins] = useState(entry.urlAllowlist?.join("\n") ?? "");
|
|
161
|
+
const [saving, setSaving] = useState(false);
|
|
162
|
+
const [error, setError] = useState<string | null>(null);
|
|
163
|
+
|
|
164
|
+
const save = async () => {
|
|
165
|
+
const parsed = parseExactOrigins(origins);
|
|
166
|
+
if (parsed === null) {
|
|
167
|
+
setError(t(`${K}invalidOrigins`));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
setSaving(true);
|
|
171
|
+
setError(null);
|
|
172
|
+
try {
|
|
173
|
+
await updateApiKeyOrigins({
|
|
174
|
+
name: entry.name,
|
|
175
|
+
scope: entry.storedScope,
|
|
176
|
+
owner: entry.scope,
|
|
177
|
+
urlAllowlist: parsed,
|
|
178
|
+
});
|
|
179
|
+
await refreshKeys(queryClient);
|
|
180
|
+
toast.success(t(`${K}originsSaved`));
|
|
181
|
+
onOpenChange(false);
|
|
182
|
+
} catch (cause) {
|
|
183
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
184
|
+
} finally {
|
|
185
|
+
setSaving(false);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
191
|
+
<DialogContent className="max-w-lg" aria-describedby={undefined}>
|
|
192
|
+
<DialogHeader>
|
|
193
|
+
<DialogTitle>{t(`${K}editOrigins`)}</DialogTitle>
|
|
194
|
+
</DialogHeader>
|
|
195
|
+
<form
|
|
196
|
+
className="grid gap-4"
|
|
197
|
+
onSubmit={(event) => {
|
|
198
|
+
event.preventDefault();
|
|
199
|
+
void save();
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
<div className="grid gap-2">
|
|
203
|
+
<Label htmlFor={id}>{t(`${K}approvedOrigins`)}</Label>
|
|
204
|
+
<Textarea
|
|
205
|
+
id={id}
|
|
206
|
+
value={origins}
|
|
207
|
+
rows={3}
|
|
208
|
+
spellCheck={false}
|
|
209
|
+
placeholder="https://api.example.com"
|
|
210
|
+
onChange={(event) => {
|
|
211
|
+
setOrigins(event.target.value);
|
|
212
|
+
setError(null);
|
|
213
|
+
}}
|
|
214
|
+
/>
|
|
215
|
+
<p className="text-xs text-muted-foreground">
|
|
216
|
+
{t(`${K}originsHint`)}
|
|
217
|
+
</p>
|
|
218
|
+
</div>
|
|
219
|
+
<FormError message={error} />
|
|
220
|
+
<DialogFooter>
|
|
221
|
+
<Button
|
|
222
|
+
type="button"
|
|
223
|
+
variant="secondary"
|
|
224
|
+
onClick={() => onOpenChange(false)}
|
|
225
|
+
>
|
|
226
|
+
{t(`${M}cancel`)}
|
|
227
|
+
</Button>
|
|
228
|
+
<Button type="submit" disabled={saving}>
|
|
229
|
+
{saving ? <Spinner aria-hidden /> : null}
|
|
230
|
+
{t(`${M}save`)}
|
|
231
|
+
</Button>
|
|
232
|
+
</DialogFooter>
|
|
233
|
+
</form>
|
|
234
|
+
</DialogContent>
|
|
235
|
+
</Dialog>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
122
239
|
/** False once the dialog content unmounts, so a late save can't close a newer dialog. */
|
|
123
240
|
function useMounted() {
|
|
124
241
|
const mounted = useRef(true);
|
|
@@ -161,6 +278,7 @@ function KeyValueDialogContent({
|
|
|
161
278
|
nameHint: useId(),
|
|
162
279
|
value: useId(),
|
|
163
280
|
who: useId(),
|
|
281
|
+
origins: useId(),
|
|
164
282
|
};
|
|
165
283
|
const replacing = dialog.mode === "replace" ? dialog.entry : null;
|
|
166
284
|
const forService = dialog.mode === "add" && dialog.forService === true;
|
|
@@ -169,6 +287,9 @@ function KeyValueDialogContent({
|
|
|
169
287
|
(dialog.mode === "add" ? (dialog.initialName ?? "") : ""),
|
|
170
288
|
);
|
|
171
289
|
const [value, setValue] = useState("");
|
|
290
|
+
const [origins, setOrigins] = useState(
|
|
291
|
+
replacing?.urlAllowlist?.join("\n") ?? "",
|
|
292
|
+
);
|
|
172
293
|
const [shared, setShared] = useState(true);
|
|
173
294
|
const [saving, setSaving] = useState(false);
|
|
174
295
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -205,9 +326,15 @@ function KeyValueDialogContent({
|
|
|
205
326
|
lockedShared === null && listing.hasOrganization && listing.canManageOrg;
|
|
206
327
|
const isShared = lockedShared ?? (canChoose && shared);
|
|
207
328
|
const valid = !blocked && target.kind !== "empty" && !!value.trim();
|
|
329
|
+
const customOrigins =
|
|
330
|
+
target.kind === "custom" ? parseExactOrigins(origins) : [];
|
|
208
331
|
|
|
209
332
|
const save = async () => {
|
|
210
333
|
if (saving || !valid) return;
|
|
334
|
+
if (customOrigins === null) {
|
|
335
|
+
setError(t(`${K}invalidOrigins`));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
211
338
|
setSaving(true);
|
|
212
339
|
onSavingChange?.(true);
|
|
213
340
|
setError(null);
|
|
@@ -218,6 +345,7 @@ function KeyValueDialogContent({
|
|
|
218
345
|
value: value.trim(),
|
|
219
346
|
registered: target.kind === "registered",
|
|
220
347
|
shared: isShared,
|
|
348
|
+
...(target.kind === "custom" ? { urlAllowlist: customOrigins } : {}),
|
|
221
349
|
}),
|
|
222
350
|
);
|
|
223
351
|
void refreshKeys(queryClient);
|
|
@@ -328,6 +456,25 @@ function KeyValueDialogContent({
|
|
|
328
456
|
</a>
|
|
329
457
|
) : null}
|
|
330
458
|
</div>
|
|
459
|
+
{target.kind === "custom" ? (
|
|
460
|
+
<div className="grid gap-2">
|
|
461
|
+
<Label htmlFor={ids.origins}>{t(`${K}approvedOrigins`)}</Label>
|
|
462
|
+
<Textarea
|
|
463
|
+
id={ids.origins}
|
|
464
|
+
value={origins}
|
|
465
|
+
rows={2}
|
|
466
|
+
spellCheck={false}
|
|
467
|
+
placeholder="https://api.example.com"
|
|
468
|
+
onChange={(event) => {
|
|
469
|
+
setOrigins(event.target.value);
|
|
470
|
+
setError(null);
|
|
471
|
+
}}
|
|
472
|
+
/>
|
|
473
|
+
<p className="text-xs text-muted-foreground">
|
|
474
|
+
{t(`${K}originsHint`)}
|
|
475
|
+
</p>
|
|
476
|
+
</div>
|
|
477
|
+
) : null}
|
|
331
478
|
{listing.hasOrganization && !blocked ? (
|
|
332
479
|
<div className="grid gap-2">
|
|
333
480
|
<span
|
|
@@ -18,6 +18,7 @@ const callActionMock = vi.hoisted(() => vi.fn());
|
|
|
18
18
|
const navigateMock = vi.hoisted(() => vi.fn());
|
|
19
19
|
const clientMock = vi.hoisted(() => ({
|
|
20
20
|
save: vi.fn(),
|
|
21
|
+
updateOrigins: vi.fn(),
|
|
21
22
|
test: vi.fn(),
|
|
22
23
|
notify: vi.fn(),
|
|
23
24
|
}));
|
|
@@ -73,6 +74,7 @@ vi.mock("../model/ProviderDialog.js", () => ({
|
|
|
73
74
|
|
|
74
75
|
vi.mock("./api-keys-client.js", () => ({
|
|
75
76
|
saveApiKeyValue: clientMock.save,
|
|
77
|
+
updateApiKeyOrigins: clientMock.updateOrigins,
|
|
76
78
|
testSavedApiKey: clientMock.test,
|
|
77
79
|
notifyKeysChanged: clientMock.notify,
|
|
78
80
|
}));
|
|
@@ -121,6 +123,7 @@ function entry(overrides: Partial<ApiKeyEntry>): ApiKeyEntry {
|
|
|
121
123
|
registered: false,
|
|
122
124
|
usedFor: [],
|
|
123
125
|
canReplace: true,
|
|
126
|
+
canEditOrigins: true,
|
|
124
127
|
canDelete: true,
|
|
125
128
|
canTest: false,
|
|
126
129
|
...overrides,
|
|
@@ -216,6 +219,15 @@ function typeInto(input: HTMLInputElement, value: string) {
|
|
|
216
219
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
217
220
|
}
|
|
218
221
|
|
|
222
|
+
function typeTextarea(input: HTMLTextAreaElement, value: string) {
|
|
223
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
224
|
+
HTMLTextAreaElement.prototype,
|
|
225
|
+
"value",
|
|
226
|
+
)!.set!;
|
|
227
|
+
setter.call(input, value);
|
|
228
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
229
|
+
}
|
|
230
|
+
|
|
219
231
|
describe("ApiKeysSettingsPage", () => {
|
|
220
232
|
let container: HTMLDivElement;
|
|
221
233
|
let root: Root;
|
|
@@ -229,6 +241,7 @@ describe("ApiKeysSettingsPage", () => {
|
|
|
229
241
|
callActionMock.mockReset();
|
|
230
242
|
navigateMock.mockReset();
|
|
231
243
|
clientMock.save.mockReset();
|
|
244
|
+
clientMock.updateOrigins.mockReset();
|
|
232
245
|
clientMock.test.mockReset();
|
|
233
246
|
onboardingOutcomeMock.mockReset();
|
|
234
247
|
onboardingAbandonmentRequestMock.mockReset();
|
|
@@ -412,6 +425,7 @@ describe("ApiKeysSettingsPage", () => {
|
|
|
412
425
|
value: "fake-linear-value",
|
|
413
426
|
registered: false,
|
|
414
427
|
shared: false,
|
|
428
|
+
urlAllowlist: [],
|
|
415
429
|
});
|
|
416
430
|
});
|
|
417
431
|
|
|
@@ -443,6 +457,180 @@ describe("ApiKeysSettingsPage", () => {
|
|
|
443
457
|
value: "fake-linear-value",
|
|
444
458
|
registered: false,
|
|
445
459
|
shared: true,
|
|
460
|
+
urlAllowlist: [],
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("sends approved exact origins with a new custom key", async () => {
|
|
465
|
+
clientMock.save.mockResolvedValue(undefined);
|
|
466
|
+
await render();
|
|
467
|
+
const header = await renderHeader();
|
|
468
|
+
await act(async () => buttonByText("Add key", header).click());
|
|
469
|
+
const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
|
|
470
|
+
const [name, value] = [
|
|
471
|
+
...dialog.querySelectorAll("input"),
|
|
472
|
+
] as HTMLInputElement[];
|
|
473
|
+
await act(async () => typeInto(name!, "WEBHOOK_TOKEN"));
|
|
474
|
+
await act(async () => typeInto(value!, "fake-value"));
|
|
475
|
+
await act(async () =>
|
|
476
|
+
typeTextarea(
|
|
477
|
+
dialog.querySelector("textarea")!,
|
|
478
|
+
"https://api.example.test",
|
|
479
|
+
),
|
|
480
|
+
);
|
|
481
|
+
await act(async () =>
|
|
482
|
+
dialog
|
|
483
|
+
.querySelector("form")!
|
|
484
|
+
.dispatchEvent(
|
|
485
|
+
new Event("submit", { bubbles: true, cancelable: true }),
|
|
486
|
+
),
|
|
487
|
+
);
|
|
488
|
+
expect(clientMock.save).toHaveBeenCalledWith({
|
|
489
|
+
name: "WEBHOOK_TOKEN",
|
|
490
|
+
value: "fake-value",
|
|
491
|
+
registered: false,
|
|
492
|
+
shared: false,
|
|
493
|
+
urlAllowlist: ["https://api.example.test"],
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("edits an existing custom key's origins without asking for its value", async () => {
|
|
498
|
+
state.listing = listing({ keys: [entry({ urlAllowlist: null })] });
|
|
499
|
+
clientMock.updateOrigins.mockResolvedValue(undefined);
|
|
500
|
+
await render();
|
|
501
|
+
expect(row("secrets:STRIPE_SECRET_KEY").textContent).toContain(
|
|
502
|
+
"No approved origins",
|
|
503
|
+
);
|
|
504
|
+
await act(async () =>
|
|
505
|
+
pointerDown(
|
|
506
|
+
row("secrets:STRIPE_SECRET_KEY").querySelector(
|
|
507
|
+
'[aria-label="Manage STRIPE_SECRET_KEY"]',
|
|
508
|
+
),
|
|
509
|
+
),
|
|
510
|
+
);
|
|
511
|
+
const item = [...document.querySelectorAll('[role="menuitem"]')].find(
|
|
512
|
+
(node) => node.textContent?.trim() === "Edit origins",
|
|
513
|
+
);
|
|
514
|
+
if (!item) throw new Error("No origin policy menu item");
|
|
515
|
+
await act(async () =>
|
|
516
|
+
item.dispatchEvent(new MouseEvent("click", { bubbles: true })),
|
|
517
|
+
);
|
|
518
|
+
const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
|
|
519
|
+
expect(dialog.querySelector('input[type="password"]')).toBeNull();
|
|
520
|
+
await act(async () =>
|
|
521
|
+
typeTextarea(
|
|
522
|
+
dialog.querySelector("textarea")!,
|
|
523
|
+
"https://api.example.test",
|
|
524
|
+
),
|
|
525
|
+
);
|
|
526
|
+
await act(async () =>
|
|
527
|
+
dialog
|
|
528
|
+
.querySelector("form")!
|
|
529
|
+
.dispatchEvent(
|
|
530
|
+
new Event("submit", { bubbles: true, cancelable: true }),
|
|
531
|
+
),
|
|
532
|
+
);
|
|
533
|
+
expect(clientMock.updateOrigins).toHaveBeenCalledWith({
|
|
534
|
+
name: "STRIPE_SECRET_KEY",
|
|
535
|
+
scope: "user",
|
|
536
|
+
owner: "user",
|
|
537
|
+
urlAllowlist: ["https://api.example.test"],
|
|
538
|
+
});
|
|
539
|
+
expect(clientMock.save).not.toHaveBeenCalled();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("offers policy editing for a legacy solo row without replacing its value", async () => {
|
|
543
|
+
state.listing = listing({
|
|
544
|
+
keys: [
|
|
545
|
+
entry({
|
|
546
|
+
name: "OLD_TOKEN",
|
|
547
|
+
storedScope: "workspace",
|
|
548
|
+
canReplace: false,
|
|
549
|
+
canEditOrigins: true,
|
|
550
|
+
urlAllowlist: null,
|
|
551
|
+
}),
|
|
552
|
+
],
|
|
553
|
+
});
|
|
554
|
+
clientMock.updateOrigins.mockResolvedValue(undefined);
|
|
555
|
+
await render();
|
|
556
|
+
await act(async () =>
|
|
557
|
+
pointerDown(
|
|
558
|
+
row("secrets:user-workspace:OLD_TOKEN").querySelector(
|
|
559
|
+
'[aria-label="Manage OLD_TOKEN"]',
|
|
560
|
+
),
|
|
561
|
+
),
|
|
562
|
+
);
|
|
563
|
+
const item = [...document.querySelectorAll('[role="menuitem"]')].find(
|
|
564
|
+
(node) => node.textContent?.trim() === "Edit origins",
|
|
565
|
+
);
|
|
566
|
+
if (!item) throw new Error("No origin policy menu item");
|
|
567
|
+
await act(async () =>
|
|
568
|
+
item.dispatchEvent(new MouseEvent("click", { bubbles: true })),
|
|
569
|
+
);
|
|
570
|
+
const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
|
|
571
|
+
await act(async () =>
|
|
572
|
+
typeTextarea(
|
|
573
|
+
dialog.querySelector("textarea")!,
|
|
574
|
+
"https://api.example.test",
|
|
575
|
+
),
|
|
576
|
+
);
|
|
577
|
+
await act(async () =>
|
|
578
|
+
dialog
|
|
579
|
+
.querySelector("form")!
|
|
580
|
+
.dispatchEvent(
|
|
581
|
+
new Event("submit", { bubbles: true, cancelable: true }),
|
|
582
|
+
),
|
|
583
|
+
);
|
|
584
|
+
expect(clientMock.updateOrigins).toHaveBeenCalledWith({
|
|
585
|
+
name: "OLD_TOKEN",
|
|
586
|
+
scope: "workspace",
|
|
587
|
+
owner: "user",
|
|
588
|
+
urlAllowlist: ["https://api.example.test"],
|
|
589
|
+
});
|
|
590
|
+
expect(clientMock.save).not.toHaveBeenCalled();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("keeps approved origins when rotating a custom key", async () => {
|
|
594
|
+
state.listing = listing({
|
|
595
|
+
keys: [entry({ urlAllowlist: ["https://api.example.test"] })],
|
|
596
|
+
});
|
|
597
|
+
clientMock.save.mockResolvedValue(undefined);
|
|
598
|
+
await render();
|
|
599
|
+
await act(async () =>
|
|
600
|
+
pointerDown(
|
|
601
|
+
row("secrets:STRIPE_SECRET_KEY").querySelector(
|
|
602
|
+
'[aria-label="Manage STRIPE_SECRET_KEY"]',
|
|
603
|
+
),
|
|
604
|
+
),
|
|
605
|
+
);
|
|
606
|
+
const item = [...document.querySelectorAll('[role="menuitem"]')].find(
|
|
607
|
+
(node) => node.textContent?.trim() === "Replace value",
|
|
608
|
+
);
|
|
609
|
+
if (!item) throw new Error("No replacement menu item");
|
|
610
|
+
await act(async () =>
|
|
611
|
+
item.dispatchEvent(new MouseEvent("click", { bubbles: true })),
|
|
612
|
+
);
|
|
613
|
+
const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
|
|
614
|
+
expect(dialog.querySelector("textarea")?.value).toBe(
|
|
615
|
+
"https://api.example.test",
|
|
616
|
+
);
|
|
617
|
+
const value = dialog.querySelector(
|
|
618
|
+
'input[type="password"]',
|
|
619
|
+
) as HTMLInputElement;
|
|
620
|
+
await act(async () => typeInto(value, "new-fake-value"));
|
|
621
|
+
await act(async () =>
|
|
622
|
+
dialog
|
|
623
|
+
.querySelector("form")!
|
|
624
|
+
.dispatchEvent(
|
|
625
|
+
new Event("submit", { bubbles: true, cancelable: true }),
|
|
626
|
+
),
|
|
627
|
+
);
|
|
628
|
+
expect(clientMock.save).toHaveBeenCalledWith({
|
|
629
|
+
name: "STRIPE_SECRET_KEY",
|
|
630
|
+
value: "new-fake-value",
|
|
631
|
+
registered: false,
|
|
632
|
+
shared: false,
|
|
633
|
+
urlAllowlist: ["https://api.example.test"],
|
|
446
634
|
});
|
|
447
635
|
});
|
|
448
636
|
|
|
@@ -532,6 +720,7 @@ describe("ApiKeysSettingsPage", () => {
|
|
|
532
720
|
value: "fake-custom-value",
|
|
533
721
|
registered: false,
|
|
534
722
|
shared: false,
|
|
723
|
+
urlAllowlist: [],
|
|
535
724
|
});
|
|
536
725
|
|
|
537
726
|
await act(async () => buttonByText("Close", dialog).click());
|
|
@@ -769,6 +958,7 @@ describe("DeleteKeyDialog", () => {
|
|
|
769
958
|
value: "fake-voyage-value",
|
|
770
959
|
registered: false,
|
|
771
960
|
shared: true,
|
|
961
|
+
urlAllowlist: [],
|
|
772
962
|
});
|
|
773
963
|
expect(onSaved).toHaveBeenCalled();
|
|
774
964
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
@@ -23,6 +23,7 @@ import { Skeleton } from "@agent-native/toolkit/ui/skeleton";
|
|
|
23
23
|
import { TooltipProvider } from "@agent-native/toolkit/ui/tooltip";
|
|
24
24
|
import {
|
|
25
25
|
IconDots,
|
|
26
|
+
IconLock,
|
|
26
27
|
IconKey,
|
|
27
28
|
IconPlugConnected,
|
|
28
29
|
IconPlus,
|
|
@@ -56,6 +57,7 @@ import {
|
|
|
56
57
|
} from "./api-keys-state.js";
|
|
57
58
|
import {
|
|
58
59
|
DeleteKeyDialog,
|
|
60
|
+
KeyOriginsDialog,
|
|
59
61
|
KeyValueDialog,
|
|
60
62
|
type KeyValueDialogMode,
|
|
61
63
|
} from "./ApiKeyDialogs.js";
|
|
@@ -98,6 +100,9 @@ export default function ApiKeysSettingsPage({ context }: SettingsPageProps) {
|
|
|
98
100
|
null,
|
|
99
101
|
);
|
|
100
102
|
const [deleting, setDeleting] = useState<ApiKeyEntry | null>(null);
|
|
103
|
+
const [editingOrigins, setEditingOrigins] = useState<ApiKeyEntry | null>(
|
|
104
|
+
null,
|
|
105
|
+
);
|
|
101
106
|
const [provider, setProvider] = useState<ProviderDialogState | null>(null);
|
|
102
107
|
const hashKey = useSecretKeyHash();
|
|
103
108
|
const handledHashKey = useRef<string | null>(null);
|
|
@@ -178,6 +183,7 @@ export default function ApiKeysSettingsPage({ context }: SettingsPageProps) {
|
|
|
178
183
|
onReplace: (entry: ApiKeyEntry) =>
|
|
179
184
|
setValueDialog({ mode: "replace", entry }),
|
|
180
185
|
onDelete: setDeleting,
|
|
186
|
+
onEditOrigins: setEditingOrigins,
|
|
181
187
|
};
|
|
182
188
|
|
|
183
189
|
return (
|
|
@@ -260,6 +266,15 @@ export default function ApiKeysSettingsPage({ context }: SettingsPageProps) {
|
|
|
260
266
|
orgName={orgName}
|
|
261
267
|
/>
|
|
262
268
|
) : null}
|
|
269
|
+
{editingOrigins ? (
|
|
270
|
+
<KeyOriginsDialog
|
|
271
|
+
entry={editingOrigins}
|
|
272
|
+
open
|
|
273
|
+
onOpenChange={(open) => {
|
|
274
|
+
if (!open) setEditingOrigins(null);
|
|
275
|
+
}}
|
|
276
|
+
/>
|
|
277
|
+
) : null}
|
|
263
278
|
<ProviderDialog
|
|
264
279
|
open={provider !== null}
|
|
265
280
|
onOpenChange={(open) => {
|
|
@@ -311,20 +326,31 @@ function KeyRow({
|
|
|
311
326
|
onManageProvider,
|
|
312
327
|
onReplace,
|
|
313
328
|
onDelete,
|
|
329
|
+
onEditOrigins,
|
|
314
330
|
}: {
|
|
315
331
|
entry: ApiKeyEntry;
|
|
316
332
|
onManageProvider: (entry: ApiKeyEntry) => void;
|
|
317
333
|
onReplace: (entry: ApiKeyEntry) => void;
|
|
318
334
|
onDelete: (entry: ApiKeyEntry) => void;
|
|
335
|
+
onEditOrigins: (entry: ApiKeyEntry) => void;
|
|
319
336
|
}) {
|
|
320
337
|
const t = useT();
|
|
321
338
|
const usedBy = <UsedBy entry={entry} />;
|
|
322
339
|
const hasUse = !!entry.provider || usedByFeatures(entry).length > 0;
|
|
340
|
+
const custom = !entry.registered && !entry.provider;
|
|
323
341
|
const description: ReactNode = (
|
|
324
342
|
<>
|
|
325
343
|
{entry.masked ? <span className="font-mono">{entry.masked}</span> : null}
|
|
326
344
|
{entry.masked && hasUse ? " · " : null}
|
|
327
345
|
{hasUse ? usedBy : null}
|
|
346
|
+
{custom && entry.canEditOrigins && (entry.masked || hasUse)
|
|
347
|
+
? " · "
|
|
348
|
+
: null}
|
|
349
|
+
{custom && entry.canEditOrigins
|
|
350
|
+
? entry.urlAllowlist?.length
|
|
351
|
+
? t(`${K}originsCount`, { count: entry.urlAllowlist.length })
|
|
352
|
+
: t(`${K}noApprovedOrigins`)
|
|
353
|
+
: null}
|
|
328
354
|
</>
|
|
329
355
|
);
|
|
330
356
|
|
|
@@ -353,6 +379,7 @@ function KeyRow({
|
|
|
353
379
|
entry={entry}
|
|
354
380
|
onReplace={() => onReplace(entry)}
|
|
355
381
|
onDelete={() => onDelete(entry)}
|
|
382
|
+
onEditOrigins={() => onEditOrigins(entry)}
|
|
356
383
|
/>
|
|
357
384
|
);
|
|
358
385
|
}
|
|
@@ -365,7 +392,11 @@ function KeyRow({
|
|
|
365
392
|
<span className="font-mono text-[13px]">{entry.name}</span>
|
|
366
393
|
)
|
|
367
394
|
}
|
|
368
|
-
description={
|
|
395
|
+
description={
|
|
396
|
+
entry.masked || hasUse || (custom && entry.canEditOrigins)
|
|
397
|
+
? description
|
|
398
|
+
: undefined
|
|
399
|
+
}
|
|
369
400
|
control={control}
|
|
370
401
|
/>
|
|
371
402
|
);
|
|
@@ -375,10 +406,12 @@ function KeyMenu({
|
|
|
375
406
|
entry,
|
|
376
407
|
onReplace,
|
|
377
408
|
onDelete,
|
|
409
|
+
onEditOrigins,
|
|
378
410
|
}: {
|
|
379
411
|
entry: ApiKeyEntry;
|
|
380
412
|
onReplace: () => void;
|
|
381
413
|
onDelete: () => void;
|
|
414
|
+
onEditOrigins: () => void;
|
|
382
415
|
}) {
|
|
383
416
|
const t = useT();
|
|
384
417
|
const test = async () => {
|
|
@@ -400,6 +433,12 @@ function KeyMenu({
|
|
|
400
433
|
</Button>
|
|
401
434
|
</DropdownMenuTrigger>
|
|
402
435
|
<DropdownMenuContent align="end" className="w-44">
|
|
436
|
+
{entry.canEditOrigins ? (
|
|
437
|
+
<DropdownMenuItem onSelect={onEditOrigins}>
|
|
438
|
+
<IconLock className="size-4" aria-hidden />
|
|
439
|
+
{t(`${K}editOrigins`)}
|
|
440
|
+
</DropdownMenuItem>
|
|
441
|
+
) : null}
|
|
403
442
|
{entry.canTest ? (
|
|
404
443
|
<DropdownMenuItem onSelect={() => void test()}>
|
|
405
444
|
<IconPlugConnected className="size-4" aria-hidden />
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { saveApiKeyValue, updateApiKeyOrigins } from "./api-keys-client.js";
|
|
4
|
+
|
|
5
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
6
|
+
|
|
7
|
+
describe("custom key requests", () => {
|
|
8
|
+
it("sends an approved origin with a new key, then edits only policy metadata", async () => {
|
|
9
|
+
const requests: Array<{ url: string; init: RequestInit }> = [];
|
|
10
|
+
vi.stubGlobal(
|
|
11
|
+
"fetch",
|
|
12
|
+
vi.fn(async (url: string, init: RequestInit) => {
|
|
13
|
+
requests.push({ url, init });
|
|
14
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
await saveApiKeyValue({
|
|
19
|
+
name: "WEBHOOK_TOKEN",
|
|
20
|
+
value: "fake-value",
|
|
21
|
+
registered: false,
|
|
22
|
+
shared: true,
|
|
23
|
+
urlAllowlist: ["https://api.example.test"],
|
|
24
|
+
});
|
|
25
|
+
await updateApiKeyOrigins({
|
|
26
|
+
name: "WEBHOOK_TOKEN",
|
|
27
|
+
scope: "workspace",
|
|
28
|
+
owner: "org",
|
|
29
|
+
urlAllowlist: ["https://new.example.test"],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(requests[0]?.url).toContain("/_agent-native/secrets/adhoc");
|
|
33
|
+
expect(JSON.parse(String(requests[0]?.init.body))).toEqual({
|
|
34
|
+
name: "WEBHOOK_TOKEN",
|
|
35
|
+
value: "fake-value",
|
|
36
|
+
scope: "workspace",
|
|
37
|
+
urlAllowlist: ["https://api.example.test"],
|
|
38
|
+
});
|
|
39
|
+
expect(requests[1]?.init.method).toBe("PATCH");
|
|
40
|
+
expect(requests[1]?.url).toContain(
|
|
41
|
+
"/_agent-native/secrets/adhoc/WEBHOOK_TOKEN",
|
|
42
|
+
);
|
|
43
|
+
expect(JSON.parse(String(requests[1]?.init.body))).toEqual({
|
|
44
|
+
scope: "workspace",
|
|
45
|
+
owner: "org",
|
|
46
|
+
urlAllowlist: ["https://new.example.test"],
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -11,6 +11,8 @@ export interface SaveApiKeyValueInput {
|
|
|
11
11
|
registered: boolean;
|
|
12
12
|
/** Ad-hoc keys only: save for everyone in the organization. */
|
|
13
13
|
shared?: boolean;
|
|
14
|
+
/** Exact outbound origins for a custom key. Omit when rotating to retain the policy. */
|
|
15
|
+
urlAllowlist?: string[];
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
async function readError(res: Response): Promise<string | null> {
|
|
@@ -41,6 +43,7 @@ export async function saveApiKeyValue({
|
|
|
41
43
|
value,
|
|
42
44
|
registered,
|
|
43
45
|
shared,
|
|
46
|
+
urlAllowlist,
|
|
44
47
|
}: SaveApiKeyValueInput): Promise<void> {
|
|
45
48
|
const res = registered
|
|
46
49
|
? await fetch(`${secretsEndpoint()}/${encodeURIComponent(name)}`, {
|
|
@@ -55,6 +58,7 @@ export async function saveApiKeyValue({
|
|
|
55
58
|
name,
|
|
56
59
|
value,
|
|
57
60
|
scope: shared ? "workspace" : "user",
|
|
61
|
+
...(urlAllowlist === undefined ? {} : { urlAllowlist }),
|
|
58
62
|
}),
|
|
59
63
|
});
|
|
60
64
|
if (!res.ok) {
|
|
@@ -65,6 +69,34 @@ export async function saveApiKeyValue({
|
|
|
65
69
|
notifyKeysChanged();
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
/** Edit a saved custom key's outbound policy without sending its value. */
|
|
73
|
+
export async function updateApiKeyOrigins(input: {
|
|
74
|
+
name: string;
|
|
75
|
+
scope: "user" | "org" | "workspace";
|
|
76
|
+
owner: "user" | "org";
|
|
77
|
+
urlAllowlist: string[];
|
|
78
|
+
}): Promise<void> {
|
|
79
|
+
const res = await fetch(
|
|
80
|
+
`${secretsEndpoint()}/adhoc/${encodeURIComponent(input.name)}`,
|
|
81
|
+
{
|
|
82
|
+
method: "PATCH",
|
|
83
|
+
headers: { "Content-Type": "application/json" },
|
|
84
|
+
body: JSON.stringify({
|
|
85
|
+
scope: input.scope,
|
|
86
|
+
owner: input.owner,
|
|
87
|
+
urlAllowlist: input.urlAllowlist,
|
|
88
|
+
}),
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
(await readError(res)) ??
|
|
94
|
+
`Could not update ${input.name} (${res.status})`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
notifyKeysChanged();
|
|
98
|
+
}
|
|
99
|
+
|
|
68
100
|
/** Remove a registered key's saved value. Throws the server's message. */
|
|
69
101
|
export async function removeApiKeyValue(name: string): Promise<void> {
|
|
70
102
|
const res = await fetch(`${secretsEndpoint()}/${encodeURIComponent(name)}`, {
|