domma-cms 1.6.59 → 1.7.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.
Files changed (143) hide show
  1. package/CHANGELOG.md +4344 -0
  2. package/README.md +1 -1
  3. package/admin/dist/domma/domma-tools.css +3 -3
  4. package/admin/dist/domma/domma-tools.min.js +3 -3
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +2 -2
  7. package/admin/js/lib/draft-owner.js +1 -0
  8. package/admin/js/lib/effect-defs.js +1 -1
  9. package/admin/js/lib/effects-builder.js +2 -2
  10. package/admin/js/lib/page-editor-reference.js +5 -5
  11. package/admin/js/lib/shortcode-tags.js +1 -1
  12. package/admin/js/templates/docs/usage-shortcodes.html +39 -0
  13. package/admin/js/views/user-editor.js +1 -1
  14. package/docs/DommaFeatures.md +3005 -0
  15. package/docs/MultiTenant.md +176 -0
  16. package/docs/api-reference.md +1667 -0
  17. package/docs/cache.md +96 -0
  18. package/docs/configuration.md +1811 -0
  19. package/docs/editions-and-licences.md +190 -0
  20. package/docs/getting-started.md +201 -0
  21. package/docs/guides/form-triggers.md +260 -0
  22. package/docs/guides/mongodb-storage-adapter.md +282 -0
  23. package/docs/guides/multi-page-forms.md +121 -0
  24. package/docs/images/scribe-composer.png +0 -0
  25. package/docs/markdown-shortcodes.md +1738 -0
  26. package/docs/pentest-report-2026-04-03.md +181 -0
  27. package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
  28. package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
  29. package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
  30. package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
  31. package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
  32. package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
  33. package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
  34. package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
  35. package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
  36. package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
  37. package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
  38. package/docs/plans/2026-03-05-user-types-design.md +139 -0
  39. package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
  40. package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
  41. package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
  42. package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
  43. package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
  44. package/docs/plans/2026-06-10-core-project-design.md +240 -0
  45. package/docs/plugin-development.md +827 -0
  46. package/docs/recruitment-recipe.md +501 -0
  47. package/docs/scaffolding.md +312 -0
  48. package/docs/screenshots/calculator-floating.png +0 -0
  49. package/docs/screenshots/calculator-standard.png +0 -0
  50. package/docs/screenshots/email-settings.png +0 -0
  51. package/docs/screenshots/messages-count.png +0 -0
  52. package/docs/structure/user-types.md +107 -0
  53. package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
  54. package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
  55. package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
  56. package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
  57. package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
  58. package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
  59. package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
  60. package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
  61. package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
  62. package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
  63. package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
  64. package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
  65. package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
  66. package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
  67. package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
  68. package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
  69. package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
  70. package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
  71. package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
  72. package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
  73. package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
  74. package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
  75. package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
  76. package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
  77. package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
  78. package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
  79. package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
  80. package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
  81. package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
  82. package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
  83. package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
  84. package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
  85. package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
  86. package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
  87. package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
  88. package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
  89. package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
  90. package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
  91. package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
  92. package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
  93. package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
  94. package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
  95. package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
  96. package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
  97. package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
  98. package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
  99. package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
  100. package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
  101. package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
  102. package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
  103. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
  104. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
  105. package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
  106. package/docs/theming.md +320 -0
  107. package/package.json +2 -2
  108. package/plugins/_lib/admin/mail/keyboard.js +49 -0
  109. package/plugins/_lib/admin/mail/reader-view.js +4 -2
  110. package/public/js/celebrations/core/canvas.js +1 -1
  111. package/public/js/celebrations/index.js +1 -1
  112. package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
  113. package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
  114. package/public/js/celebrations/message-attrs.mjs +1 -0
  115. package/public/js/celebrations/themes/anniversary.js +1 -0
  116. package/public/js/celebrations/themes/autumn.js +1 -0
  117. package/public/js/celebrations/themes/birthday.js +1 -0
  118. package/public/js/celebrations/themes/butterfly-flight.js +1 -0
  119. package/public/js/celebrations/themes/calendar.js +1 -0
  120. package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
  121. package/public/js/celebrations/themes/chinese-new-year.js +1 -0
  122. package/public/js/celebrations/themes/christmas.js +1 -1
  123. package/public/js/celebrations/themes/easter.js +1 -0
  124. package/public/js/celebrations/themes/garden-drawing.js +1 -0
  125. package/public/js/celebrations/themes/garden.js +1 -0
  126. package/public/js/celebrations/themes/graduation.js +1 -0
  127. package/public/js/celebrations/themes/greeting.js +1 -0
  128. package/public/js/celebrations/themes/make-it-rain.js +1 -0
  129. package/public/js/celebrations/themes/money-rain.js +1 -0
  130. package/public/js/celebrations/themes/new-year.js +1 -0
  131. package/public/js/celebrations/themes/occasion-drawing.js +1 -0
  132. package/public/js/celebrations/themes/occasions.js +1 -0
  133. package/public/js/celebrations/themes/rain-control.js +1 -0
  134. package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
  135. package/public/js/celebrations/themes/sparkler-font.js +1 -0
  136. package/public/js/celebrations/themes/sparkler-writing.js +1 -0
  137. package/public/js/celebrations/themes/summer-garden.js +1 -0
  138. package/public/js/celebrations/themes/winter.js +1 -0
  139. package/public/js/celebrations/trait-data.mjs +1 -1
  140. package/public/js/effects.js +1 -1
  141. package/scripts/build.js +4 -0
  142. package/server/services/markdown.js +5 -2
  143. package/server/templates/page.html +1 -1
