@jini-ai/admin 0.4.11 → 0.4.13
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 +9 -0
- 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 +74 -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/ports/seo.d.ts +20 -0
- 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/adapters/http.js +5 -0
- package/dist/seo/controllers/seo.controller.js +4 -2
- package/dist/seo/messages.en.d.ts +15 -10
- package/dist/seo/messages.en.js +15 -10
- package/dist/seo/models.d.ts +11 -0
- package/dist/seo/react/components/MediaRefField.d.ts +17 -5
- package/dist/seo/react/components/MediaRefField.js +31 -4
- package/dist/seo/react/hooks/MediaRefField.hooks.js +12 -2
- package/dist/seo/react/hooks/SeoPage.hooks.d.ts +1 -0
- package/dist/seo/react/hooks/SeoPage.hooks.js +2 -2
- package/dist/seo/react/hooks/SeoQuery.hooks.d.ts +1 -0
- package/dist/seo/react/hooks/SeoQuery.hooks.js +1 -0
- package/dist/seo/react/hooks/use-entry-picker.hooks.js +7 -2
- package/dist/seo/react/hooks/use-seo-entry-panel.hooks.js +13 -2
- package/dist/seo/react/hooks/use-share-image-warning.hooks.d.ts +4 -1
- package/dist/seo/react/hooks/use-share-image-warning.hooks.js +48 -33
- package/dist/seo/react/index.d.ts +45 -30
- package/dist/seo/react/pages/SeoPage.d.ts +6 -4
- package/dist/seo/react/pages/SeoPage.js +51 -29
- package/dist/seo/rules.d.ts +91 -0
- package/dist/seo/rules.js +161 -6
- package/dist/seo/seo.module.d.ts +15 -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 +19 -10
- 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
|
@@ -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
|
}
|
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
import { START_FRESH_CONFIRMATION } from '../rules.js';
|
|
2
|
+
function customUsernameField({ patch }, { initial } = {}) {
|
|
3
|
+
const username = patch.username !== undefined ? patch.username : patch.connection?.username ?? initial?.username;
|
|
4
|
+
return username ? { username } : {};
|
|
5
|
+
}
|
|
6
|
+
function customHosts({ patch }, { initial } = {}) { return Object.freeze([...(patch.additionalHosts ?? initial?.additionalHosts ?? [])]); }
|
|
7
|
+
function customProjection({ patch }, { initial } = {}) {
|
|
8
|
+
return { category: patch.category ?? initial?.category ?? 'general', baseUrl: patch.baseUrl ?? initial?.baseUrl ?? '', additionalHosts: customHosts({ patch }, { initial }), ...customUsernameField({ patch }, { initial }) };
|
|
9
|
+
}
|
|
2
10
|
/** Fake backend: retains only read models, never credential material. */
|
|
3
11
|
export function createMemorySecurityApi({ providers = [], rows = [] }, _optional = {}) {
|
|
4
12
|
let items = rows.map(r => Object.freeze({ ...r }));
|
|
5
13
|
let sequence = 0;
|
|
6
14
|
function store(kind) {
|
|
7
|
-
function
|
|
15
|
+
function isDefaultFor({ patch, providerId }, { initial } = {}) {
|
|
16
|
+
if (kind === 'custom')
|
|
17
|
+
return false;
|
|
18
|
+
return patch.isDefault ?? initial?.isDefault ?? !items.some(r => r.kind === kind && r.providerId === providerId);
|
|
19
|
+
}
|
|
20
|
+
/** The caller resolves the label: create requires one, update falls back to the saved label. */
|
|
21
|
+
function project({ id, patch, providerId, label }, { initial } = {}) {
|
|
8
22
|
const now = new Date().toISOString();
|
|
9
|
-
return Object.freeze({ kind, id, providerId, label
|
|
10
|
-
...(kind === 'custom' ? {
|
|
23
|
+
return Object.freeze({ kind, id, providerId, label, configured: true, isDefault: isDefaultFor({ patch, providerId }, { initial }), createdAt: initial?.createdAt ?? now, updatedAt: now,
|
|
24
|
+
...(kind === 'custom' ? customProjection({ patch }, { initial }) : {}),
|
|
11
25
|
});
|
|
12
26
|
}
|
|
13
27
|
function check(label, providerId, id) { if (items.some(r => r.kind === kind && r.id !== id && (kind === 'custom' || r.providerId === providerId) && r.label.toLowerCase() === label.toLowerCase()))
|
|
@@ -21,9 +35,9 @@ export function createMemorySecurityApi({ providers = [], rows = [] }, _optional
|
|
|
21
35
|
return {
|
|
22
36
|
async list(_r, o = {}) { o.signal?.throwIfAborted(); return Object.freeze(items.filter(r => r.kind === kind)); },
|
|
23
37
|
async create(input, o = {}) { o.signal?.throwIfAborted(); if (!Object.values(input.connection).some(v => v.trim()) || !input.label.trim())
|
|
24
|
-
throw new Error('Incomplete credential'); const providerId = kind === 'custom' ? '' : input.connection.providerId ?? ''; check(input.label, providerId); return insert(project(`credential-${++sequence}`, input, providerId)); },
|
|
38
|
+
throw new Error('Incomplete credential'); const providerId = kind === 'custom' ? '' : input.connection.providerId ?? ''; check(input.label, providerId); return insert(project({ id: `credential-${++sequence}`, patch: input, providerId, label: input.label })); },
|
|
25
39
|
async update({ id, patch }, o = {}) { o.signal?.throwIfAborted(); const old = items.find(r => r.id === id && r.kind === kind); if (!old)
|
|
26
|
-
throw new Error('Credential not found');
|
|
40
|
+
throw new Error('Credential not found'); const label = patch.label ?? old.label; check(label, old.providerId, id); return insert(project({ id, patch, providerId: old.providerId, label }, { initial: old })); },
|
|
27
41
|
async remove({ id }, o = {}) { o.signal?.throwIfAborted(); const old = items.find(r => r.id === id && r.kind === kind); items = items.filter(r => !(r.id === id && r.kind === kind)); if (old?.isDefault) {
|
|
28
42
|
const next = items.filter(r => r.kind === kind && r.providerId === old.providerId).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
|
|
29
43
|
if (next)
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { createControllerStore } from '../../core/module/controller-store.js';
|
|
2
|
-
import { blankCredentialDraft, buildCredentialPatch, credentialStore, validateCredentialDraft, writePermission, credentialLabel } from '../rules.js';
|
|
2
|
+
import { blankCredentialDraft, buildCredentialPatch, credentialCategory, credentialStore, validateCredentialDraft, writePermission, credentialLabel } from '../rules.js';
|
|
3
|
+
/** An edit draft never carries the saved secret: token and extra values start blank. */
|
|
4
|
+
function draftFromRow({ row, providers }, _optional = {}) {
|
|
5
|
+
return { ...blankCredentialDraft({ kind: row.kind, providerId: row.providerId }), id: row.id, label: credentialLabel({ row, providers }), category: credentialCategory({ row }), baseUrl: row.baseUrl ?? '', additionalHosts: row.additionalHosts?.join('\n') ?? '', username: row.username ?? '' };
|
|
6
|
+
}
|
|
3
7
|
export function createAccessTokensController({ api, permissions = [] }, _optional = {}) {
|
|
4
8
|
const grants = new Set(permissions);
|
|
5
9
|
const store = createControllerStore({ initial: { rows: [], providers: [], draft: null, loading: false, saving: false, error: null, errors: {}, query: '', category: 'all' } });
|
|
@@ -43,7 +47,7 @@ export function createAccessTokensController({ api, permissions = [] }, _optiona
|
|
|
43
47
|
const k = row?.kind ?? kind ?? 'custom';
|
|
44
48
|
if (!allowed(k) || store.getSnapshot().saving)
|
|
45
49
|
return;
|
|
46
|
-
const draft = row ?
|
|
50
|
+
const draft = row ? draftFromRow({ row, providers: store.getSnapshot().providers }) : blankCredentialDraft({ kind: k, ...(providerId ? { providerId } : {}) });
|
|
47
51
|
store.set({ patch: { draft, error: null } });
|
|
48
52
|
}
|
|
49
53
|
async function save(_required, _optional = {}) {
|
|
@@ -57,44 +61,41 @@ export function createAccessTokensController({ api, permissions = [] }, _optiona
|
|
|
57
61
|
}
|
|
58
62
|
const patch = buildCredentialPatch({ draft, providers: state.providers });
|
|
59
63
|
const target = credentialStore({ api, kind: draft.kind });
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
// First-create default assignment can change siblings; the server owns that invariant.
|
|
66
|
-
await load({});
|
|
67
|
-
return !store.signal.aborted;
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
store.set({ patch: { error: 'Unable to save credential' } });
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
finally {
|
|
74
|
-
store.set({ patch: { saving: false } });
|
|
75
|
-
}
|
|
64
|
+
return mutate({ failure: 'Unable to save credential', run: async () => {
|
|
65
|
+
const row = draft.id ? await target.update({ id: draft.id, patch }, { signal: store.signal }) : await target.create(patch, { signal: store.signal });
|
|
66
|
+
// Clear accepted secrets before any later read can fail. A successful save must never leave a saved secret visible.
|
|
67
|
+
store.set({ patch: { draft: null, rows: Object.freeze([...store.getSnapshot().rows.filter(r => !(r.kind === row.kind && r.id === row.id)), row]) } });
|
|
68
|
+
} });
|
|
76
69
|
}
|
|
77
70
|
async function write({ row, remove }) {
|
|
78
71
|
if (!allowed(row.kind) || store.getSnapshot().saving)
|
|
79
72
|
return false;
|
|
73
|
+
return mutate({ failure: 'Unable to update credential', run: async () => {
|
|
74
|
+
const target = credentialStore({ api, kind: row.kind });
|
|
75
|
+
if (remove)
|
|
76
|
+
await target.remove({ id: row.id }, { signal: store.signal });
|
|
77
|
+
else
|
|
78
|
+
await target.update({ id: row.id, patch: { isDefault: true } }, { signal: store.signal });
|
|
79
|
+
} });
|
|
80
|
+
}
|
|
81
|
+
/** Shared write lifecycle: one write at a time, a fixed failure message, and a refetch after success. */
|
|
82
|
+
async function mutate({ run, failure }, _optional = {}) {
|
|
80
83
|
store.set({ patch: { saving: true, error: null } });
|
|
84
|
+
let ok = false;
|
|
81
85
|
try {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
await target.remove({ id: row.id }, { signal: store.signal });
|
|
85
|
-
else
|
|
86
|
-
await target.update({ id: row.id, patch: { isDefault: true } }, { signal: store.signal });
|
|
86
|
+
await run();
|
|
87
|
+
// First-create default assignment can change siblings; the server owns that invariant.
|
|
87
88
|
// Remove/default can promote another row. Refetch rather than duplicating server promotion rules.
|
|
88
89
|
await load({});
|
|
89
|
-
|
|
90
|
+
ok = !store.signal.aborted;
|
|
90
91
|
}
|
|
91
92
|
catch {
|
|
92
|
-
store.set({ patch: { error:
|
|
93
|
-
return false;
|
|
93
|
+
store.set({ patch: { error: failure } });
|
|
94
94
|
}
|
|
95
95
|
finally {
|
|
96
96
|
store.set({ patch: { saving: false } });
|
|
97
97
|
}
|
|
98
|
+
return ok;
|
|
98
99
|
}
|
|
99
100
|
return {
|
|
100
101
|
getSnapshot: store.getSnapshot, subscribe: store.subscribe, load, begin, save,
|
|
@@ -40,6 +40,8 @@ export function createOtherCredentialsController({ api, permissions = [] }, _opt
|
|
|
40
40
|
if (store.signal.aborted)
|
|
41
41
|
return false;
|
|
42
42
|
store.set({ patch: { saving: true } });
|
|
43
|
+
// One exit after `finally`: a catch that returned left a fall-through path nothing could take.
|
|
44
|
+
let saved = false;
|
|
43
45
|
try {
|
|
44
46
|
if (remove)
|
|
45
47
|
await api.remove({ store: row.store, id: row.id }, { signal: store.signal });
|
|
@@ -49,15 +51,15 @@ export function createOtherCredentialsController({ api, permissions = [] }, _opt
|
|
|
49
51
|
delete drafts[`${row.store}:${row.id}`];
|
|
50
52
|
store.set({ patch: { drafts } });
|
|
51
53
|
await read(row.store, generation);
|
|
52
|
-
|
|
54
|
+
saved = !store.signal.aborted;
|
|
53
55
|
}
|
|
54
56
|
catch {
|
|
55
57
|
store.set({ patch: { errors: { ...store.getSnapshot().errors, [row.store]: 'Unable to update credential' } } });
|
|
56
|
-
return false;
|
|
57
58
|
}
|
|
58
59
|
finally {
|
|
59
60
|
store.set({ patch: { saving: false } });
|
|
60
61
|
}
|
|
62
|
+
return saved;
|
|
61
63
|
});
|
|
62
64
|
lane = run.then(() => undefined);
|
|
63
65
|
return run;
|