@actuate-media/cms-admin 0.67.0 → 0.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +59 -15
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts +2 -0
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts.map +1 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js +119 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts +2 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.js +88 -0
- package/dist/__tests__/lib/post-types-service.test.js.map +1 -0
- package/dist/__tests__/router/query-string.test.d.ts +2 -0
- package/dist/__tests__/router/query-string.test.d.ts.map +1 -0
- package/dist/__tests__/router/query-string.test.js +44 -0
- package/dist/__tests__/router/query-string.test.js.map +1 -0
- package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
- package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
- package/dist/__tests__/views/forms-list.render.test.js +25 -6
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.js +105 -0
- package/dist/__tests__/views/page-templates.render.test.js.map +1 -0
- package/dist/__tests__/views/post-type-editor.render.test.d.ts +2 -0
- package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
- package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
- package/dist/__tests__/views/security-settings.render.test.js +85 -4
- package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.d.ts +2 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.js +91 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/collections/EntryDetailPane.d.ts +10 -0
- package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
- package/dist/components/collections/EntryDetailPane.js +92 -0
- package/dist/components/collections/EntryDetailPane.js.map +1 -0
- package/dist/components/forms/FieldsPanel.js +1 -1
- package/dist/components/forms/FieldsPanel.js.map +1 -1
- package/dist/components/forms/FormSchemaPane.d.ts +8 -0
- package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
- package/dist/components/forms/FormSchemaPane.js +95 -0
- package/dist/components/forms/FormSchemaPane.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.d.ts +9 -0
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
- package/dist/components/seo/SeoEditorPane.js +175 -0
- package/dist/components/seo/SeoEditorPane.js.map +1 -0
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/components/ui/Button.d.ts +1 -1
- package/dist/components/ui/StatusBadge.d.ts +1 -1
- package/dist/hooks/useIdleLogout.d.ts +33 -0
- package/dist/hooks/useIdleLogout.d.ts.map +1 -0
- package/dist/hooks/useIdleLogout.js +99 -0
- package/dist/hooks/useIdleLogout.js.map +1 -0
- package/dist/lib/api.d.ts +1 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +7 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/post-types-service.d.ts +44 -0
- package/dist/lib/post-types-service.d.ts.map +1 -0
- package/dist/lib/post-types-service.js +83 -0
- package/dist/lib/post-types-service.js.map +1 -0
- package/dist/router/index.d.ts +7 -0
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +23 -4
- package/dist/router/index.js.map +1 -1
- package/dist/views/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +23 -4
- package/dist/views/CollectionList.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +25 -21
- package/dist/views/Forms.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts +4 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +27 -5
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/Posts/PostTypeEditor.d.ts +16 -0
- package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
- package/dist/views/Posts/PostTypeEditor.js +159 -0
- package/dist/views/Posts/PostTypeEditor.js.map +1 -0
- package/dist/views/Posts/PostTypesView.d.ts +3 -2
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js +1 -1
- package/dist/views/Posts/PostTypesView.js.map +1 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +2 -1
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/Settings.d.ts +4 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +4 -3
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/Users.d.ts +4 -1
- package/dist/views/Users.d.ts.map +1 -1
- package/dist/views/Users.js +22 -2
- package/dist/views/Users.js.map +1 -1
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts +20 -0
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
- package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
- package/dist/views/page-builder/PageBuilder.js +51 -2
- package/dist/views/page-builder/PageBuilder.js.map +1 -1
- package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
- package/dist/views/page-builder/PageTemplates.js +113 -6
- package/dist/views/page-builder/PageTemplates.js.map +1 -1
- package/dist/views/seo/ContentTab.d.ts.map +1 -1
- package/dist/views/seo/ContentTab.js +22 -6
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
- package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
- package/dist/views/settings/AccessProtectionCard.js +13 -10
- package/dist/views/settings/AccessProtectionCard.js.map +1 -1
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +5 -4
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
- package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
- package/dist/views/settings/SessionPolicyCard.js +12 -5
- package/dist/views/settings/SessionPolicyCard.js.map +1 -1
- package/dist/views/settings/useSecuritySettings.d.ts +13 -1
- package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
- package/dist/views/settings/useSecuritySettings.js +47 -4
- package/dist/views/settings/useSecuritySettings.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +88 -18
- package/src/__tests__/hooks/useIdleLogout.test.tsx +188 -0
- package/src/__tests__/lib/post-types-service.test.ts +112 -0
- package/src/__tests__/router/query-string.test.ts +54 -0
- package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
- package/src/__tests__/views/forms-list.render.test.tsx +34 -6
- package/src/__tests__/views/page-builder-sections-panel.render.test.tsx +138 -0
- package/src/__tests__/views/page-templates.render.test.tsx +142 -0
- package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
- package/src/__tests__/views/security-settings.render.test.tsx +111 -4
- package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
- package/src/__tests__/views/users-selected-deeplink.render.test.tsx +102 -0
- package/src/components/collections/EntryDetailPane.tsx +182 -0
- package/src/components/forms/FieldsPanel.tsx +3 -1
- package/src/components/forms/FormSchemaPane.tsx +192 -0
- package/src/components/seo/SeoEditorPane.tsx +533 -0
- package/src/hooks/useIdleLogout.ts +129 -0
- package/src/lib/api.ts +8 -2
- package/src/lib/post-types-service.ts +129 -0
- package/src/router/index.ts +25 -4
- package/src/views/CollectionList.tsx +158 -110
- package/src/views/Forms.tsx +154 -126
- package/src/views/MediaBrowser.tsx +28 -5
- package/src/views/Posts/PostTypeEditor.tsx +568 -0
- package/src/views/Posts/PostTypesView.tsx +5 -4
- package/src/views/SEO.tsx +2 -1
- package/src/views/Settings.tsx +7 -2
- package/src/views/Users.tsx +23 -2
- package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
- package/src/views/page-builder/PageBuilder.tsx +69 -2
- package/src/views/page-builder/PageTemplates.tsx +340 -57
- package/src/views/seo/ContentTab.tsx +113 -80
- package/src/views/settings/AccessProtectionCard.tsx +83 -53
- package/src/views/settings/SecurityTab.tsx +25 -3
- package/src/views/settings/SessionPolicyCard.tsx +45 -9
- package/src/views/settings/useSecuritySettings.ts +78 -22
- package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
- package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
- package/dist/components/forms/FormSchemaDrawer.js +0 -99
- package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.js +0 -172
- package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
- package/src/components/forms/FormSchemaDrawer.tsx +0 -192
- package/src/components/seo/SeoEditorDrawer.tsx +0 -529
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useSecuritySettings.d.ts","sourceRoot":"","sources":["../../../src/views/settings/useSecuritySettings.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,kBAAkB,EAExB,MAAM,yCAAyC,CAAA;AAEhD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAA;AAE9D,yEAAyE;AACzE,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,OAAO,CAAA;IACpB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,qBAAqB,EAAE,MAAM,CAAA;
|
|
1
|
+
{"version":3,"file":"useSecuritySettings.d.ts","sourceRoot":"","sources":["../../../src/views/settings/useSecuritySettings.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,kBAAkB,EAExB,MAAM,yCAAyC,CAAA;AAEhD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAA;AAE9D,yEAAyE;AACzE,MAAM,WAAW,YAAY;IAC3B,WAAW,EAAE,OAAO,CAAA;IACpB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,qBAAqB,EAAE,MAAM,CAAA;IAC7B,YAAY,EAAE,MAAM,CAAA;IACpB,iBAAiB,EAAE,OAAO,CAAA;IAC1B,0DAA0D;IAC1D,eAAe,EAAE,MAAM,CAAA;IACvB,mBAAmB,EAAE,OAAO,CAAA;CAC7B;AAED,KAAK,SAAS,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,GAAG,OAAO,CAAA;AAYxD,6EAA6E;AAC7E,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAOzD;AA6BD,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,OAAO,CAAA;IAChB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,IAAI,EAAE,kBAAkB,GAAG,IAAI,CAAA;IAC/B,IAAI,EAAE,YAAY,CAAA;IAClB,QAAQ,EAAE,CAAC,CAAC,SAAS,MAAM,YAAY,EAAE,GAAG,EAAE,CAAC,EAAE,KAAK,EAAE,YAAY,CAAC,CAAC,CAAC,KAAK,IAAI,CAAA;IAChF,KAAK,EAAE,OAAO,CAAA;IACd,MAAM,EAAE,eAAe,EAAE,CAAA;IACzB,SAAS,EAAE,OAAO,CAAA;IAClB,OAAO,EAAE,OAAO,CAAA;IAChB,SAAS,EAAE,SAAS,CAAA;IACpB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,IAAI,EAAE,CAAC,OAAO,CAAC,EAAE;QAAE,cAAc,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/D,KAAK,EAAE,MAAM,IAAI,CAAA;IACjB,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,0EAA0E;IAC1E,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,oBAAoB,EAAE,MAAM,IAAI,CAAA;IAChC,WAAW,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,OAAO,CAAA;IAClD,sBAAsB,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;CACrD;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,GAAG,mBAAmB,CAuJzE"}
|
|
@@ -6,18 +6,44 @@ const EMPTY_FORM = {
|
|
|
6
6
|
mfaRequired: false,
|
|
7
7
|
mfaGracePeriodDays: 7,
|
|
8
8
|
maxConcurrentSessions: 5,
|
|
9
|
+
timeoutHours: 168,
|
|
10
|
+
idleLogoutEnabled: false,
|
|
11
|
+
ipAllowlistText: '',
|
|
12
|
+
auditLoggingEnabled: true,
|
|
9
13
|
};
|
|
14
|
+
/** Split the textarea into trimmed, de-duplicated entries (one per line). */
|
|
15
|
+
export function parseAllowlistText(text) {
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const line of text.split('\n')) {
|
|
18
|
+
const entry = line.trim();
|
|
19
|
+
if (entry && !out.includes(entry))
|
|
20
|
+
out.push(entry);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
10
24
|
function formFromSettings(s) {
|
|
11
25
|
return {
|
|
12
26
|
mfaRequired: s.mfa.required,
|
|
13
27
|
mfaGracePeriodDays: s.mfa.gracePeriodDays,
|
|
14
28
|
maxConcurrentSessions: s.session.maxConcurrentSessions,
|
|
29
|
+
timeoutHours: s.session.timeoutHours ?? 168,
|
|
30
|
+
idleLogoutEnabled: s.session.idleLogoutEnabled ?? false,
|
|
31
|
+
ipAllowlistText: (s.access?.ipAllowlist ?? []).join('\n'),
|
|
32
|
+
auditLoggingEnabled: s.access?.auditLoggingEnabled ?? true,
|
|
15
33
|
};
|
|
16
34
|
}
|
|
17
35
|
function formToSettings(f) {
|
|
18
36
|
return {
|
|
19
37
|
mfa: { required: f.mfaRequired, gracePeriodDays: f.mfaGracePeriodDays },
|
|
20
|
-
session: {
|
|
38
|
+
session: {
|
|
39
|
+
maxConcurrentSessions: f.maxConcurrentSessions,
|
|
40
|
+
timeoutHours: f.timeoutHours,
|
|
41
|
+
idleLogoutEnabled: f.idleLogoutEnabled,
|
|
42
|
+
},
|
|
43
|
+
access: {
|
|
44
|
+
ipAllowlist: parseAllowlistText(f.ipAllowlistText),
|
|
45
|
+
auditLoggingEnabled: f.auditLoggingEnabled,
|
|
46
|
+
},
|
|
21
47
|
};
|
|
22
48
|
}
|
|
23
49
|
/**
|
|
@@ -35,6 +61,7 @@ export function useSecuritySettings(canEdit) {
|
|
|
35
61
|
const [form, setForm] = useState(EMPTY_FORM);
|
|
36
62
|
const [saveState, setSaveState] = useState('idle');
|
|
37
63
|
const [saveError, setSaveError] = useState(null);
|
|
64
|
+
const [lockoutPrompt, setLockoutPrompt] = useState(null);
|
|
38
65
|
const [revokeState, setRevokeState] = useState('idle');
|
|
39
66
|
const [reloadKey, setReloadKey] = useState(0);
|
|
40
67
|
useEffect(() => {
|
|
@@ -85,18 +112,30 @@ export function useSecuritySettings(canEdit) {
|
|
|
85
112
|
return list;
|
|
86
113
|
}, [form, baseline.mfaRequired, data]);
|
|
87
114
|
const hasErrors = issues.some((i) => i.severity === 'error');
|
|
88
|
-
const save = useCallback(async () => {
|
|
115
|
+
const save = useCallback(async (options) => {
|
|
89
116
|
if (!canEdit || hasErrors || !dirty)
|
|
90
117
|
return;
|
|
91
118
|
setSaveState('saving');
|
|
92
119
|
setSaveError(null);
|
|
120
|
+
setLockoutPrompt(null);
|
|
93
121
|
try {
|
|
94
122
|
const res = await cmsApi('/security', {
|
|
95
123
|
method: 'PUT',
|
|
96
|
-
body: JSON.stringify(
|
|
124
|
+
body: JSON.stringify({
|
|
125
|
+
...formToSettings(form),
|
|
126
|
+
...(options?.confirmLockout ? { confirmLockout: true } : {}),
|
|
127
|
+
}),
|
|
97
128
|
});
|
|
98
|
-
|
|
129
|
+
// The server's lockout guard: surface a confirm dialog instead of a
|
|
130
|
+
// generic failure so the operator can retry with confirmLockout.
|
|
131
|
+
if (res.code === 'IP_LOCKOUT_RISK') {
|
|
132
|
+
setSaveState('idle');
|
|
133
|
+
setLockoutPrompt(res.error ?? 'This allowlist may lock you out. Save anyway?');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (res.error || !res.data) {
|
|
99
137
|
throw new Error(res.error ?? 'Failed to save security settings.');
|
|
138
|
+
}
|
|
100
139
|
setData(res.data);
|
|
101
140
|
const f = formFromSettings(res.data.settings);
|
|
102
141
|
setBaseline(f);
|
|
@@ -108,10 +147,12 @@ export function useSecuritySettings(canEdit) {
|
|
|
108
147
|
setSaveError(err instanceof Error ? err.message : 'Failed to save security settings.');
|
|
109
148
|
}
|
|
110
149
|
}, [canEdit, hasErrors, dirty, form]);
|
|
150
|
+
const dismissLockoutPrompt = useCallback(() => setLockoutPrompt(null), []);
|
|
111
151
|
const reset = useCallback(() => {
|
|
112
152
|
setForm(baseline);
|
|
113
153
|
setSaveState('idle');
|
|
114
154
|
setSaveError(null);
|
|
155
|
+
setLockoutPrompt(null);
|
|
115
156
|
}, [baseline]);
|
|
116
157
|
const refetch = useCallback(() => setReloadKey((k) => k + 1), []);
|
|
117
158
|
const revokeAllOtherSessions = useCallback(async () => {
|
|
@@ -147,6 +188,8 @@ export function useSecuritySettings(canEdit) {
|
|
|
147
188
|
save,
|
|
148
189
|
reset,
|
|
149
190
|
refetch,
|
|
191
|
+
lockoutPrompt,
|
|
192
|
+
dismissLockoutPrompt,
|
|
150
193
|
revokeState,
|
|
151
194
|
revokeAllOtherSessions,
|
|
152
195
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useSecuritySettings.js","sourceRoot":"","sources":["../../../src/views/settings/useSecuritySettings.ts"],"names":[],"mappings":"AAAA,YAAY,CAAA;AAEZ,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AACjE,OAAO,EACL,wBAAwB,GAGzB,MAAM,yCAAyC,CAAA;AAChD,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;
|
|
1
|
+
{"version":3,"file":"useSecuritySettings.js","sourceRoot":"","sources":["../../../src/views/settings/useSecuritySettings.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@actuate-media/cms-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.69.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/actuate-media/actuatecms.git",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"tailwindcss": "^4.0.0",
|
|
96
96
|
"typescript": "^5.7.0",
|
|
97
97
|
"vitest": "^4.1.0",
|
|
98
|
-
"@actuate-media/cms-core": "0.
|
|
98
|
+
"@actuate-media/cms-core": "0.89.0",
|
|
99
99
|
"@actuate-media/component-blocks": "0.2.1"
|
|
100
100
|
},
|
|
101
101
|
"scripts": {
|
package/src/AdminRoot.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import * as Dialog from '@radix-ui/react-dialog'
|
|
|
5
5
|
import { toast } from 'sonner'
|
|
6
6
|
import { Layout } from './layout/Layout.js'
|
|
7
7
|
import { adminPortalContainer } from './lib/portal-container.js'
|
|
8
|
-
import { useAdminRouter } from './router/index.js'
|
|
8
|
+
import { useAdminRouter, parseRouteQuery } from './router/index.js'
|
|
9
9
|
import { cmsApi, ensureCsrfToken } from './lib/api.js'
|
|
10
10
|
import { setDocumentFavicon, BRAND_UPDATED_EVENT } from './lib/favicon.js'
|
|
11
11
|
import {
|
|
@@ -38,6 +38,7 @@ import { ThemeProvider } from './components/ThemeProvider.js'
|
|
|
38
38
|
import { LocaleProvider } from './components/LocaleProvider.js'
|
|
39
39
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts.js'
|
|
40
40
|
import { useAppearanceBootstrap } from './hooks/useAppearanceBootstrap.js'
|
|
41
|
+
import { useIdleLogout } from './hooks/useIdleLogout.js'
|
|
41
42
|
import { PageBuilder } from './views/page-builder/PageBuilder.js'
|
|
42
43
|
import { PageSectionEditor } from './views/page-editor/PageSectionEditor.js'
|
|
43
44
|
import { PagePreview } from './views/page-editor/PagePreview.js'
|
|
@@ -52,6 +53,7 @@ import { PagesListView } from './views/Pages/PagesListView.js'
|
|
|
52
53
|
import { PageTagManager } from './views/Pages/PageTagManager.js'
|
|
53
54
|
import { PostsListView } from './views/Posts/PostsListView.js'
|
|
54
55
|
import { PostTypesView } from './views/Posts/PostTypesView.js'
|
|
56
|
+
import { PostTypeEditor } from './views/Posts/PostTypeEditor.js'
|
|
55
57
|
import { NewPostPicker } from './views/Posts/NewPostPicker.js'
|
|
56
58
|
import { NewPostTypeInstructions } from './views/Posts/NewPostTypeInstructions.js'
|
|
57
59
|
import { PostSectionEditor } from './views/post-editor/PostSectionEditor.js'
|
|
@@ -179,6 +181,55 @@ function AdminShell({
|
|
|
179
181
|
[buildHref],
|
|
180
182
|
)
|
|
181
183
|
|
|
184
|
+
// Idle auto-logout (Settings → Security). The policy is read from the
|
|
185
|
+
// non-admin-gated /auth/session-policy endpoint so every member's client
|
|
186
|
+
// enforces it, not just admins. When enabled, `timeoutHours` of inactivity
|
|
187
|
+
// signs the user out through the normal logout flow (server revocation +
|
|
188
|
+
// cookie clear + local state reset).
|
|
189
|
+
const [sessionPolicy, setSessionPolicy] = useState<{
|
|
190
|
+
timeoutHours: number
|
|
191
|
+
idleLogoutEnabled: boolean
|
|
192
|
+
} | null>(null)
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (!session) {
|
|
195
|
+
setSessionPolicy(null)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
let cancelled = false
|
|
199
|
+
void cmsApi<{ timeoutHours: number; idleLogoutEnabled: boolean }>('/auth/session-policy').then(
|
|
200
|
+
(res) => {
|
|
201
|
+
if (!cancelled && res.data && typeof res.data.timeoutHours === 'number') {
|
|
202
|
+
setSessionPolicy(res.data)
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
return () => {
|
|
207
|
+
cancelled = true
|
|
208
|
+
}
|
|
209
|
+
}, [session])
|
|
210
|
+
|
|
211
|
+
const { stayActive } = useIdleLogout({
|
|
212
|
+
enabled: Boolean(session && sessionPolicy?.idleLogoutEnabled),
|
|
213
|
+
timeoutMs: (sessionPolicy?.timeoutHours ?? 0) * 3_600_000,
|
|
214
|
+
onIdle: handleLogout,
|
|
215
|
+
// One-minute heads-up so the sign-out is never a surprise. Any activity
|
|
216
|
+
// (or the toast action) resets the clock and dismisses the warning.
|
|
217
|
+
warningMs: 60_000,
|
|
218
|
+
onWarn: () => {
|
|
219
|
+
toast.warning('You will be signed out in about a minute due to inactivity.', {
|
|
220
|
+
id: 'idle-logout-warning',
|
|
221
|
+
duration: 60_000,
|
|
222
|
+
action: {
|
|
223
|
+
label: 'Stay signed in',
|
|
224
|
+
onClick: () => stayActive(),
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
},
|
|
228
|
+
onWarnCancelled: () => {
|
|
229
|
+
toast.dismiss('idle-logout-warning')
|
|
230
|
+
},
|
|
231
|
+
})
|
|
232
|
+
|
|
182
233
|
useBranding(config)
|
|
183
234
|
useAppearanceBootstrap(session)
|
|
184
235
|
useAdminPageTitle(config, currentPath)
|
|
@@ -292,10 +343,11 @@ function AdminShell({
|
|
|
292
343
|
|
|
293
344
|
const resetMatch = matchRoute('/reset-password')
|
|
294
345
|
if (resetMatch) {
|
|
295
|
-
|
|
296
|
-
|
|
346
|
+
// currentPath carries the query string (router syncs it from the URL on
|
|
347
|
+
// mount/popstate), so the emailed `?token=` deep link survives reloads.
|
|
348
|
+
return (
|
|
349
|
+
<ResetPassword onNavigate={navigate} token={parseRouteQuery(currentPath).get('token')} />
|
|
297
350
|
)
|
|
298
|
-
return <ResetPassword onNavigate={navigate} token={params.get('token')} />
|
|
299
351
|
}
|
|
300
352
|
|
|
301
353
|
if (onLogin) {
|
|
@@ -332,7 +384,7 @@ function AdminShell({
|
|
|
332
384
|
// a collection slugged `media`, `users`, or `settings`).
|
|
333
385
|
|
|
334
386
|
if (matchRoute('/media')) {
|
|
335
|
-
return <MediaBrowser onNavigate={navigate} />
|
|
387
|
+
return <MediaBrowser onNavigate={navigate} currentPath={currentPath} />
|
|
336
388
|
}
|
|
337
389
|
|
|
338
390
|
// ─── Posts area ────────────────────────────────────────────────
|
|
@@ -358,13 +410,7 @@ function AdminShell({
|
|
|
358
410
|
}
|
|
359
411
|
const postTypeEdit = matchRoute('/posts/types/:slug/edit')
|
|
360
412
|
if (postTypeEdit?.slug) {
|
|
361
|
-
|
|
362
|
-
// PR introduces a dedicated PostTypeEditor with field-level
|
|
363
|
-
// metadata. The list view is the natural per-type management
|
|
364
|
-
// surface today (it already shows posts in that type).
|
|
365
|
-
return (
|
|
366
|
-
<CollectionList collectionSlug={postTypeEdit.slug} config={config} onNavigate={navigate} />
|
|
367
|
-
)
|
|
413
|
+
return <PostTypeEditor slug={postTypeEdit.slug} onNavigate={navigate} />
|
|
368
414
|
}
|
|
369
415
|
if (matchRoute('/posts/types')) {
|
|
370
416
|
return <PostTypesView onNavigate={navigate} />
|
|
@@ -434,7 +480,7 @@ function AdminShell({
|
|
|
434
480
|
// "Add sub-page" in the Pages tree view arrives with the parent id
|
|
435
481
|
// in the query string (`/pages/new?parent=<id>`); matchRoute ignores
|
|
436
482
|
// the query so it's parsed here and preselected in the editor.
|
|
437
|
-
const newPageQuery =
|
|
483
|
+
const newPageQuery = parseRouteQuery(currentPath)
|
|
438
484
|
return (
|
|
439
485
|
<PageSectionEditor
|
|
440
486
|
initialParentId={newPageQuery.get('parent')}
|
|
@@ -538,17 +584,38 @@ function AdminShell({
|
|
|
538
584
|
// Users + API Keys moved into the Settings view as tabs. The legacy
|
|
539
585
|
// top-level routes still resolve — they just land on the matching tab.
|
|
540
586
|
if (matchRoute('/users')) {
|
|
541
|
-
return
|
|
587
|
+
return (
|
|
588
|
+
<Settings
|
|
589
|
+
onNavigate={navigate}
|
|
590
|
+
config={config}
|
|
591
|
+
session={session}
|
|
592
|
+
initialTab="users"
|
|
593
|
+
currentPath={currentPath}
|
|
594
|
+
/>
|
|
595
|
+
)
|
|
542
596
|
}
|
|
543
597
|
|
|
544
598
|
if (matchRoute('/api-keys')) {
|
|
545
599
|
return (
|
|
546
|
-
<Settings
|
|
600
|
+
<Settings
|
|
601
|
+
onNavigate={navigate}
|
|
602
|
+
config={config}
|
|
603
|
+
session={session}
|
|
604
|
+
initialTab="api-keys"
|
|
605
|
+
currentPath={currentPath}
|
|
606
|
+
/>
|
|
547
607
|
)
|
|
548
608
|
}
|
|
549
609
|
|
|
550
610
|
if (matchRoute('/settings')) {
|
|
551
|
-
return
|
|
611
|
+
return (
|
|
612
|
+
<Settings
|
|
613
|
+
onNavigate={navigate}
|
|
614
|
+
config={config}
|
|
615
|
+
session={session}
|
|
616
|
+
currentPath={currentPath}
|
|
617
|
+
/>
|
|
618
|
+
)
|
|
552
619
|
}
|
|
553
620
|
|
|
554
621
|
if (matchRoute('/profile')) {
|
|
@@ -687,7 +754,8 @@ function AdminShell({
|
|
|
687
754
|
// Preview routes render as standalone pages (no sidebar/header) so a
|
|
688
755
|
// new-tab preview looks like the real published page rather than an
|
|
689
756
|
// embedded admin view. Everything else gets the full admin chrome.
|
|
690
|
-
|
|
757
|
+
// Query string is stripped — currentPath carries it after reloads.
|
|
758
|
+
if (currentPath.split('?')[0]!.endsWith('/preview')) {
|
|
691
759
|
return (
|
|
692
760
|
<div className="bg-background h-full min-h-screen">
|
|
693
761
|
<ErrorBoundary>{renderView()}</ErrorBoundary>
|
|
@@ -838,7 +906,9 @@ function useAdminPageTitle(config: any, currentPath: string) {
|
|
|
838
906
|
const baseName = branding?.title ?? (branding?.name ? `${branding.name} Admin` : null)
|
|
839
907
|
if (!baseName) return
|
|
840
908
|
|
|
841
|
-
|
|
909
|
+
// Strip the query so `/settings?tab=users` titles as "Settings".
|
|
910
|
+
const pathname = currentPath.split('?')[0]!.split('#')[0]!
|
|
911
|
+
const segment = pathname === '/' ? 'Dashboard' : pathname.split('/').filter(Boolean)[0]
|
|
842
912
|
const pageName = segment ? segment.charAt(0).toUpperCase() + segment.slice(1) : 'Dashboard'
|
|
843
913
|
document.title = `${pageName} — ${baseName}`
|
|
844
914
|
}, [config, currentPath])
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { render } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
import { useIdleLogout } from '../../hooks/useIdleLogout.js'
|
|
6
|
+
|
|
7
|
+
function Harness({
|
|
8
|
+
enabled,
|
|
9
|
+
timeoutMs,
|
|
10
|
+
onIdle,
|
|
11
|
+
warningMs,
|
|
12
|
+
onWarn,
|
|
13
|
+
onWarnCancelled,
|
|
14
|
+
stayActiveRef,
|
|
15
|
+
}: {
|
|
16
|
+
enabled: boolean
|
|
17
|
+
timeoutMs: number
|
|
18
|
+
onIdle: () => void
|
|
19
|
+
warningMs?: number
|
|
20
|
+
onWarn?: () => void
|
|
21
|
+
onWarnCancelled?: () => void
|
|
22
|
+
stayActiveRef?: { current: (() => void) | null }
|
|
23
|
+
}) {
|
|
24
|
+
const { stayActive } = useIdleLogout({
|
|
25
|
+
enabled,
|
|
26
|
+
timeoutMs,
|
|
27
|
+
onIdle,
|
|
28
|
+
warningMs,
|
|
29
|
+
onWarn,
|
|
30
|
+
onWarnCancelled,
|
|
31
|
+
})
|
|
32
|
+
if (stayActiveRef) stayActiveRef.current = stayActive
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const TIMEOUT_MS = 60_000
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
vi.useFakeTimers()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.useRealTimers()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('useIdleLogout', () => {
|
|
47
|
+
it('fires onIdle exactly once after the timeout with no activity', () => {
|
|
48
|
+
const onIdle = vi.fn()
|
|
49
|
+
render(<Harness enabled timeoutMs={TIMEOUT_MS} onIdle={onIdle} />)
|
|
50
|
+
|
|
51
|
+
vi.advanceTimersByTime(TIMEOUT_MS - 1_000)
|
|
52
|
+
expect(onIdle).not.toHaveBeenCalled()
|
|
53
|
+
|
|
54
|
+
vi.advanceTimersByTime(TIMEOUT_MS)
|
|
55
|
+
expect(onIdle).toHaveBeenCalledTimes(1)
|
|
56
|
+
|
|
57
|
+
// The idle callback must not re-fire on subsequent ticks.
|
|
58
|
+
vi.advanceTimersByTime(TIMEOUT_MS * 3)
|
|
59
|
+
expect(onIdle).toHaveBeenCalledTimes(1)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('resets the idle deadline on user activity', () => {
|
|
63
|
+
const onIdle = vi.fn()
|
|
64
|
+
render(<Harness enabled timeoutMs={TIMEOUT_MS} onIdle={onIdle} />)
|
|
65
|
+
|
|
66
|
+
// Just before the deadline the user types — the clock restarts.
|
|
67
|
+
vi.advanceTimersByTime(TIMEOUT_MS - 5_000)
|
|
68
|
+
window.dispatchEvent(new Event('keydown'))
|
|
69
|
+
vi.advanceTimersByTime(TIMEOUT_MS - 5_000)
|
|
70
|
+
expect(onIdle).not.toHaveBeenCalled()
|
|
71
|
+
|
|
72
|
+
// A full quiet timeout after the last activity signs the user out.
|
|
73
|
+
vi.advanceTimersByTime(TIMEOUT_MS)
|
|
74
|
+
expect(onIdle).toHaveBeenCalledTimes(1)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('does nothing when disabled', () => {
|
|
78
|
+
const onIdle = vi.fn()
|
|
79
|
+
render(<Harness enabled={false} timeoutMs={TIMEOUT_MS} onIdle={onIdle} />)
|
|
80
|
+
vi.advanceTimersByTime(TIMEOUT_MS * 10)
|
|
81
|
+
expect(onIdle).not.toHaveBeenCalled()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('tears everything down on unmount', () => {
|
|
85
|
+
const onIdle = vi.fn()
|
|
86
|
+
const { unmount } = render(<Harness enabled timeoutMs={TIMEOUT_MS} onIdle={onIdle} />)
|
|
87
|
+
unmount()
|
|
88
|
+
vi.advanceTimersByTime(TIMEOUT_MS * 10)
|
|
89
|
+
expect(onIdle).not.toHaveBeenCalled()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('checks the deadline immediately when a hidden tab becomes visible', () => {
|
|
93
|
+
const onIdle = vi.fn()
|
|
94
|
+
render(<Harness enabled timeoutMs={TIMEOUT_MS} onIdle={onIdle} />)
|
|
95
|
+
|
|
96
|
+
// Simulate the deadline passing while the tab was hidden (the interval is
|
|
97
|
+
// shared with visibility, so freeze it by only moving the mocked clock).
|
|
98
|
+
vi.setSystemTime(Date.now() + TIMEOUT_MS + 1_000)
|
|
99
|
+
document.dispatchEvent(new Event('visibilitychange'))
|
|
100
|
+
expect(onIdle).toHaveBeenCalledTimes(1)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('warning window', () => {
|
|
104
|
+
const WARNING_MS = 20_000
|
|
105
|
+
|
|
106
|
+
it('fires onWarn once inside the warning window, then onIdle at the deadline', () => {
|
|
107
|
+
const onIdle = vi.fn()
|
|
108
|
+
const onWarn = vi.fn()
|
|
109
|
+
render(
|
|
110
|
+
<Harness
|
|
111
|
+
enabled
|
|
112
|
+
timeoutMs={TIMEOUT_MS}
|
|
113
|
+
warningMs={WARNING_MS}
|
|
114
|
+
onIdle={onIdle}
|
|
115
|
+
onWarn={onWarn}
|
|
116
|
+
/>,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// Before the warning window: nothing.
|
|
120
|
+
vi.advanceTimersByTime(TIMEOUT_MS - WARNING_MS - 5_000)
|
|
121
|
+
expect(onWarn).not.toHaveBeenCalled()
|
|
122
|
+
|
|
123
|
+
// Inside the window: warn exactly once, no logout yet.
|
|
124
|
+
vi.advanceTimersByTime(10_000)
|
|
125
|
+
expect(onWarn).toHaveBeenCalledTimes(1)
|
|
126
|
+
expect(onIdle).not.toHaveBeenCalled()
|
|
127
|
+
|
|
128
|
+
// Deadline: logout, and the warning doesn't re-fire.
|
|
129
|
+
vi.advanceTimersByTime(TIMEOUT_MS)
|
|
130
|
+
expect(onIdle).toHaveBeenCalledTimes(1)
|
|
131
|
+
expect(onWarn).toHaveBeenCalledTimes(1)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('activity after the warning cancels it and resets the clock', () => {
|
|
135
|
+
const onIdle = vi.fn()
|
|
136
|
+
const onWarn = vi.fn()
|
|
137
|
+
const onWarnCancelled = vi.fn()
|
|
138
|
+
render(
|
|
139
|
+
<Harness
|
|
140
|
+
enabled
|
|
141
|
+
timeoutMs={TIMEOUT_MS}
|
|
142
|
+
warningMs={WARNING_MS}
|
|
143
|
+
onIdle={onIdle}
|
|
144
|
+
onWarn={onWarn}
|
|
145
|
+
onWarnCancelled={onWarnCancelled}
|
|
146
|
+
/>,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
vi.advanceTimersByTime(TIMEOUT_MS - 10_000)
|
|
150
|
+
expect(onWarn).toHaveBeenCalledTimes(1)
|
|
151
|
+
|
|
152
|
+
window.dispatchEvent(new Event('keydown'))
|
|
153
|
+
expect(onWarnCancelled).toHaveBeenCalledTimes(1)
|
|
154
|
+
|
|
155
|
+
// The clock restarted: warn again on the next quiet cycle, no logout yet.
|
|
156
|
+
vi.advanceTimersByTime(TIMEOUT_MS - 10_000)
|
|
157
|
+
expect(onWarn).toHaveBeenCalledTimes(2)
|
|
158
|
+
expect(onIdle).not.toHaveBeenCalled()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('stayActive resets the clock like real activity', () => {
|
|
162
|
+
const onIdle = vi.fn()
|
|
163
|
+
const onWarn = vi.fn()
|
|
164
|
+
const onWarnCancelled = vi.fn()
|
|
165
|
+
const stayActiveRef: { current: (() => void) | null } = { current: null }
|
|
166
|
+
render(
|
|
167
|
+
<Harness
|
|
168
|
+
enabled
|
|
169
|
+
timeoutMs={TIMEOUT_MS}
|
|
170
|
+
warningMs={WARNING_MS}
|
|
171
|
+
onIdle={onIdle}
|
|
172
|
+
onWarn={onWarn}
|
|
173
|
+
onWarnCancelled={onWarnCancelled}
|
|
174
|
+
stayActiveRef={stayActiveRef}
|
|
175
|
+
/>,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
vi.advanceTimersByTime(TIMEOUT_MS - 10_000)
|
|
179
|
+
expect(onWarn).toHaveBeenCalledTimes(1)
|
|
180
|
+
|
|
181
|
+
stayActiveRef.current?.()
|
|
182
|
+
expect(onWarnCancelled).toHaveBeenCalledTimes(1)
|
|
183
|
+
|
|
184
|
+
vi.advanceTimersByTime(TIMEOUT_MS - 15_000)
|
|
185
|
+
expect(onIdle).not.toHaveBeenCalled()
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const cmsApiMock = vi.fn()
|
|
4
|
+
vi.mock('../../lib/api.js', () => ({
|
|
5
|
+
cmsApi: (...args: unknown[]) => cmsApiMock(...args),
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
fetchPostTypeDetail,
|
|
10
|
+
fieldKeyFromName,
|
|
11
|
+
fieldTypeLabel,
|
|
12
|
+
humanizeFieldName,
|
|
13
|
+
renderFieldSnippet,
|
|
14
|
+
} = await import('../../lib/post-types-service.js')
|
|
15
|
+
|
|
16
|
+
const COLLECTIONS = [
|
|
17
|
+
{
|
|
18
|
+
slug: 'blog',
|
|
19
|
+
labels: { singular: 'Blog Post', plural: 'Blog' },
|
|
20
|
+
type: 'post',
|
|
21
|
+
urlPrefix: 'blog',
|
|
22
|
+
description: 'Long-form articles',
|
|
23
|
+
icon: 'file-text',
|
|
24
|
+
color: 'purple',
|
|
25
|
+
hidden: false,
|
|
26
|
+
fields: [
|
|
27
|
+
{ name: 'title', type: 'text', required: true },
|
|
28
|
+
{ name: 'body', type: 'richText', required: false },
|
|
29
|
+
{ name: 'author', type: 'relationship', required: false, relationTo: 'users' },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
describe('fetchPostTypeDetail', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
cmsApiMock.mockReset()
|
|
37
|
+
cmsApiMock.mockResolvedValue({ data: COLLECTIONS })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('maps the matching collection row onto PostTypeDetail', async () => {
|
|
41
|
+
const detail = await fetchPostTypeDetail('blog')
|
|
42
|
+
expect(detail).toEqual({
|
|
43
|
+
slug: 'blog',
|
|
44
|
+
singular: 'Blog Post',
|
|
45
|
+
plural: 'Blog',
|
|
46
|
+
description: 'Long-form articles',
|
|
47
|
+
urlPrefix: 'blog',
|
|
48
|
+
icon: 'file-text',
|
|
49
|
+
color: 'purple',
|
|
50
|
+
fields: COLLECTIONS[0]!.fields,
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('returns null when the slug is unknown', async () => {
|
|
55
|
+
expect(await fetchPostTypeDetail('missing')).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('throws on an API error envelope', async () => {
|
|
59
|
+
cmsApiMock.mockResolvedValue({ error: 'boom' })
|
|
60
|
+
await expect(fetchPostTypeDetail('blog')).rejects.toThrow('boom')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('field name helpers', () => {
|
|
65
|
+
it('humanizes camelCase and snake_case keys', () => {
|
|
66
|
+
expect(humanizeFieldName('clientName')).toBe('Client Name')
|
|
67
|
+
expect(humanizeFieldName('meta_description')).toBe('Meta Description')
|
|
68
|
+
expect(humanizeFieldName('seo-score')).toBe('Seo Score')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('derives camelCase keys from display names', () => {
|
|
72
|
+
expect(fieldKeyFromName('Client Name')).toBe('clientName')
|
|
73
|
+
expect(fieldKeyFromName(' Featured Image! ')).toBe('featuredImage')
|
|
74
|
+
expect(fieldKeyFromName('')).toBe('')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('labels known field types and passes through unknown ones', () => {
|
|
78
|
+
expect(fieldTypeLabel('text')).toBe('Short Text')
|
|
79
|
+
expect(fieldTypeLabel('relationship')).toBe('Relation')
|
|
80
|
+
expect(fieldTypeLabel('customThing')).toBe('customThing')
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('renderFieldSnippet', () => {
|
|
85
|
+
it('renders a minimal optional field', () => {
|
|
86
|
+
expect(
|
|
87
|
+
renderFieldSnippet({ key: 'subtitle', label: 'Subtitle', type: 'text', required: false }),
|
|
88
|
+
).toBe("subtitle: { type: 'text', label: 'Subtitle' },")
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('includes required, help text, and escapes quotes', () => {
|
|
92
|
+
const snippet = renderFieldSnippet({
|
|
93
|
+
key: 'clientName',
|
|
94
|
+
label: "Client's Name",
|
|
95
|
+
type: 'text',
|
|
96
|
+
required: true,
|
|
97
|
+
helpText: "Editor's hint",
|
|
98
|
+
})
|
|
99
|
+
expect(snippet).toContain("label: 'Client\\'s Name'")
|
|
100
|
+
expect(snippet).toContain('required: true')
|
|
101
|
+
expect(snippet).toContain("admin: { description: 'Editor\\'s hint' }")
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('seeds placeholder options for select and relationTo for relationship', () => {
|
|
105
|
+
expect(
|
|
106
|
+
renderFieldSnippet({ key: 'tone', label: 'Tone', type: 'select', required: false }),
|
|
107
|
+
).toContain("options: [{ label: 'Option A', value: 'option-a' }]")
|
|
108
|
+
expect(
|
|
109
|
+
renderFieldSnippet({ key: 'owner', label: 'Owner', type: 'relationship', required: false }),
|
|
110
|
+
).toContain("relationTo: 'posts'")
|
|
111
|
+
})
|
|
112
|
+
})
|