@@ -0,0 +1,1035 @@
1
+ # User Types Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Move roles from static `config/auth.json` into a preset Collection (`content/collections/user-types/`), add a User Types tab to the Users admin view with full CRUD, and wire the auth middleware to read roles dynamically from the collection cache.
6
+
7
+ **Architecture:** A new `server/services/userTypes.js` service seeds the preset collection on startup, loads it into an in-memory cache (roleMap + permissionsMap), and exposes `getRoleMap()`, `getPermissionsFor(resource)`, and `invalidate()`. The auth middleware calls these functions at request time instead of reading static config. The Collections route calls `invalidate()` after any CRUD on the `user-types` slug.
8
+
9
+ **Tech Stack:** Fastify 5 ESM, `fs/promises`, Domma SPA (tabs via `E.tabs()`, table via `T.create()`, modal via `E.modal()`), existing Collections API routes.
10
+
11
+ ---
12
+
13
+ ## Task 1: Create `server/services/userTypes.js`
14
+
15
+ **Files:**
16
+ - Create: `server/services/userTypes.js`
17
+
18
+ **Step 1: Write the file**
19
+
20
+ ```js
21
+ /**
22
+ * User Types Service
23
+ * Preset Collection - seeds roles on first startup, caches them in memory.
24
+ * Auth middleware calls getRoleMap() / getPermissionsFor() at request time.
25
+ */
26
+ import fs from 'fs/promises';
27
+ import path from 'path';
28
+ import { v4 as uuidv4 } from 'uuid';
29
+ import { config } from '../config.js';
30
+
31
+ const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
32
+ const SLUG = 'user-types';
33
+ const DIR = path.join(COLLECTIONS_DIR, SLUG);
34
+ const SCHEMA_PATH = path.join(DIR, 'schema.json');
35
+ const DATA_PATH = path.join(DIR, 'data.json');
36
+
37
+ const RESOURCES = ['pages', 'settings', 'navigation', 'layouts', 'media', 'users', 'plugins', 'collections'];
38
+
39
+ const PRESET_SCHEMA = {
40
+ slug: SLUG,
41
+ title: 'User Types',
42
+ description: 'CMS role definitions - managed by the system.',
43
+ preset: true,
44
+ fields: [
45
+ { name: 'name', label: 'Name (slug)', type: 'text', required: true },
46
+ { name: 'label', label: 'Label', type: 'text', required: true },
47
+ { name: 'level', label: 'Level', type: 'number', required: true },
48
+ { name: 'permissions', label: 'Permissions', type: 'multi-select', options: RESOURCES },
49
+ { name: 'badgeClass', label: 'Badge Class', type: 'select',
50
+ options: ['badge-danger','badge-warning','badge-info','badge-secondary','badge-success','badge-primary'] }
51
+ ],
52
+ api: {
53
+ create: { enabled: false, access: 'admin' },
54
+ read: { enabled: false, access: 'admin' },
55
+ update: { enabled: false, access: 'admin' },
56
+ delete: { enabled: false, access: 'admin' }
57
+ }
58
+ };
59
+
60
+ const SEED_ENTRIES = [
61
+ { name: 'admin', label: 'Admin', level: 0, permissions: RESOURCES, badgeClass: 'badge-danger' },
62
+ { name: 'manager', label: 'Manager', level: 1, permissions: ['pages','settings','navigation','layouts','media','users','collections'], badgeClass: 'badge-warning' },
63
+ { name: 'editor', label: 'Editor', level: 2, permissions: ['pages','media'], badgeClass: 'badge-info' },
64
+ { name: 'subscriber', label: 'Subscriber', level: 3, permissions: [], badgeClass: 'badge-secondary' }
65
+ ];
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // In-memory cache
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /** @type {Map<string,{label:string,level:number,badgeClass:string}>} */
72
+ let roleMap = new Map();
73
+
74
+ /** @type {Map<string,string[]>} */
75
+ let permissionsMap = new Map();
76
+
77
+ /**
78
+ * Build in-memory maps from an array of data entries.
79
+ *
80
+ * @param {object[]} entries
81
+ */
82
+ function buildCache(entries) {
83
+ roleMap = new Map();
84
+ permissionsMap = new Map();
85
+
86
+ for (const entry of entries) {
87
+ const d = entry.data;
88
+ roleMap.set(d.name, { label: d.label, level: d.level, badgeClass: d.badgeClass || '' });
89
+ for (const resource of (d.permissions || [])) {
90
+ if (!permissionsMap.has(resource)) permissionsMap.set(resource, []);
91
+ permissionsMap.get(resource).push(d.name);
92
+ }
93
+ }
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Public API
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Seed the preset collection on first startup (no-op if data.json already exists).
102
+ *
103
+ * @returns {Promise<void>}
104
+ */
105
+ export async function seed() {
106
+ await fs.mkdir(DIR, { recursive: true });
107
+
108
+ // Always write schema (overwrite to keep in sync with code)
109
+ await fs.writeFile(SCHEMA_PATH, JSON.stringify(PRESET_SCHEMA, null, 2) + '\n', 'utf8');
110
+
111
+ // Only write data if it doesn't exist yet
112
+ try {
113
+ await fs.access(DATA_PATH);
114
+ } catch {
115
+ const entries = SEED_ENTRIES.map(data => ({ id: uuidv4(), data, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }));
116
+ await fs.writeFile(DATA_PATH, JSON.stringify(entries, null, 2) + '\n', 'utf8');
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Load the collection from disk into the in-memory cache.
122
+ *
123
+ * @returns {Promise<void>}
124
+ */
125
+ export async function load() {
126
+ try {
127
+ const raw = await fs.readFile(DATA_PATH, 'utf8');
128
+ const entries = JSON.parse(raw);
129
+ buildCache(entries);
130
+ } catch (err) {
131
+ console.warn('[userTypes] Failed to load user-types collection:', err.message);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Reload from disk - call after any CRUD on the user-types collection.
137
+ *
138
+ * @returns {Promise<void>}
139
+ */
140
+ export async function invalidate() {
141
+ await load();
142
+ }
143
+
144
+ /**
145
+ * Return the full role map.
146
+ *
147
+ * @returns {Map<string,{label:string,level:number,badgeClass:string}>}
148
+ */
149
+ export function getRoleMap() {
150
+ return roleMap;
151
+ }
152
+
153
+ /**
154
+ * Return the level for a named role, or Infinity if not found.
155
+ *
156
+ * @param {string} roleName
157
+ * @returns {number}
158
+ */
159
+ export function getRoleLevel(roleName) {
160
+ return roleMap.get(roleName)?.level ?? Infinity;
161
+ }
162
+
163
+ /**
164
+ * Return the role names allowed to access a resource, or [] if resource unknown.
165
+ *
166
+ * @param {string} resource
167
+ * @returns {string[]}
168
+ */
169
+ export function getPermissionsFor(resource) {
170
+ return permissionsMap.get(resource) ?? [];
171
+ }
172
+
173
+ /**
174
+ * Return role names ordered from most to least privileged.
175
+ *
176
+ * @returns {string[]}
177
+ */
178
+ export function getRoleHierarchy() {
179
+ return [...roleMap.entries()]
180
+ .sort((a, b) => a[1].level - b[1].level)
181
+ .map(([key]) => key);
182
+ }
183
+ ```
184
+
185
+ **Step 2: Verify file created**
186
+
187
+ ```bash
188
+ cat server/services/userTypes.js | head -5
189
+ ```
190
+ Expected: first comment line visible.
191
+
192
+ **Step 3: Commit**
193
+
194
+ ```bash
195
+ git add server/services/userTypes.js
196
+ git commit -m "feat: add userTypes service (seed, load, cache)"
197
+ ```
198
+
199
+ ---
200
+
201
+ ## Task 2: Seed + load in `server/server.js`
202
+
203
+ **Files:**
204
+ - Modify: `server/server.js`
205
+
206
+ **Step 1: Add import after existing imports (before `const app = Fastify`)**
207
+
208
+ After `import { registerPlugins } from './services/plugins.js';` add:
209
+
210
+ ```js
211
+ import { seed as seedUserTypes, load as loadUserTypes } from './services/userTypes.js';
212
+ ```
213
+
214
+ **Step 2: Add seed + load calls before route imports**
215
+
216
+ After the `await fs.mkdir(pluginsDir, { recursive: true });` block (around line 85) and before the `// Auth API Routes` comment, add:
217
+
218
+ ```js
219
+ // ---------------------------------------------------------------------------
220
+ // User Types - seed preset collection + load into cache
221
+ // ---------------------------------------------------------------------------
222
+
223
+ await seedUserTypes();
224
+ await loadUserTypes();
225
+ ```
226
+
227
+ **Step 3: Verify server still starts**
228
+
229
+ ```bash
230
+ curl -s -o /dev/null -w "%{http_code}" http://localhost/api/auth/setup-status
231
+ ```
232
+ Expected: `200`
233
+
234
+ **Step 4: Verify seed files created**
235
+
236
+ ```bash
237
+ ls content/collections/user-types/
238
+ ```
239
+ Expected: `data.json schema.json`
240
+
241
+ ```bash
242
+ cat content/collections/user-types/data.json | head -5
243
+ ```
244
+ Expected: JSON array with first entry visible.
245
+
246
+ **Step 5: Commit**
247
+
248
+ ```bash
249
+ git add server/server.js
250
+ git commit -m "feat: seed and load user types on startup"
251
+ ```
252
+
253
+ ---
254
+
255
+ ## Task 3: Update `server/middleware/auth.js`
256
+
257
+ **Files:**
258
+ - Modify: `server/middleware/auth.js`
259
+
260
+ The goal is to replace the static `config.auth.roles` read with calls to the userTypes cache. No breaking changes to existing exports - just switch internals to use `getRoleMap()` / `getRoleLevel()` / `getRoleHierarchy()`.
261
+
262
+ **Step 1: Replace entire file with updated version**
263
+
264
+ ```js
265
+ /**
266
+ * Authentication Middleware
267
+ * JWT-based authentication with role guards for Domma CMS.
268
+ * Role data is read from the userTypes cache (not config.auth.roles).
269
+ */
270
+ import { getRoleMap, getRoleLevel, getRoleHierarchy, getPermissionsFor } from '../services/userTypes.js';
271
+
272
+ /**
273
+ * Role hierarchy ordered from most to least privileged.
274
+ * Computed lazily at request time from the userTypes cache.
275
+ *
276
+ * @returns {string[]}
277
+ */
278
+ export function getRoleHierarchyList() {
279
+ return getRoleHierarchy();
280
+ }
281
+
282
+ /**
283
+ * Verify JWT Bearer token. Populates request.user on success.
284
+ *
285
+ * @param {FastifyRequest} request
286
+ * @param {FastifyReply} reply
287
+ * @returns {Promise<void>}
288
+ */
289
+ export async function authenticate(request, reply) {
290
+ try {
291
+ await request.jwtVerify();
292
+ } catch {
293
+ return reply.code(401).send({
294
+ statusCode: 401,
295
+ error: 'Unauthorised',
296
+ message: 'Invalid or missing authentication token'
297
+ });
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Return a preHandler that enforces one of the specified roles.
303
+ * Must be used after authenticate.
304
+ *
305
+ * @param {string[]} allowedRoles
306
+ * @returns {Function}
307
+ */
308
+ export function requireRole(allowedRoles) {
309
+ const allowed = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles];
310
+
311
+ return async (request, reply) => {
312
+ if (!request.user) {
313
+ return reply.code(401).send({
314
+ statusCode: 401,
315
+ error: 'Unauthorised',
316
+ message: 'Authentication required'
317
+ });
318
+ }
319
+
320
+ if (!allowed.includes(request.user.role)) {
321
+ return reply.code(403).send({
322
+ statusCode: 403,
323
+ error: 'Forbidden',
324
+ message: `Access denied. Required role: ${allowed.join(' or ')}`
325
+ });
326
+ }
327
+ };
328
+ }
329
+
330
+ /**
331
+ * Return a preHandler that checks the current user's role has access to resource.
332
+ * Reads from the userTypes cache at request time - reflects live role changes.
333
+ *
334
+ * @param {string} resource - Resource key (e.g. 'pages', 'users')
335
+ * @returns {Function}
336
+ */
337
+ export function requirePermission(resource) {
338
+ return async (request, reply) => {
339
+ if (!request.user) {
340
+ return reply.code(401).send({
341
+ statusCode: 401,
342
+ error: 'Unauthorised',
343
+ message: 'Authentication required'
344
+ });
345
+ }
346
+
347
+ const allowed = getPermissionsFor(resource);
348
+ if (!allowed.includes(request.user.role)) {
349
+ return reply.code(403).send({
350
+ statusCode: 403,
351
+ error: 'Forbidden',
352
+ message: 'Insufficient permissions'
353
+ });
354
+ }
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Shorthand preHandler - level-0 role (admin) only.
360
+ *
361
+ * @param {FastifyRequest} request
362
+ * @param {FastifyReply} reply
363
+ * @returns {Promise<void>}
364
+ */
365
+ export async function requireAdmin(request, reply) {
366
+ if (!request.user) {
367
+ return reply.code(401).send({ statusCode: 401, error: 'Unauthorised', message: 'Authentication required' });
368
+ }
369
+ const level = getRoleLevel(request.user.role);
370
+ if (level !== 0) {
371
+ return reply.code(403).send({ statusCode: 403, error: 'Forbidden', message: 'Admin access required' });
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Determine whether an actor can manage a target user.
377
+ * Managers cannot create, edit, or delete users with a lower level number (higher privilege).
378
+ *
379
+ * @param {string} actorRole - Role of the user performing the action
380
+ * @param {string} targetRole - Role of the user being acted upon
381
+ * @returns {boolean}
382
+ */
383
+ export function canManageUser(actorRole, targetRole) {
384
+ const actorLevel = getRoleLevel(actorRole);
385
+ const targetLevel = getRoleLevel(targetRole);
386
+ return actorLevel < targetLevel;
387
+ }
388
+ ```
389
+
390
+ **Step 2: Verify server still starts**
391
+
392
+ ```bash
393
+ curl -s -o /dev/null -w "%{http_code}" http://localhost/api/auth/setup-status
394
+ ```
395
+ Expected: `200`
396
+
397
+ **Step 3: Commit**
398
+
399
+ ```bash
400
+ git add server/middleware/auth.js
401
+ git commit -m "refactor: auth middleware reads roles from userTypes cache"
402
+ ```
403
+
404
+ ---
405
+
406
+ ## Task 4: Update route guards to use `requirePermission`
407
+
408
+ **Files:**
409
+ - Modify: `server/routes/api/pages.js`
410
+ - Modify: `server/routes/api/settings.js`
411
+ - Modify: `server/routes/api/layouts.js`
412
+ - Modify: `server/routes/api/navigation.js`
413
+ - Modify: `server/routes/api/media.js`
414
+ - Modify: `server/routes/api/users.js`
415
+ - Modify: `server/routes/api/plugins.js`
416
+ - Modify: `server/routes/api/collections.js`
417
+
418
+ **Step 1: In each file, update the import line**
419
+
420
+ Find: `import { authenticate, requireRole } from '../../middleware/auth.js';`
421
+ Replace with: `import { authenticate, requireRole, requirePermission } from '../../middleware/auth.js';`
422
+
423
+ (Some files also import `canManageUser` or `requireAdmin` - keep those.)
424
+
425
+ **Step 2: In each file, update the guard**
426
+
427
+ Find the pattern: `const guard = [authenticate, requireRole(permissions.xxx)];` or `{ preHandler: [authenticate, requireRole(config.auth.permissions.xxx)] }`
428
+
429
+ Replace with: `const guard = [authenticate, requirePermission('xxx')];` or `{ preHandler: [authenticate, requirePermission('xxx')] }`
430
+
431
+ Use the correct resource key for each file:
432
+ - `pages.js` → `requirePermission('pages')`
433
+ - `settings.js` → `requirePermission('settings')`
434
+ - `layouts.js` → `requirePermission('layouts')`
435
+ - `navigation.js` → `requirePermission('navigation')`
436
+ - `media.js` → `requirePermission('media')`
437
+ - `users.js` → `requirePermission('users')`
438
+ - `plugins.js` → `requirePermission('plugins')`
439
+ - `collections.js` → `requirePermission('collections')`
440
+
441
+ **Step 3: Remove `const { permissions } = config.auth;` and `import { config } from` lines** from any file that no longer uses `config` for anything else. Check each file individually - some may use `config` for other things (e.g. `auth.js` uses `accessTokenExpiry`).
442
+
443
+ **Step 4: Update `roleLevel()` in `collections.js`**
444
+
445
+ The existing `roleLevel()` helper reads `config.auth.roles`. Replace it:
446
+
447
+ Find:
448
+ ```js
449
+ function roleLevel(roleName) {
450
+ return config.auth.roles[roleName]?.level ?? 99;
451
+ }
452
+ ```
453
+ Replace with:
454
+ ```js
455
+ import { getRoleLevel } from '../../services/userTypes.js';
456
+ // ... (add to existing imports)
457
+
458
+ function roleLevel(roleName) {
459
+ return getRoleLevel(roleName);
460
+ }
461
+ ```
462
+
463
+ **Step 5: Verify login still works**
464
+
465
+ ```bash
466
+ curl -s -X POST http://localhost/api/auth/login \
467
+ -H 'Content-Type: application/json' \
468
+ -d '{"email":"your@email.com","password":"yourpass"}' | head -c 100
469
+ ```
470
+ Expected: JSON with `token` key.
471
+
472
+ **Step 6: Commit**
473
+
474
+ ```bash
475
+ git add server/routes/api/
476
+ git commit -m "refactor: route guards use requirePermission (reads userTypes cache)"
477
+ ```
478
+
479
+ ---
480
+
481
+ ## Task 5: Invalidate cache on user-types CRUD + protect preset schema
482
+
483
+ **Files:**
484
+ - Modify: `server/routes/api/collections.js`
485
+
486
+ **Step 1: Add invalidate import**
487
+
488
+ Add to existing imports:
489
+ ```js
490
+ import { invalidate as invalidateUserTypes } from '../../services/userTypes.js';
491
+ ```
492
+
493
+ **Step 2: Protect user-types schema from delete**
494
+
495
+ In the `DELETE /collections/:slug` handler, add a guard before the try/catch:
496
+
497
+ ```js
498
+ fastify.delete('/collections/:slug', guard, async (request, reply) => {
499
+ if (request.params.slug === 'user-types') {
500
+ return reply.status(403).send({ error: 'Cannot delete a preset collection' });
501
+ }
502
+ // ... existing try/catch
503
+ });
504
+ ```
505
+
506
+ **Step 3: Invalidate cache after user-types entry CRUD**
507
+
508
+ After each entry write operation (`POST`, `PUT`, `DELETE` on `/collections/:slug/entries`), add:
509
+
510
+ ```js
511
+ if (request.params.slug === 'user-types') await invalidateUserTypes();
512
+ ```
513
+
514
+ For entry create (after `return reply.status(201).send(entry);` - add before return):
515
+ ```js
516
+ if (request.params.slug === 'user-types') await invalidateUserTypes();
517
+ return reply.status(201).send(entry);
518
+ ```
519
+
520
+ For entry update:
521
+ ```js
522
+ if (request.params.slug === 'user-types') await invalidateUserTypes();
523
+ return entry;
524
+ ```
525
+
526
+ For entry delete (single):
527
+ ```js
528
+ if (request.params.slug === 'user-types') await invalidateUserTypes();
529
+ return { success: true };
530
+ ```
531
+
532
+ **Step 4: Also protect level-0 entry from delete**
533
+
534
+ In the `DELETE /collections/:slug/entries/:id` handler, add after finding the entry:
535
+
536
+ ```js
537
+ const entry = await getEntry(request.params.slug, request.params.id);
538
+ if (!entry) return reply.status(404).send({ error: 'Entry not found' });
539
+
540
+ if (request.params.slug === 'user-types' && entry.data?.level === 0) {
541
+ return reply.status(403).send({ error: 'Cannot delete the root admin role' });
542
+ }
543
+ ```
544
+
545
+ (You will need to call `getEntry` before deleting - check if the existing delete handler already does this. If not, add the lookup.)
546
+
547
+ **Step 5: Verify user-types entries are readable**
548
+
549
+ ```bash
550
+ curl -s http://localhost/api/collections/user-types/entries \
551
+ -H "Authorization: Bearer YOUR_TOKEN" | head -c 200
552
+ ```
553
+ Expected: JSON with `entries` array containing 4 roles.
554
+
555
+ **Step 6: Commit**
556
+
557
+ ```bash
558
+ git add server/routes/api/collections.js
559
+ git commit -m "feat: invalidate userTypes cache on collection CRUD, protect preset + level-0"
560
+ ```
561
+
562
+ ---
563
+
564
+ ## Task 6: Strip `roles` + `permissions` from `config/auth.json`
565
+
566
+ **Files:**
567
+ - Modify: `config/auth.json`
568
+
569
+ **Step 1: Replace file contents**
570
+
571
+ ```json
572
+ {
573
+ "accessTokenExpiry": "15m",
574
+ "refreshTokenExpiry": "7d",
575
+ "bcryptRounds": 10
576
+ }
577
+ ```
578
+
579
+ **Step 2: Verify server still starts cleanly**
580
+
581
+ ```bash
582
+ curl -s -o /dev/null -w "%{http_code}" http://localhost/
583
+ ```
584
+ Expected: `200`
585
+
586
+ ```bash
587
+ curl -s -o /dev/null -w "%{http_code}" http://localhost/api/auth/setup-status
588
+ ```
589
+ Expected: `200`
590
+
591
+ **Step 3: Commit**
592
+
593
+ ```bash
594
+ git add config/auth.json
595
+ git commit -m "chore: remove static roles/permissions from auth.json (now in user-types collection)"
596
+ ```
597
+
598
+ ---
599
+
600
+ ## Task 7: Update `admin/js/templates/users.html` - tabs structure
601
+
602
+ **Files:**
603
+ - Modify: `admin/js/templates/users.html`
604
+
605
+ **Step 1: Replace file contents**
606
+
607
+ ```html
608
+ <div class="view-header">
609
+ <h1><span data-icon="users"></span> Users</h1>
610
+ <div id="users-header-actions">
611
+ <a href="#/users/new" class="btn btn-primary"><span data-icon="plus"></span> New User</a>
612
+ </div>
613
+ </div>
614
+
615
+ <div class="tabs" id="users-tabs">
616
+ <div class="tab-list">
617
+ <button class="tab-item active" data-tab="users">Users</button>
618
+ <button class="tab-item" data-tab="user-types">User Types</button>
619
+ </div>
620
+ <div class="tab-content">
621
+ <div class="tab-panel active" id="panel-users">
622
+ <div class="card">
623
+ <div class="card-body">
624
+ <div id="users-table"></div>
625
+ </div>
626
+ </div>
627
+ </div>
628
+ <div class="tab-panel" id="panel-user-types">
629
+ <div class="card">
630
+ <div class="card-header" style="display:flex;align-items:center;justify-content:space-between;">
631
+ <strong>User Types</strong>
632
+ <button class="btn btn-primary btn-sm" id="btn-add-user-type">
633
+ <span data-icon="plus"></span> Add User Type
634
+ </button>
635
+ </div>
636
+ <div class="card-body">
637
+ <div id="user-types-table"></div>
638
+ </div>
639
+ </div>
640
+ </div>
641
+ </div>
642
+ </div>
643
+ ```
644
+
645
+ **Step 2: Commit**
646
+
647
+ ```bash
648
+ git add admin/js/templates/users.html
649
+ git commit -m "feat: add tabs to users template (Users + User Types panels)"
650
+ ```
651
+
652
+ ---
653
+
654
+ ## Task 8: Update `admin/js/views/users.js` - tabs + User Types table + modal
655
+
656
+ **Files:**
657
+ - Modify: `admin/js/views/users.js`
658
+
659
+ **Step 1: Replace entire file**
660
+
661
+ ```js
662
+ /**
663
+ * Users View
664
+ * Two tabs: Users (existing table) and User Types (preset collection CRUD).
665
+ */
666
+ import {api, getUser} from '../api.js';
667
+
668
+ const RESOURCES = ['pages', 'settings', 'navigation', 'layouts', 'media', 'users', 'plugins', 'collections'];
669
+
670
+ const BADGE_OPTIONS = [
671
+ { value: 'badge-danger', label: 'Red (Admin)' },
672
+ { value: 'badge-warning', label: 'Orange (Manager)' },
673
+ { value: 'badge-info', label: 'Blue (Editor)' },
674
+ { value: 'badge-secondary', label: 'Grey (Subscriber)' },
675
+ { value: 'badge-success', label: 'Green' },
676
+ { value: 'badge-primary', label: 'Primary' }
677
+ ];
678
+
679
+ export const usersView = {
680
+ templateUrl: '/admin/js/templates/users.html',
681
+
682
+ async onMount($container) {
683
+ // Init tabs
684
+ E.tabs($container.find('#users-tabs').get(0));
685
+
686
+ // Hide "New User" button when on User Types tab
687
+ $container.find('.tab-item[data-tab="user-types"]').on('click', function () {
688
+ $container.find('#users-header-actions').css('display', 'none');
689
+ });
690
+ $container.find('.tab-item[data-tab="users"]').on('click', function () {
691
+ $container.find('#users-header-actions').css('display', '');
692
+ });
693
+
694
+ // -----------------------------------------------------------------------
695
+ // Tab 1: Users
696
+ // -----------------------------------------------------------------------
697
+ const currentUser = getUser();
698
+ let users = await api.users.list().catch(() => []);
699
+
700
+ const renderUsersTable = (data) => {
701
+ T.create('#users-table', {
702
+ data,
703
+ columns: [
704
+ { key: 'name', title: 'Name' },
705
+ { key: 'email', title: 'Email' },
706
+ {
707
+ key: 'role', title: 'Role',
708
+ render: (val) => `<span class="badge badge-secondary">${val}</span>`
709
+ },
710
+ {
711
+ key: 'isActive', title: 'Status',
712
+ render: (val) => val
713
+ ? '<span class="badge badge-success">Active</span>'
714
+ : '<span class="badge badge-secondary">Inactive</span>'
715
+ },
716
+ {
717
+ key: 'lastLogin', title: 'Last login',
718
+ render: (val) => val ? D(val).format('DD MMM YYYY HH:mm') : 'Never'
719
+ },
720
+ {
721
+ key: 'actions', title: 'Actions',
722
+ render: (_, row) => {
723
+ const isSelf = row.id === currentUser?.id;
724
+ return `
725
+ <a href="#/users/edit/${row.id}" class="btn btn-sm btn-outline">Edit</a>
726
+ ${!isSelf ? `<button class="btn btn-sm btn-danger btn-delete-user" data-id="${row.id}" data-name="${escapeAttr(row.name)}">Delete</button>` : ''}
727
+ `;
728
+ }
729
+ }
730
+ ],
731
+ emptyMessage: 'No users found.'
732
+ });
733
+ Domma.icons.scan();
734
+ };
735
+
736
+ renderUsersTable(users);
737
+
738
+ $container.off('click', '.btn-delete-user').on('click', '.btn-delete-user', async function () {
739
+ const id = $(this).data('id');
740
+ const name = $(this).data('name');
741
+ const confirmed = await E.confirm(`Delete user <strong>${name}</strong>? This cannot be undone.`);
742
+ if (!confirmed) return;
743
+ try {
744
+ await api.delete(`/users/${id}`);
745
+ E.toast('User deleted.', { type: 'success' });
746
+ users = users.filter(u => u.id !== id);
747
+ renderUsersTable(users);
748
+ } catch (err) {
749
+ E.toast(`Failed to delete: ${err.message}`, { type: 'error' });
750
+ }
751
+ });
752
+
753
+ // -----------------------------------------------------------------------
754
+ // Tab 2: User Types
755
+ // -----------------------------------------------------------------------
756
+ let userTypes = [];
757
+
758
+ const renderUserTypesTable = (data) => {
759
+ T.create('#user-types-table', {
760
+ data,
761
+ columns: [
762
+ {
763
+ key: 'data', title: 'Label',
764
+ render: (d) => `<span class="badge ${d.badgeClass || 'badge-secondary'}">${d.label}</span>`
765
+ },
766
+ {
767
+ key: 'data', title: 'Name (slug)',
768
+ render: (d) => `<code>${d.name}</code>`
769
+ },
770
+ {
771
+ key: 'data', title: 'Level',
772
+ render: (d) => d.level
773
+ },
774
+ {
775
+ key: 'data', title: 'Permissions',
776
+ render: (d) => (d.permissions || []).join(', ') || '<em>none</em>'
777
+ },
778
+ {
779
+ key: 'id', title: 'Actions',
780
+ render: (id, row) => {
781
+ const isRoot = row.data?.level === 0;
782
+ return `
783
+ <button class="btn btn-sm btn-outline btn-edit-ut" data-id="${id}">Edit</button>
784
+ ${!isRoot ? `<button class="btn btn-sm btn-danger btn-delete-ut" data-id="${id}" data-name="${escapeAttr(row.data?.label)}">Delete</button>` : ''}
785
+ `;
786
+ }
787
+ }
788
+ ],
789
+ emptyMessage: 'No user types found.'
790
+ });
791
+ Domma.icons.scan();
792
+ };
793
+
794
+ const loadUserTypes = async () => {
795
+ const res = await api.get('/collections/user-types/entries?limit=100');
796
+ userTypes = res.entries || [];
797
+ renderUserTypesTable(userTypes);
798
+ };
799
+
800
+ await loadUserTypes();
801
+
802
+ // Build User Type modal form
803
+ const buildUtForm = (entry = null) => {
804
+ const d = entry?.data || {};
805
+
806
+ const form = document.createElement('div');
807
+ form.style.cssText = 'display:flex;flex-direction:column;gap:1rem;padding:1rem;';
808
+
809
+ const field = (label, input) => {
810
+ const wrap = document.createElement('div');
811
+ const lbl = document.createElement('label');
812
+ lbl.textContent = label;
813
+ lbl.style.cssText = 'display:block;font-size:.85rem;margin-bottom:.25rem;font-weight:600;';
814
+ wrap.appendChild(lbl);
815
+ wrap.appendChild(input);
816
+ return wrap;
817
+ };
818
+
819
+ // Name
820
+ const nameInput = document.createElement('input');
821
+ nameInput.type = 'text';
822
+ nameInput.value = d.name || '';
823
+ nameInput.placeholder = 'e.g. moderator';
824
+ nameInput.className = 'form-control';
825
+ if (d.level === 0) nameInput.disabled = true;
826
+
827
+ // Label
828
+ const labelInput = document.createElement('input');
829
+ labelInput.type = 'text';
830
+ labelInput.value = d.label || '';
831
+ labelInput.placeholder = 'e.g. Moderator';
832
+ labelInput.className = 'form-control';
833
+
834
+ // Level
835
+ const levelInput = document.createElement('input');
836
+ levelInput.type = 'number';
837
+ levelInput.value = d.level ?? '';
838
+ levelInput.min = d.level === 0 ? 0 : 1;
839
+ levelInput.className = 'form-control';
840
+ if (d.level === 0) levelInput.disabled = true;
841
+
842
+ // Badge class select
843
+ const badgeSelect = document.createElement('select');
844
+ badgeSelect.className = 'form-control';
845
+ BADGE_OPTIONS.forEach(opt => {
846
+ const o = document.createElement('option');
847
+ o.value = opt.value;
848
+ o.textContent = opt.label;
849
+ if (d.badgeClass === opt.value) o.selected = true;
850
+ badgeSelect.appendChild(o);
851
+ });
852
+
853
+ // Permissions checkboxes
854
+ const permWrap = document.createElement('div');
855
+ permWrap.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:.25rem;';
856
+ RESOURCES.forEach(r => {
857
+ const label = document.createElement('label');
858
+ label.style.cssText = 'display:flex;align-items:center;gap:.4rem;font-size:.875rem;';
859
+ const cb = document.createElement('input');
860
+ cb.type = 'checkbox';
861
+ cb.name = 'perm';
862
+ cb.value = r;
863
+ if ((d.permissions || []).includes(r)) cb.checked = true;
864
+ label.appendChild(cb);
865
+ label.appendChild(document.createTextNode(r));
866
+ permWrap.appendChild(label);
867
+ });
868
+
869
+ // Save button
870
+ const saveBtn = document.createElement('button');
871
+ saveBtn.className = 'btn btn-primary';
872
+ saveBtn.textContent = entry ? 'Update User Type' : 'Create User Type';
873
+
874
+ form.appendChild(field('Name (slug)', nameInput));
875
+ form.appendChild(field('Label', labelInput));
876
+ form.appendChild(field('Level', levelInput));
877
+ form.appendChild(field('Badge Colour', badgeSelect));
878
+ form.appendChild(field('Permissions', permWrap));
879
+ form.appendChild(saveBtn);
880
+
881
+ return { form, nameInput, labelInput, levelInput, badgeSelect, permWrap, saveBtn };
882
+ };
883
+
884
+ const openUtModal = (entry = null) => {
885
+ const { form, nameInput, labelInput, levelInput, badgeSelect, permWrap, saveBtn } = buildUtForm(entry);
886
+ const modal = E.modal({ title: entry ? 'Edit User Type' : 'Add User Type', size: 'md' });
887
+ modal.element.appendChild(form);
888
+ modal.open();
889
+
890
+ saveBtn.addEventListener('click', async () => {
891
+ const name = nameInput.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
892
+ const label = labelInput.value.trim();
893
+ const level = parseInt(levelInput.value, 10);
894
+ const perms = [...permWrap.querySelectorAll('input[name="perm"]:checked')].map(cb => cb.value);
895
+ const badgeClass = badgeSelect.value;
896
+
897
+ if (!name || !label || isNaN(level)) {
898
+ E.toast('Name, label and level are required.', { type: 'error' });
899
+ return;
900
+ }
901
+
902
+ const data = { name, label, level, permissions: perms, badgeClass };
903
+ try {
904
+ if (entry) {
905
+ await api.put(`/collections/user-types/entries/${entry.id}`, { data });
906
+ E.toast('User type updated.', { type: 'success' });
907
+ } else {
908
+ await api.post('/collections/user-types/entries', { data });
909
+ E.toast('User type created.', { type: 'success' });
910
+ }
911
+ modal.close();
912
+ await loadUserTypes();
913
+ } catch (err) {
914
+ E.toast(`Error: ${err.message}`, { type: 'error' });
915
+ }
916
+ });
917
+ };
918
+
919
+ // Add button
920
+ $container.find('#btn-add-user-type').on('click', () => openUtModal());
921
+
922
+ // Edit / Delete delegation via native event (namespaced delegation broken in Domma)
923
+ document.addEventListener('click', async function handler(e) {
924
+ // Clean up when view is unmounted (element removed from DOM)
925
+ if (!document.contains($container.get(0))) {
926
+ document.removeEventListener('click', handler);
927
+ return;
928
+ }
929
+
930
+ const editBtn = e.target.closest('.btn-edit-ut');
931
+ const deleteBtn = e.target.closest('.btn-delete-ut');
932
+
933
+ if (editBtn) {
934
+ const id = editBtn.dataset.id;
935
+ const entry = userTypes.find(ut => ut.id === id);
936
+ if (entry) openUtModal(entry);
937
+ }
938
+
939
+ if (deleteBtn) {
940
+ const id = deleteBtn.dataset.id;
941
+ const name = deleteBtn.dataset.name;
942
+ const confirmed = await E.confirm(`Delete user type <strong>${name}</strong>?`);
943
+ if (!confirmed) return;
944
+ try {
945
+ await api.delete(`/collections/user-types/entries/${id}`);
946
+ E.toast('User type deleted.', { type: 'success' });
947
+ await loadUserTypes();
948
+ } catch (err) {
949
+ E.toast(`Failed: ${err.message}`, { type: 'error' });
950
+ }
951
+ }
952
+ });
953
+ }
954
+ };
955
+
956
+ function escapeAttr(str) {
957
+ return String(str).replace(/"/g, '&quot;');
958
+ }
959
+ ```
960
+
961
+ **Step 2: Verify page loads in browser**
962
+
963
+ Navigate to `http://localhost/admin/#/users` - you should see two tabs. Users tab should show the existing table. User Types tab should show the 4 seed roles.
964
+
965
+ **Step 3: Test add/edit/delete**
966
+
967
+ - Click "Add User Type" - modal should open
968
+ - Fill in name, label, level, permissions → Save
969
+ - New entry appears in table
970
+ - Click Edit on an entry → modal pre-populated
971
+ - Click Delete on non-admin entry → confirm prompt → deleted
972
+
973
+ **Step 4: Commit**
974
+
975
+ ```bash
976
+ git add admin/js/views/users.js
977
+ git commit -m "feat: users view gets User Types tab with full CRUD"
978
+ ```
979
+
980
+ ---
981
+
982
+ ## Task 9: End-to-end verification
983
+
984
+ **Step 1: Confirm auth still works**
985
+
986
+ ```bash
987
+ curl -s -o /dev/null -w "%{http_code}" http://localhost/api/auth/setup-status
988
+ ```
989
+ Expected: `200`
990
+
991
+ **Step 2: Confirm user-types entries accessible**
992
+
993
+ ```bash
994
+ TOKEN=$(curl -s -X POST http://localhost/api/auth/login \
995
+ -H 'Content-Type: application/json' \
996
+ -d '{"email":"YOUR_EMAIL","password":"YOUR_PASS"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
997
+
998
+ curl -s http://localhost/api/collections/user-types/entries \
999
+ -H "Authorization: Bearer $TOKEN" | python3 -m json.tool | head -30
1000
+ ```
1001
+ Expected: JSON with 4 role entries.
1002
+
1003
+ **Step 3: Confirm pages API still works**
1004
+
1005
+ ```bash
1006
+ curl -s -o /dev/null -w "%{http_code}" http://localhost/api/pages \
1007
+ -H "Authorization: Bearer $TOKEN"
1008
+ ```
1009
+ Expected: `200`
1010
+
1011
+ **Step 4: Confirm preset collection protected**
1012
+
1013
+ ```bash
1014
+ curl -s -o /dev/null -w "%{http_code}" -X DELETE \
1015
+ http://localhost/api/collections/user-types \
1016
+ -H "Authorization: Bearer $TOKEN"
1017
+ ```
1018
+ Expected: `403`
1019
+
1020
+ **Step 5: Confirm admin role protected from delete**
1021
+
1022
+ Get the admin entry ID first, then:
1023
+ ```bash
1024
+ curl -s -o /dev/null -w "%{http_code}" -X DELETE \
1025
+ "http://localhost/api/collections/user-types/entries/ADMIN_ENTRY_ID" \
1026
+ -H "Authorization: Bearer $TOKEN"
1027
+ ```
1028
+ Expected: `403`
1029
+
1030
+ **Step 6: Final commit**
1031
+
1032
+ ```bash
1033
+ git add -A
1034
+ git commit -m "feat: User Types complete - dynamic roles via preset collection"
1035
+ ```