@jini-ai/admin 0.4.12 → 0.6.10
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/CHANGELOG.md +70 -2
- package/dist/agent-plugins/controllers/agent-plugin-files.controller.js +14 -9
- package/dist/agent-plugins/controllers/agent-plugins.controller.js +12 -9
- package/dist/agent-plugins/package-file-tree.js +54 -51
- package/dist/agent-plugins/react/components/AgentPluginRow.d.ts +1 -1
- package/dist/agent-plugins/react/components/AgentPluginRow.js +9 -2
- package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.d.ts +5 -5
- package/dist/agent-plugins/react/hooks/AgentPluginInspector.hooks.js +14 -5
- package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.d.ts +15 -13
- package/dist/agent-plugins/react/hooks/AgentPluginPanel.hooks.js +54 -40
- package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.d.ts +13 -13
- package/dist/agent-plugins/react/hooks/AgentPluginsPage.hooks.js +23 -11
- package/dist/browser/navigation.js +44 -27
- package/dist/comments/adapters/memory.js +30 -12
- package/dist/comments/conformance/comments-api.conformance.js +55 -33
- package/dist/comments/controllers/comment-queue.controller.js +13 -4
- package/dist/comments/react/hooks/use-comment-queue.hooks.d.ts +17 -2
- package/dist/comments/react/hooks/use-comment-queue.hooks.js +79 -30
- package/dist/comments/react/hooks/use-comment-settings.hooks.js +9 -5
- package/dist/comments/react/index.d.ts +2 -1
- package/dist/comments/react/index.js +1 -1
- package/dist/consent/react/ConsentFields.d.ts +35 -0
- package/dist/consent/react/ConsentFields.js +76 -0
- package/dist/consent/react/ConsentPreviewFrame.d.ts +14 -0
- package/dist/consent/react/ConsentPreviewFrame.js +14 -0
- package/dist/consent/react/ConsentSettingsPanel.d.ts +27 -4
- package/dist/consent/react/ConsentSettingsPanel.js +6 -17
- package/dist/consent/react/index.d.ts +6 -1
- package/dist/consent/react/index.js +2 -0
- package/dist/consent/react/settings-panel.hooks.d.ts +28 -1
- package/dist/consent/react/settings-panel.hooks.js +26 -0
- package/dist/core/entities/rules.js +126 -81
- package/dist/core/manifest/rules.js +10 -10
- package/dist/core/module/create-admin.js +195 -117
- package/dist/core/ports/redirects.d.ts +8 -6
- package/dist/core/ports/redirects.js +8 -6
- package/dist/core/routing/rules.js +9 -4
- package/dist/core/transport/http.js +37 -20
- package/dist/forms/adapters/memory.js +22 -11
- package/dist/forms/conformance/forms-api.conformance.d.ts +6 -4
- package/dist/forms/conformance/forms-api.conformance.js +42 -46
- package/dist/forms/field-settings.js +150 -115
- package/dist/forms/html-rules.js +45 -28
- package/dist/forms/react/components/FieldSettingsRows.d.ts +22 -0
- package/dist/forms/react/components/FieldSettingsRows.js +29 -0
- package/dist/forms/react/components/FieldSettingsSection.d.ts +1 -1
- package/dist/forms/react/components/FieldSettingsSection.js +5 -6
- package/dist/forms/react/components/FormEditorFieldsBody.d.ts +1 -1
- package/dist/forms/react/components/FormEditorFieldsBody.js +4 -15
- package/dist/forms/react/components/FormEditorFieldsBodyParts.d.ts +18 -0
- package/dist/forms/react/components/FormEditorFieldsBodyParts.js +32 -0
- package/dist/forms/react/components/FormSubmissions.d.ts +1 -1
- package/dist/forms/react/components/FormSubmissions.js +4 -20
- package/dist/forms/react/components/FormSubmissionsTable.d.ts +19 -0
- package/dist/forms/react/components/FormSubmissionsTable.js +22 -0
- package/dist/media/adapters/memory.js +31 -26
- package/dist/media/controllers/edit-media.controller.js +2 -1
- package/dist/media/controllers/library.controller.js +48 -35
- package/dist/media/controllers/providers.controller.js +2 -1
- package/dist/media/html-attributes.js +29 -15
- package/dist/media/index.d.ts +1 -1
- package/dist/media/index.js +1 -1
- package/dist/media/react/components/MediaIcons.d.ts +0 -3
- package/dist/media/react/components/MediaIcons.js +0 -9
- package/dist/media/react/components/MediaLightbox.d.ts +2 -1
- package/dist/media/react/components/MediaLightbox.js +11 -5
- package/dist/media/react/hooks/LibraryTab.hooks.d.ts +55 -45
- package/dist/media/react/hooks/LibraryTab.hooks.js +107 -70
- package/dist/media/react/hooks/MediaCard.hooks.d.ts +27 -31
- package/dist/media/react/hooks/MediaCard.hooks.js +52 -26
- package/dist/media/react/hooks/MediaGrid.hooks.d.ts +1 -1
- package/dist/media/react/hooks/MediaLightbox.hooks.d.ts +10 -14
- package/dist/media/react/hooks/MediaLightbox.hooks.js +9 -56
- package/dist/media/react/hooks/MediaPage.hooks.d.ts +6 -0
- package/dist/media/react/hooks/MediaPage.hooks.js +29 -12
- package/dist/media/react/hooks/ProvidersTab.hooks.d.ts +33 -33
- package/dist/media/react/hooks/ProvidersTab.hooks.js +119 -79
- package/dist/media/react/hooks/UploadButton.hooks.d.ts +7 -5
- package/dist/media/react/hooks/UploadButton.hooks.js +36 -24
- package/dist/media/react/index.js +4 -0
- package/dist/media/react/tabs/LibraryTab.d.ts +1 -1
- package/dist/media/react/tabs/LibraryTab.js +2 -2
- package/dist/media/rules.d.ts +7 -0
- package/dist/media/rules.js +25 -2
- package/dist/react/components/ConfirmDialog/ConfirmDialog.js +5 -8
- package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.d.ts +20 -0
- package/dist/react/components/ConfirmDialog/confirm-dialog-attrs.js +24 -0
- package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +16 -7
- package/dist/react/components/RowMenu/RowMenu.js +16 -10
- package/dist/react/components/Sidebar.js +32 -26
- package/dist/react/entities/EntityEdit.js +17 -14
- package/dist/react/entities/EntityField.d.ts +5 -2
- package/dist/react/entities/EntityField.js +38 -23
- package/dist/react/entities/EntityList.js +6 -5
- package/dist/react/entities/RelationTruncationNotice.d.ts +9 -0
- package/dist/react/entities/RelationTruncationNotice.js +9 -0
- package/dist/react/entities/use-entity-edit.d.ts +4 -2
- package/dist/react/entities/use-entity-edit.js +52 -33
- package/dist/react/shell/AdminShell.js +40 -23
- package/dist/react/shell/model.d.ts +4 -1
- package/dist/react/shell/model.js +3 -2
- package/dist/react/shell/use-admin-shell-session.js +25 -26
- package/dist/redirects/adapters/memory.js +9 -2
- package/dist/redirects/conformance/redirects-api.conformance.js +66 -36
- package/dist/redirects/react/hooks/use-import-redirects-form.hooks.d.ts +1 -1
- package/dist/redirects/react/hooks/use-import-redirects-form.hooks.js +10 -3
- package/dist/redirects/react/hooks/wired.hooks.d.ts +1 -1
- package/dist/security/adapters/http.js +25 -10
- package/dist/security/adapters/memory.js +19 -5
- package/dist/security/controllers/access-tokens.controller.js +27 -26
- package/dist/security/controllers/other-credentials.controller.js +4 -2
- package/dist/security/controllers/root-key.controller.js +17 -9
- package/dist/security/react/hooks/AccessTokensTab.hooks.d.ts +40 -38
- package/dist/security/react/hooks/AccessTokensTab.hooks.js +36 -9
- package/dist/security/react/hooks/RootKeyTab.hooks.d.ts +14 -14
- package/dist/security/react/hooks/RootKeyTab.hooks.js +12 -3
- package/dist/security/react/tabs/RootKeyTab.d.ts +1 -1
- package/dist/security/react/tabs/RootKeyTab.js +12 -3
- package/dist/security/rules.js +45 -25
- package/dist/seo/controllers/seo.controller.js +4 -2
- package/dist/seo/react/components/MediaRefField.d.ts +1 -1
- package/dist/seo/react/components/MediaRefField.js +19 -2
- package/dist/seo/react/hooks/MediaRefField.hooks.js +3 -1
- package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -1
- package/dist/seo/react/hooks/use-share-image-warning.hooks.js +48 -34
- package/dist/seo/react/pages/SeoPage.d.ts +4 -4
- package/dist/seo/react/pages/SeoPage.js +26 -6
- package/dist/seo/rules.js +39 -10
- package/dist/source-control/conformance/source-control-api.conformance.js +68 -38
- package/dist/source-control/controllers/source-control.controller.js +20 -8
- package/dist/source-control/react/hooks/ProvidersTab.hooks.d.ts +2 -1
- package/dist/source-control/react/hooks/ProvidersTab.hooks.js +18 -11
- package/dist/widgets/conformance/widgets-api.conformance.js +15 -1
- package/dist/widgets/controllers/instance-editor.controller.js +33 -24
- package/dist/widgets/controllers/library.controller.js +18 -12
- package/dist/widgets/controllers/region-editor.controller.js +6 -5
- package/dist/widgets/react/hooks/page-views.hooks.d.ts +1 -0
- package/dist/widgets/react/hooks/page-views.hooks.js +3 -2
- package/dist/widgets/react/hooks/use-widget-regions.hooks.d.ts +1 -1
- package/dist/widgets/react/hooks/use-widget-regions.hooks.js +9 -3
- package/dist/widgets/react/hooks/use-widgets-library.hooks.js +9 -2
- package/dist/widgets/react/pages/WidgetInstanceEditor.d.ts +1 -1
- package/dist/widgets/react/pages/WidgetInstanceEditor.js +7 -8
- package/dist/widgets/rules.d.ts +14 -2
- package/dist/widgets/rules.js +9 -6
- package/package.json +23 -14
- package/src/themes/default/admin.css +51 -0
- package/src/themes/default/chat.css +21 -0
- package/src/themes/default/manifest.json +29 -0
- package/dist/media/styles.d.ts +0 -4
- package/dist/media/styles.js +0 -5
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { draftForRow, invalidDraftFields, missingRequiredFields } from './rules.js';
|
|
3
3
|
import { loadEntityRow, useEntityRead } from './use-entity-read.js';
|
|
4
|
+
/** The first form for a loaded row, or null while it is loading or an edited row was not found. */
|
|
5
|
+
function initialForm({ descriptor, loaded, id }) {
|
|
6
|
+
if (!loaded || (id !== null && loaded.row === null))
|
|
7
|
+
return null;
|
|
8
|
+
const draft = draftForRow({ descriptor, row: loaded.row });
|
|
9
|
+
const jsonText = Object.create(null);
|
|
10
|
+
for (const field of descriptor.fields) {
|
|
11
|
+
if (field.kind === 'json')
|
|
12
|
+
jsonText[field.name] = draft[field.name] === undefined ? '' : JSON.stringify(draft[field.name], null, 2);
|
|
13
|
+
}
|
|
14
|
+
return { draft, jsonText, jsonErrors: {} };
|
|
15
|
+
}
|
|
16
|
+
function canSaveForm({ form, missing, invalid, pending }) {
|
|
17
|
+
if (form === null || pending !== null)
|
|
18
|
+
return false;
|
|
19
|
+
return missing.length === 0 && invalid.length === 0 && !Object.values(form.jsonErrors).some(Boolean);
|
|
20
|
+
}
|
|
21
|
+
function parseJsonText(text) {
|
|
22
|
+
try {
|
|
23
|
+
return { value: text.trim() === '' ? undefined : JSON.parse(text), failed: false };
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return { value: undefined, failed: true };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function mutationAllowed({ kind, canSave, id, port }) {
|
|
30
|
+
if (kind === 'save')
|
|
31
|
+
return canSave;
|
|
32
|
+
return id !== null && port.remove !== undefined;
|
|
33
|
+
}
|
|
34
|
+
/** Run the port call; resolves to the saved row's id, or null after a removal. */
|
|
35
|
+
async function performMutation({ kind, port, id, draft }) {
|
|
36
|
+
if (kind === 'remove' && id !== null && port.remove) {
|
|
37
|
+
await port.remove(id);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
// A create omits blanks; an update carries explicit undefined to clear optional fields.
|
|
41
|
+
const saved = id === null
|
|
42
|
+
? await port.create(Object.fromEntries(Object.entries(draft).filter(([, value]) => value !== undefined)))
|
|
43
|
+
: await port.update(id, draft);
|
|
44
|
+
return saved.id;
|
|
45
|
+
}
|
|
4
46
|
/** Called by the editor mounted for one entity/id pair. */
|
|
5
47
|
export function useEntityEdit({ port, ...props }) {
|
|
6
48
|
const { entityName, id, registry, routes } = props;
|
|
@@ -15,22 +57,16 @@ export function useEntityEdit({ port, ...props }) {
|
|
|
15
57
|
const [mutation, setMutation] = useState({ scope, kind: null, failed: false });
|
|
16
58
|
const busy = useRef(false);
|
|
17
59
|
let form = formState?.scope === scope ? formState.form : null;
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (field.kind === 'json')
|
|
23
|
-
jsonText[field.name] = draft[field.name] === undefined ? '' : JSON.stringify(draft[field.name], null, 2);
|
|
24
|
-
}
|
|
25
|
-
form = { draft, jsonText, jsonErrors: {} };
|
|
26
|
-
setFormState({ scope, form });
|
|
60
|
+
if (form === null) {
|
|
61
|
+
form = initialForm({ descriptor: port.descriptor, loaded: read.data, id });
|
|
62
|
+
if (form !== null)
|
|
63
|
+
setFormState({ scope, form });
|
|
27
64
|
}
|
|
28
65
|
const draft = form?.draft ?? {};
|
|
29
66
|
const missing = missingRequiredFields({ descriptor: port.descriptor, draft });
|
|
30
67
|
const invalid = invalidDraftFields({ descriptor: port.descriptor, draft });
|
|
31
68
|
const pending = mutation.scope === scope ? mutation.kind : null;
|
|
32
|
-
const canSave = form
|
|
33
|
-
!Object.values(form.jsonErrors).some(Boolean) && pending === null;
|
|
69
|
+
const canSave = canSaveForm({ form, missing, invalid, pending });
|
|
34
70
|
function setField({ name, value }) {
|
|
35
71
|
if (!port.descriptor.fields.some((field) => field.name === name) || busy.current)
|
|
36
72
|
return;
|
|
@@ -39,14 +75,7 @@ export function useEntityEdit({ port, ...props }) {
|
|
|
39
75
|
function setJsonText({ name, text }) {
|
|
40
76
|
if (!port.descriptor.fields.some((field) => field.name === name && field.kind === 'json') || busy.current)
|
|
41
77
|
return;
|
|
42
|
-
|
|
43
|
-
let failed = false;
|
|
44
|
-
try {
|
|
45
|
-
value = text.trim() === '' ? undefined : JSON.parse(text);
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
failed = true;
|
|
49
|
-
}
|
|
78
|
+
const { value, failed } = parseJsonText(text);
|
|
50
79
|
setFormState((state) => state?.scope === scope ? {
|
|
51
80
|
scope,
|
|
52
81
|
form: {
|
|
@@ -57,25 +86,15 @@ export function useEntityEdit({ port, ...props }) {
|
|
|
57
86
|
} : state);
|
|
58
87
|
}
|
|
59
88
|
async function mutate({ kind }) {
|
|
60
|
-
if (busy.current || (kind
|
|
89
|
+
if (busy.current || !mutationAllowed({ kind, canSave, id, port }))
|
|
61
90
|
return;
|
|
62
91
|
busy.current = true;
|
|
63
92
|
setMutation({ scope, kind, failed: false });
|
|
64
93
|
const active = () => mounted.current && currentScope.current === scope;
|
|
65
94
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
routes.navigate({ entity: entityName });
|
|
70
|
-
}
|
|
71
|
-
else {
|
|
72
|
-
// A create omits blanks; an update carries explicit undefined to clear optional fields.
|
|
73
|
-
const saved = id === null
|
|
74
|
-
? await port.create(Object.fromEntries(Object.entries(draft).filter(([, value]) => value !== undefined)))
|
|
75
|
-
: await port.update(id, draft);
|
|
76
|
-
if (active())
|
|
77
|
-
routes.navigate({ entity: entityName, id: saved.id });
|
|
78
|
-
}
|
|
95
|
+
const savedId = await performMutation({ kind, port, id, draft });
|
|
96
|
+
if (active())
|
|
97
|
+
routes.navigate(savedId === null ? { entity: entityName } : { entity: entityName, id: savedId });
|
|
79
98
|
if (active())
|
|
80
99
|
setMutation({ scope, kind: null, failed: false });
|
|
81
100
|
}
|
|
@@ -5,6 +5,40 @@ import { useShellDrawer, useShellRoute } from './AdminShell.hooks.js';
|
|
|
5
5
|
import { resolveAdminShellModel } from './model.js';
|
|
6
6
|
import { useAdminShellSession } from './use-admin-shell-session.js';
|
|
7
7
|
import { useAdminTheme } from './use-admin-theme.js';
|
|
8
|
+
/** Copy only the theme props the host supplied, so absent ones keep the hook's own defaults. */
|
|
9
|
+
function themeOptions(props) {
|
|
10
|
+
return {
|
|
11
|
+
...(props.theme ? { theme: props.theme } : {}),
|
|
12
|
+
...(props.colorScheme ? { colorScheme: props.colorScheme } : {}),
|
|
13
|
+
...(props.themeEnvironment ? { environment: props.themeEnvironment } : {}),
|
|
14
|
+
...(props.themePreferenceStore ? { preferenceStore: props.themePreferenceStore } : {}),
|
|
15
|
+
...(props.onColorSchemeChange ? { onColorSchemeChange: props.onColorSchemeChange } : {}),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** Loading, login or session-error UI for every state except an authenticated one. */
|
|
19
|
+
function renderSessionGate({ props, context, state, refresh }) {
|
|
20
|
+
if (state.status === 'checking')
|
|
21
|
+
return _jsx("div", { className: "boot-screen", role: "status", children: props.labels.loading });
|
|
22
|
+
if (state.status === 'anonymous')
|
|
23
|
+
return props.slots.login({ context, refreshSession: refresh });
|
|
24
|
+
if (props.slots.sessionError)
|
|
25
|
+
return props.slots.sessionError({ error: state.error, retry: refresh });
|
|
26
|
+
return _jsxs("div", { role: "alert", children: [_jsx("p", { children: props.labels.sessionError }), _jsx("button", { type: "button", onClick: () => { void refresh(); }, children: props.labels.retry })] });
|
|
27
|
+
}
|
|
28
|
+
/** Resolve the authenticated route, nav and slot arguments from the session's contributions. */
|
|
29
|
+
function resolveShellView({ props, context, session, routePath, controller }, { appearance }) {
|
|
30
|
+
const model = resolveAdminShellModel({ panels: props.panels, session, routePath, defaultPanelId: props.defaultPanelId }, { capabilities: props.capabilities ?? [] });
|
|
31
|
+
const slotArgs = {
|
|
32
|
+
context, session, route: model.route, logout: controller.logout, refreshSession: controller.refresh,
|
|
33
|
+
...(appearance ? { appearance } : {}),
|
|
34
|
+
};
|
|
35
|
+
const nav = props.translateNav ? props.translateNav({ groups: model.nav }) : model.nav;
|
|
36
|
+
return { model, slotArgs, nav };
|
|
37
|
+
}
|
|
38
|
+
/** The manifest-driven sidebar: header slot, nav groups, rail toggle and footer slot. */
|
|
39
|
+
function ShellSidebar({ props, view, open, id }) {
|
|
40
|
+
return (_jsxs(Sidebar, { activeId: view.model.route.panelId, base: props.adminBase, open: open, id: id, label: props.labels.navigation, railStorageKey: props.railStorageKey, railDefaultCollapsed: props.railDefaultCollapsed ?? false, children: [props.slots.sidebarHeader, _jsx(Sidebar.Nav, { groups: view.nav, soonLabel: props.labels.soon, collapsibleGroups: props.collapsibleGroups ?? [] }), _jsxs(Sidebar.Footer, { children: [_jsx(Sidebar.RailToggle, { expandLabel: props.labels.expandSidebar, collapseLabel: props.labels.collapseSidebar }), props.slots.sidebarFooter?.(view.slotArgs)] })] }));
|
|
41
|
+
}
|
|
8
42
|
/**
|
|
9
43
|
* A host-composed admin layout with manifest navigation and a session gate.
|
|
10
44
|
* @param props Required host scope, ports, contributions, labels and slots; optional layout settings.
|
|
@@ -13,34 +47,17 @@ import { useAdminTheme } from './use-admin-theme.js';
|
|
|
13
47
|
export function AdminShell(props) {
|
|
14
48
|
const context = useMemo(() => ({ apiBase: props.apiBase, workspace: props.workspace }), [props.apiBase, props.workspace]);
|
|
15
49
|
const session = useAdminShellSession({ port: props.session, context });
|
|
16
|
-
const appearance = useAdminTheme({ userId: session.state.status === 'authenticated' ? session.state.session.user.id : null, workspace: context.workspace },
|
|
17
|
-
...(props.theme ? { theme: props.theme } : {}),
|
|
18
|
-
...(props.colorScheme ? { colorScheme: props.colorScheme } : {}),
|
|
19
|
-
...(props.themeEnvironment ? { environment: props.themeEnvironment } : {}),
|
|
20
|
-
...(props.themePreferenceStore ? { preferenceStore: props.themePreferenceStore } : {}),
|
|
21
|
-
...(props.onColorSchemeChange ? { onColorSchemeChange: props.onColorSchemeChange } : {}),
|
|
22
|
-
});
|
|
50
|
+
const appearance = useAdminTheme({ userId: session.state.status === 'authenticated' ? session.state.session.user.id : null, workspace: context.workspace }, themeOptions(props));
|
|
23
51
|
const routePath = useShellRoute({ navigation: props.navigation, base: props.adminBase });
|
|
24
52
|
const drawer = useShellDrawer({ routePath });
|
|
25
53
|
const id = useId();
|
|
26
54
|
const sidebarId = `${id}-sidebar`;
|
|
27
55
|
const mainId = `${id}-main`;
|
|
28
|
-
if (session.state.status
|
|
29
|
-
return
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (session.state.status === 'error') {
|
|
33
|
-
if (props.slots.sessionError)
|
|
34
|
-
return props.slots.sessionError({ error: session.state.error, retry: session.refresh });
|
|
35
|
-
return _jsxs("div", { role: "alert", children: [_jsx("p", { children: props.labels.sessionError }), _jsx("button", { type: "button", onClick: () => { void session.refresh(); }, children: props.labels.retry })] });
|
|
36
|
-
}
|
|
37
|
-
const model = resolveAdminShellModel({ panels: props.panels, session: session.state.session, routePath, defaultPanelId: props.defaultPanelId }, { capabilities: props.capabilities ?? [] });
|
|
38
|
-
const slotArgs = {
|
|
39
|
-
context, session: session.state.session, route: model.route, logout: session.logout, refreshSession: session.refresh,
|
|
40
|
-
...(appearance ? { appearance } : {}),
|
|
41
|
-
};
|
|
42
|
-
const nav = props.translateNav ? props.translateNav({ groups: model.nav }) : model.nav;
|
|
56
|
+
if (session.state.status !== 'authenticated')
|
|
57
|
+
return renderSessionGate({ props, context, state: session.state, refresh: session.refresh });
|
|
58
|
+
const view = resolveShellView({ props, context, session: session.state.session, routePath, controller: session }, { appearance });
|
|
59
|
+
const { model, slotArgs } = view;
|
|
43
60
|
const assistant = props.slots.assistant;
|
|
44
|
-
return (_jsxs("div", { className: ['admin-layout', props.className].filter(Boolean).join(' '), children: [_jsx("a", { href: `#${mainId}`, className: "skip-link", children: props.labels.skipToContent }),
|
|
61
|
+
return (_jsxs("div", { className: ['admin-layout', props.className].filter(Boolean).join(' '), children: [_jsx("a", { href: `#${mainId}`, className: "skip-link", children: props.labels.skipToContent }), _jsx(ShellSidebar, { props: props, view: view, open: drawer.open, id: sidebarId }), drawer.open && _jsx("button", { type: "button", className: "sidebar-backdrop", "aria-label": props.labels.closeNavigation, onClick: () => drawer.setOpen(false) }), _jsxs("div", { className: "admin-main-col", children: [_jsxs("div", { className: "admin-topbar", children: [_jsx("button", { type: "button", className: "admin-topbar-toggle", "aria-expanded": drawer.open, "aria-controls": sidebarId, "aria-label": drawer.open ? props.labels.closeNavigation : props.labels.openNavigation, onClick: () => drawer.setOpen((open) => !open), children: _jsx("svg", { viewBox: "0 0 18 18", fill: "none", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": "true", children: _jsx("path", { d: "M2.5 5h13M2.5 9h13M2.5 13h13" }) }) }), _jsx("span", { className: "admin-topbar-title", children: props.title }), props.slots.topbar] }), _jsx("main", { ref: props.mainRef, id: mainId, className: "admin-content", tabIndex: -1, "data-agent-page": model.agentPageId ?? undefined, children: model.panel?.render(slotArgs) })] }), assistant && _jsx("aside", { className: "admin-assistant-pane", "aria-label": assistant.label, hidden: !assistant.open, children: assistant.content }), props.slots.overlays?.(slotArgs)] }));
|
|
45
62
|
}
|
|
46
63
|
//# sourceMappingURL=AdminShell.js.map
|
|
@@ -4,7 +4,10 @@ import type { AdminShellPanel, AdminShellSession } from './types.js';
|
|
|
4
4
|
export interface AdminShellModel {
|
|
5
5
|
readonly panels: readonly AdminShellPanel[];
|
|
6
6
|
readonly panel: AdminShellPanel | undefined;
|
|
7
|
-
|
|
7
|
+
/** Always names a panel: an unmatched or root path resolves to the host's default panel. */
|
|
8
|
+
readonly route: AdminRoute & {
|
|
9
|
+
readonly panelId: string;
|
|
10
|
+
};
|
|
8
11
|
readonly nav: readonly AdminNavGroup[];
|
|
9
12
|
readonly agentPageId: string | null;
|
|
10
13
|
}
|
|
@@ -12,12 +12,13 @@ export function resolveAdminShellModel(args, options = {}) {
|
|
|
12
12
|
permissions: args.session.effectivePermissions ?? [],
|
|
13
13
|
});
|
|
14
14
|
const matched = matchRoute({ routePath: args.routePath, panels });
|
|
15
|
-
|
|
15
|
+
// `split` with a string separator always returns at least one element, so index 0 exists.
|
|
16
|
+
const rawPath = args.routePath.split('?')[0];
|
|
16
17
|
const isRoot = rawPath.split('/').filter(Boolean).length === 0;
|
|
17
18
|
// The existing matcher maps root to dashboard. The shell's default is host-owned.
|
|
18
19
|
const route = isRoot || matched.panelId === null
|
|
19
20
|
? { panelId: args.defaultPanelId, view: null, params: {}, query: matched.query }
|
|
20
|
-
: matched;
|
|
21
|
+
: { ...matched, panelId: matched.panelId };
|
|
21
22
|
return {
|
|
22
23
|
panels,
|
|
23
24
|
panel: panels.find((panel) => panel.id === route.panelId),
|
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
/** Run one port operation and map its outcome, including adapter failures, to the next gate state. */
|
|
3
|
+
async function settleOperation({ operation, port, context }) {
|
|
4
|
+
try {
|
|
5
|
+
if (operation === 'logout') {
|
|
6
|
+
await port.logout(context);
|
|
7
|
+
return { status: 'anonymous' };
|
|
8
|
+
}
|
|
9
|
+
const session = await port.read(context);
|
|
10
|
+
return session === null
|
|
11
|
+
? { status: 'anonymous' }
|
|
12
|
+
: { status: 'authenticated', session };
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
// Local sign-out must complete even when the server cannot revoke its cookie. A read
|
|
16
|
+
// retry here would quietly restore that still-valid session instead of showing login.
|
|
17
|
+
return operation === 'logout'
|
|
18
|
+
? { status: 'anonymous' }
|
|
19
|
+
: { status: 'error', error };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
2
22
|
/**
|
|
3
23
|
* Own session reads and invalidation without any host API imports.
|
|
4
24
|
* @param args Stable context and session port. Changing either starts a new session check.
|
|
@@ -22,33 +42,12 @@ export function useAdminShellSession(args) {
|
|
|
22
42
|
const request = ++generation.current;
|
|
23
43
|
const isCurrent = () => active.current === owner && generation.current === request;
|
|
24
44
|
setOwned({ context, port, state: { status: 'checking' } });
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (isCurrent())
|
|
29
|
-
setOwned({ context, port, state: { status: 'anonymous' } });
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
const session = await port.read(context);
|
|
33
|
-
if (!isCurrent())
|
|
34
|
-
return;
|
|
35
|
-
const state = session === null
|
|
36
|
-
? { status: 'anonymous' }
|
|
37
|
-
: { status: 'authenticated', session };
|
|
45
|
+
// settleOperation never rejects, so the logout latch below always clears.
|
|
46
|
+
const state = await settleOperation({ operation, port, context });
|
|
47
|
+
if (isCurrent())
|
|
38
48
|
setOwned({ context, port, state });
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// Local sign-out must complete even when the server cannot revoke its cookie. A read
|
|
42
|
-
// retry here would quietly restore that still-valid session instead of showing login.
|
|
43
|
-
if (isCurrent())
|
|
44
|
-
setOwned({ context, port, state: operation === 'logout'
|
|
45
|
-
? { status: 'anonymous' }
|
|
46
|
-
: { status: 'error', error } });
|
|
47
|
-
}
|
|
48
|
-
finally {
|
|
49
|
-
if (operation === 'logout' && loggingOut.current === owner)
|
|
50
|
-
loggingOut.current = null;
|
|
51
|
-
}
|
|
49
|
+
if (operation === 'logout' && loggingOut.current === owner)
|
|
50
|
+
loggingOut.current = null;
|
|
52
51
|
}, [context, port]);
|
|
53
52
|
const refresh = useCallback(() => run('read'), [run]);
|
|
54
53
|
const logout = useCallback(() => run('logout'), [run]);
|
|
@@ -6,6 +6,8 @@ import { AdminApiError } from '../../core/transport/errors.js';
|
|
|
6
6
|
export function createMemoryRedirectsApi({ redirects = [], hits = {} }, { validate, onImport } = {}) {
|
|
7
7
|
const rows = new Map(redirects.map(row => [row.id, Object.freeze({ ...row })]));
|
|
8
8
|
const stats = new Map(Object.entries(hits).map(([id, value]) => [id, Object.freeze({ ...value })]));
|
|
9
|
+
// Ids removed to the Trash: hidden from the list, still readable by id, restored by `status: 'active'`.
|
|
10
|
+
const trashed = new Set();
|
|
9
11
|
let nextId = 1;
|
|
10
12
|
function get(id) {
|
|
11
13
|
const row = rows.get(id);
|
|
@@ -32,7 +34,8 @@ export function createMemoryRedirectsApi({ redirects = [], hits = {} }, { valida
|
|
|
32
34
|
}
|
|
33
35
|
return {
|
|
34
36
|
async listRedirects(_required, filter = {}) {
|
|
35
|
-
return Object.freeze([...rows.values()].filter(row => (
|
|
37
|
+
return Object.freeze([...rows.values()].filter(row => !trashed.has(row.id) &&
|
|
38
|
+
(filter.status === undefined || row.status === filter.status) &&
|
|
36
39
|
(filter.source === undefined || row.source === filter.source) &&
|
|
37
40
|
(filter.matchType === undefined || row.matchType === filter.matchType)));
|
|
38
41
|
},
|
|
@@ -47,14 +50,18 @@ export function createMemoryRedirectsApi({ redirects = [], hits = {} }, { valida
|
|
|
47
50
|
throw new AdminApiError({ message: 'regex match type is not supported', status: 400 }, { code: 'REDIRECT_VALIDATION_ERROR' });
|
|
48
51
|
const row = Object.freeze({ ...next, version: before.version + 1 });
|
|
49
52
|
rows.set(id, row);
|
|
53
|
+
if (patch.status === 'active')
|
|
54
|
+
trashed.delete(id);
|
|
50
55
|
return row;
|
|
51
56
|
},
|
|
52
57
|
async tombstoneRedirect({ id }) {
|
|
53
58
|
const row = get(id);
|
|
54
|
-
|
|
59
|
+
// Keyed on the Trash, not on `status`: a rule already toggled off still moves to the Trash.
|
|
60
|
+
if (trashed.has(id))
|
|
55
61
|
return row;
|
|
56
62
|
const disabled = Object.freeze({ ...row, status: 'disabled', version: row.version + 1 });
|
|
57
63
|
rows.set(id, disabled);
|
|
64
|
+
trashed.add(id);
|
|
58
65
|
return disabled;
|
|
59
66
|
},
|
|
60
67
|
async getRedirectHitStats({ id }) {
|
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
import { AdminApiError } from '../../core/transport/errors.js';
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export async function runRedirectsApiConformance({ api }, { referenceRegexRejection = true } = {}) {
|
|
8
|
-
const checks = [];
|
|
9
|
-
function assert(ok, name) {
|
|
2
|
+
/** The id every not-found probe asks for; no conforming backend may hold it. */
|
|
3
|
+
const MISSING = { id: 'missing-conformance-rule' };
|
|
4
|
+
function createChecklist(_required = {}, _optional = {}) {
|
|
5
|
+
const passed = [];
|
|
6
|
+
function check({ ok, name }) {
|
|
10
7
|
if (!ok)
|
|
11
8
|
throw new AdminApiError({ message: `Redirects API conformance: ${name}`, status: 0 }, { code: 'CONFORMANCE_FAILED' });
|
|
12
|
-
|
|
9
|
+
passed.push(name);
|
|
13
10
|
}
|
|
14
|
-
async function rejects(run, name) {
|
|
11
|
+
async function rejects({ run, name }) {
|
|
15
12
|
let rejected = false;
|
|
16
13
|
try {
|
|
17
14
|
await run();
|
|
@@ -19,48 +16,81 @@ export async function runRedirectsApiConformance({ api }, { referenceRegexReject
|
|
|
19
16
|
catch {
|
|
20
17
|
rejected = true;
|
|
21
18
|
}
|
|
22
|
-
|
|
19
|
+
check({ ok: rejected, name });
|
|
23
20
|
}
|
|
24
|
-
|
|
21
|
+
return { passed, check, rejects };
|
|
22
|
+
}
|
|
23
|
+
async function checkCreateAndRead({ api, list, input }, _optional = {}) {
|
|
25
24
|
const created = await api.createRedirect(input, { override: true, priority: 7 });
|
|
26
|
-
|
|
25
|
+
list.check({ ok: created.status === 'active' && created.override && created.priority === 7, name: 'create preserves fields and options' });
|
|
27
26
|
const before = await api.listRedirects({});
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
list.check({ ok: before.some(row => row.id === created.id), name: 'list contains the created rule' });
|
|
28
|
+
list.check({ ok: (await api.getRedirect({ id: created.id })).id === created.id, name: 'get reads by id' });
|
|
30
29
|
const hits = await api.getRedirectHitStats({ id: created.id });
|
|
31
|
-
|
|
30
|
+
list.check({ ok: hits.redirectId === created.id && hits.hitCount === 0 && hits.lastHitAt === null, name: 'new rule has zero hits' });
|
|
31
|
+
return { created, before };
|
|
32
|
+
}
|
|
33
|
+
async function checkUpdate({ api, list, created, before }, _optional = {}) {
|
|
32
34
|
const updated = await api.updateRedirect({ id: created.id }, { status: 'disabled' });
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
list.check({ ok: updated.status === 'disabled' && updated.toTarget === created.toTarget, name: 'partial update preserves other fields' });
|
|
36
|
+
list.check({ ok: created.status === 'active' && before.find(row => row.id === created.id)?.status === 'active', name: 'prior snapshots stay unchanged' });
|
|
37
|
+
list.check({ ok: (await api.listRedirects({}, { status: 'disabled' })).every(row => row.status === 'disabled'), name: 'status filter' });
|
|
36
38
|
await api.updateRedirect({ id: created.id }, { status: 'active' });
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
await
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
39
|
+
}
|
|
40
|
+
async function checkSoftDelete({ api, list, id }, _optional = {}) {
|
|
41
|
+
const disabled = await api.tombstoneRedirect({ id });
|
|
42
|
+
list.check({ ok: disabled.status === 'disabled', name: 'tombstone returns the disabled rule' });
|
|
43
|
+
// Removal moves the rule to the Trash: it leaves the list and is shown only there (Bug B).
|
|
44
|
+
list.check({ ok: !(await api.listRedirects({})).some(row => row.id === id), name: 'removed rule leaves the list' });
|
|
45
|
+
list.check({ ok: (await api.getRedirect({ id })).status === 'disabled', name: 'tombstoned rule remains readable' });
|
|
46
|
+
// A pure `status: 'active'` update is the restore; remove the rule again so the run leaves it in the Trash.
|
|
47
|
+
await api.updateRedirect({ id }, { status: 'active' });
|
|
48
|
+
list.check({ ok: (await api.listRedirects({})).some(row => row.id === id), name: 'restore returns the rule to the list' });
|
|
49
|
+
await api.tombstoneRedirect({ id });
|
|
50
|
+
}
|
|
51
|
+
async function checkMissingRule({ api, list }, _optional = {}) {
|
|
52
|
+
await list.rejects({ run: () => api.getRedirect(MISSING), name: 'missing rule rejects' });
|
|
53
|
+
await list.rejects({ run: () => api.getRedirectHitStats(MISSING), name: 'missing hits differs from zero hits' });
|
|
54
|
+
await list.rejects({ run: () => api.updateRedirect(MISSING, { status: 'disabled' }), name: 'missing update rejects' });
|
|
55
|
+
await list.rejects({ run: () => api.tombstoneRedirect(MISSING), name: 'missing tombstone rejects' });
|
|
56
|
+
}
|
|
57
|
+
async function checkReferenceRegexRejection({ api, list, input, id }, _optional = {}) {
|
|
58
|
+
await list.rejects({ run: () => api.createRedirect({ ...input, fromPattern: '/regex-conformance', matchType: 'regex' }), name: 'reference regex create is loudly rejected' });
|
|
59
|
+
await list.rejects({ run: () => api.updateRedirect({ id }, { matchType: 'regex' }), name: 'reference regex update is loudly rejected' });
|
|
60
|
+
}
|
|
61
|
+
/** With reference rejection, a regex item sits between two valid ones to prove per-item partial success. */
|
|
62
|
+
async function checkImport({ api, list, input }, { referenceRegexRejection }) {
|
|
49
63
|
const batch = await api.importRedirects({ rules: [
|
|
50
64
|
{ ...input, fromPattern: '/import-conformance-a' },
|
|
51
65
|
...(referenceRegexRejection ? [{ ...input, fromPattern: '/import-conformance-regex', matchType: 'regex' }] : []),
|
|
52
66
|
{ ...input, fromPattern: '/import-conformance-c' },
|
|
53
67
|
] });
|
|
54
68
|
if (referenceRegexRejection) {
|
|
55
|
-
|
|
56
|
-
|
|
69
|
+
list.check({ ok: batch.created.length === 2 && batch.failed.length === 1 && batch.failed[0]?.index === 1, name: 'middle failure preserves both successful siblings' });
|
|
70
|
+
list.check({ ok: batch.created.every(row => row.fromPattern !== '/import-conformance-regex'), name: 'invalid item is never reported created' });
|
|
57
71
|
}
|
|
58
72
|
else {
|
|
59
|
-
|
|
73
|
+
list.check({ ok: batch.created.length === 2 && batch.failed.length === 0, name: 'valid import batch succeeds' });
|
|
60
74
|
}
|
|
61
|
-
|
|
75
|
+
list.check({ ok: batch.created.every(row => row.source === 'import'), name: 'import provenance is stamped by the backend' });
|
|
62
76
|
for (const row of batch.created)
|
|
63
77
|
await api.tombstoneRedirect({ id: row.id });
|
|
64
|
-
|
|
78
|
+
}
|
|
79
|
+
/** Destructive framework-free checklist: use an isolated backend with no production data.
|
|
80
|
+
* Validates core soft-delete, snapshots, lazy-read data and per-item partial import contracts.
|
|
81
|
+
* Reference regex rejection probes can be disabled for a host implementing the port's open vocabulary.
|
|
82
|
+
* @example await runRedirectsApiConformance({ api: createMemoryRedirectsApi({}, {}) }, {});
|
|
83
|
+
*/
|
|
84
|
+
export async function runRedirectsApiConformance({ api }, { referenceRegexRejection = true } = {}) {
|
|
85
|
+
const list = createChecklist();
|
|
86
|
+
const input = { matchType: 'exact', fromPattern: '/redirects-conformance-a', toTarget: '/redirects-conformance-b', statusCode: 301 };
|
|
87
|
+
const { created, before } = await checkCreateAndRead({ api, list, input });
|
|
88
|
+
await checkUpdate({ api, list, created, before });
|
|
89
|
+
await checkSoftDelete({ api, list, id: created.id });
|
|
90
|
+
await checkMissingRule({ api, list });
|
|
91
|
+
if (referenceRegexRejection)
|
|
92
|
+
await checkReferenceRegexRejection({ api, list, input, id: created.id });
|
|
93
|
+
await checkImport({ api, list, input }, { referenceRegexRejection });
|
|
94
|
+
return Object.freeze(list.passed);
|
|
65
95
|
}
|
|
66
96
|
//# sourceMappingURL=redirects-api.conformance.js.map
|
|
@@ -9,7 +9,7 @@ export declare function useImportRedirectsForm({ api }: {
|
|
|
9
9
|
locale?: string;
|
|
10
10
|
}): {
|
|
11
11
|
raw: string;
|
|
12
|
-
setRaw: (
|
|
12
|
+
setRaw: (next: string) => void | undefined;
|
|
13
13
|
error: string | null;
|
|
14
14
|
result: import("../../index.js").AdminRedirectImportResult | null;
|
|
15
15
|
importing: boolean;
|
|
@@ -5,6 +5,13 @@ import { useController } from '../../../core/react/use-controller.js';
|
|
|
5
5
|
import { createImportRedirectsController } from '../../controllers/import-redirects.controller.js';
|
|
6
6
|
import { KEYS } from '../../rules.js';
|
|
7
7
|
import { translateRedirectsEn } from '../../messages.en.js';
|
|
8
|
+
/** Before the controller mounts there is no snapshot: an empty form. A parse error wins over a
|
|
9
|
+
* request error, so a later parse failure replaces an earlier request failure instead of stacking. */
|
|
10
|
+
function deriveFormState({ snapshot, requestError }, _optional = {}) {
|
|
11
|
+
return { raw: snapshot?.raw ?? '',
|
|
12
|
+
error: snapshot?.parseError ?? (requestError ? describeApiError({ e: requestError, fallback: 'Import failed' }) : null),
|
|
13
|
+
result: snapshot?.result ?? null };
|
|
14
|
+
}
|
|
8
15
|
/** A resolved 207 is a result, including failed items; only transport failures enter error state. */
|
|
9
16
|
export function useImportRedirectsForm({ api }, { t = translateRedirectsEn, locale = 'en' } = {}) {
|
|
10
17
|
const mutation = useFetchMutation({ run: ({ input: rules }) => api.importRedirects({ rules }) }, { invalidates: [KEYS.list] });
|
|
@@ -18,8 +25,8 @@ export function useImportRedirectsForm({ api }, { t = translateRedirectsEn, loca
|
|
|
18
25
|
e.preventDefault();
|
|
19
26
|
return controller?.submit() ?? Promise.resolve();
|
|
20
27
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
28
|
+
const { raw, error, result } = deriveFormState({ snapshot, requestError: mutation.error });
|
|
29
|
+
return { raw, setRaw: (next) => controller?.setRaw({ raw: next }),
|
|
30
|
+
error, result, importing: mutation.status === 'pending', submit, t, locale };
|
|
24
31
|
}
|
|
25
32
|
//# sourceMappingURL=use-import-redirects-form.hooks.js.map
|
|
@@ -44,7 +44,7 @@ export declare function useWiredImportRedirectsForm({ t, locale }: {
|
|
|
44
44
|
createdText: string;
|
|
45
45
|
failedLabel: (failure: AdminRedirectImportFailure) => string;
|
|
46
46
|
raw: string;
|
|
47
|
-
setRaw: (
|
|
47
|
+
setRaw: (next: string) => void | undefined;
|
|
48
48
|
error: string | null;
|
|
49
49
|
result: import("../../index.js").AdminRedirectImportResult | null;
|
|
50
50
|
importing: boolean;
|
|
@@ -3,8 +3,17 @@ function summary(row, kind) {
|
|
|
3
3
|
return Object.freeze({ kind, id: row.id, providerId: row.providerId ?? '', label: row.label, configured: row.configured, isDefault: row.isDefault ?? false, createdAt: row.createdAt, updatedAt: row.updatedAt,
|
|
4
4
|
...(row.accountLabel !== undefined ? { accountLabel: row.accountLabel } : {}), ...(kind === 'custom' ? { category: row.category ?? 'general', baseUrl: row.baseUrl ?? '', additionalHosts: Object.freeze([...(row.additionalHosts ?? [])]), ...(row.username ? { username: row.username } : {}) } : {}) });
|
|
5
5
|
}
|
|
6
|
+
function trimBase({ workspacePath }, _optional = {}) { return workspacePath.replace(/\/$/, ''); }
|
|
7
|
+
function descriptorExtras({ credential }, _optional = {}) {
|
|
8
|
+
const help = credential?.userHelp ?? credential?.help;
|
|
9
|
+
return { ...(credential?.tokenPageUrl ? { tokenPageUrl: credential.tokenPageUrl } : {}), ...(help ? { help } : {}) };
|
|
10
|
+
}
|
|
11
|
+
function providerFromDescriptor({ kind, descriptor: d }, _optional = {}) {
|
|
12
|
+
const c = d.credential;
|
|
13
|
+
return { kind, id: d.id, label: d.label, vendorLabel: c?.vendorLabel ?? d.label, category: kind === 'publish' ? 'hosting' : 'source-control', tokenField: c?.tokenField ?? 'token', fields: c?.fields ?? [], ...descriptorExtras({ credential: c }) };
|
|
14
|
+
}
|
|
6
15
|
export function createHttpSecurityApi({ transport, workspacePath }, _optional = {}) {
|
|
7
|
-
const base =
|
|
16
|
+
const base = trimBase({ workspacePath });
|
|
8
17
|
function store(kind, suffix) {
|
|
9
18
|
const path = `${base}/system/${suffix}/credentials`;
|
|
10
19
|
async function mutate(method, target, body, options) { options.signal?.throwIfAborted(); const r = await transport.request({ method, path: target, body }, options); return summary(r.credential, kind); }
|
|
@@ -22,24 +31,28 @@ export function createHttpSecurityApi({ transport, workspacePath }, _optional =
|
|
|
22
31
|
transport.request({ method: 'GET', path: `${base}/system/publish-targets` }, options),
|
|
23
32
|
transport.request({ method: 'GET', path: `${base}/system/source-control/providers` }, options),
|
|
24
33
|
]);
|
|
25
|
-
|
|
26
|
-
return [...publish.targets.filter(d => d.credential).map(d => map('publish', d)), ...source.providers.map(d => map('source-control', d))];
|
|
34
|
+
return [...publish.targets.filter(d => d.credential).map(descriptor => providerFromDescriptor({ kind: 'publish', descriptor })), ...source.providers.map(descriptor => providerFromDescriptor({ kind: 'source-control', descriptor }))];
|
|
27
35
|
},
|
|
28
36
|
publish: store('publish', 'publish'), sourceControl: store('source-control', 'source-control'), custom: store('custom', 'custom'),
|
|
29
37
|
};
|
|
30
38
|
}
|
|
39
|
+
/** Only non-secret settings are echoed back; apiKey is sent solely for the entry being replaced. */
|
|
40
|
+
function mediaEntry({ entry }, { apiKey } = {}) {
|
|
41
|
+
return { ...(entry.baseUrl !== undefined ? { baseUrl: entry.baseUrl } : {}), ...(entry.model !== undefined ? { model: entry.model } : {}), ...(apiKey !== undefined ? { apiKey } : {}) };
|
|
42
|
+
}
|
|
31
43
|
/** Replacing the ENTIRE map requires sibling settings unchanged. Blank apiKey preserves their stored keys. */
|
|
32
44
|
function rebuildMedia(current, id, token) {
|
|
33
45
|
const next = {};
|
|
34
46
|
for (const [key, entry] of Object.entries(current)) {
|
|
35
|
-
if (key
|
|
36
|
-
|
|
37
|
-
|
|
47
|
+
if (key !== id)
|
|
48
|
+
next[key] = mediaEntry({ entry });
|
|
49
|
+
else if (token !== undefined)
|
|
50
|
+
next[key] = mediaEntry({ entry }, { apiKey: token });
|
|
38
51
|
}
|
|
39
52
|
return next;
|
|
40
53
|
}
|
|
41
54
|
export function createHttpOtherCredentials({ transport, workspacePath }, { mediaLabels = {} } = {}) {
|
|
42
|
-
const base =
|
|
55
|
+
const base = trimBase({ workspacePath });
|
|
43
56
|
const singles = { 'site-assistant': { path: '/assistant/site-credential', label: 'Assistant model key' }, 'admin-byok': { path: '/assistant/execution-credential', label: 'Admin model key' } };
|
|
44
57
|
async function request(path, method, options, body) { options.signal?.throwIfAborted(); return transport.request({ path: `${base}${path}`, method, ...(body !== undefined ? { body } : {}) }, options); }
|
|
45
58
|
return {
|
|
@@ -79,15 +92,17 @@ export function createHttpOtherCredentials({ transport, workspacePath }, { media
|
|
|
79
92
|
},
|
|
80
93
|
};
|
|
81
94
|
}
|
|
95
|
+
/** Explicit projection: receivers carry signing secrets the UI must never hold. */
|
|
96
|
+
function webhookTargets({ webhooks }, _optional = {}) { return webhooks.map(w => ({ label: w.label, targetUrl: w.targetUrl })); }
|
|
82
97
|
export function createHttpRootKey({ transport, workspacePath }, _optional = {}) {
|
|
83
|
-
const path = `${
|
|
98
|
+
const path = `${trimBase({ workspacePath })}/system/site-token`;
|
|
84
99
|
async function request(suffix, method, options, body) { options.signal?.throwIfAborted(); return transport.request({ path: `${path}${suffix}`, method, ...(body !== undefined ? { body } : {}) }, options); }
|
|
85
|
-
function result(r) { return { outcome: r.outcome, fingerprint: r.fingerprint, keyFilePath: r.keyFilePath, runtimeMode: r.runtimeMode, ...(r.resealed !== undefined ? { resealed: r.resealed } : {}), ...(r.discarded !== undefined ? { discarded: r.discarded } : {}), ...(r.kept !== undefined ? { kept: r.kept } : {}), ...(r.restorePointId ? { restorePointId: r.restorePointId } : {}), ...(r.affectedWebhooks ? { affectedWebhooks:
|
|
100
|
+
function result(r) { return { outcome: r.outcome, fingerprint: r.fingerprint, keyFilePath: r.keyFilePath, runtimeMode: r.runtimeMode, ...(r.resealed !== undefined ? { resealed: r.resealed } : {}), ...(r.discarded !== undefined ? { discarded: r.discarded } : {}), ...(r.kept !== undefined ? { kept: r.kept } : {}), ...(r.restorePointId ? { restorePointId: r.restorePointId } : {}), ...(r.affectedWebhooks ? { affectedWebhooks: webhookTargets({ webhooks: r.affectedWebhooks }) } : {}) }; }
|
|
86
101
|
return {
|
|
87
102
|
async status(_r, o = {}) { const r = await request('', 'GET', o); return { active: r.active, source: r.source, state: r.state, keyFilePath: r.keyFilePath, runtimeMode: r.runtimeMode, ...(r.fingerprint ? { fingerprint: r.fingerprint } : {}), ...(r.invalid !== undefined ? { invalid: r.invalid } : {}) }; },
|
|
88
103
|
async generate(_r, o = {}) { return result(await request('/generate', 'POST', o)); },
|
|
89
104
|
async importToken({ token }, o = {}) { return result(await request('/import', 'POST', o, { token })); },
|
|
90
|
-
async previewStartFresh(_r, o = {}) { const r = await request('/start-fresh', 'GET', o); return { removes: r.removes, detail: r.detail, runtimeMode: r.runtimeMode, affectedWebhooks:
|
|
105
|
+
async previewStartFresh(_r, o = {}) { const r = await request('/start-fresh', 'GET', o); return { removes: r.removes, detail: r.detail, runtimeMode: r.runtimeMode, affectedWebhooks: webhookTargets({ webhooks: r.affectedWebhooks }) }; },
|
|
91
106
|
async startFresh({ confirm }, o = {}) { return result(await request('/start-fresh', 'POST', o, { confirm })); },
|
|
92
107
|
};
|
|
93
108
|
}
|