domma-cms 1.6.60 → 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.
- package/CHANGELOG.md +4344 -0
- package/README.md +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/lib/effect-defs.js +1 -1
- package/admin/js/lib/effects-builder.js +2 -2
- package/admin/js/lib/page-editor-reference.js +5 -5
- package/admin/js/lib/shortcode-tags.js +1 -1
- package/admin/js/templates/docs/usage-shortcodes.html +39 -0
- package/docs/DommaFeatures.md +3005 -0
- package/docs/MultiTenant.md +176 -0
- package/docs/api-reference.md +1667 -0
- package/docs/cache.md +96 -0
- package/docs/configuration.md +1811 -0
- package/docs/editions-and-licences.md +190 -0
- package/docs/getting-started.md +201 -0
- package/docs/guides/form-triggers.md +260 -0
- package/docs/guides/mongodb-storage-adapter.md +282 -0
- package/docs/guides/multi-page-forms.md +121 -0
- package/docs/images/scribe-composer.png +0 -0
- package/docs/markdown-shortcodes.md +1738 -0
- package/docs/pentest-report-2026-04-03.md +181 -0
- package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
- package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
- package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
- package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
- package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
- package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
- package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
- package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
- package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
- package/docs/plans/2026-03-05-user-types-design.md +139 -0
- package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
- package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
- package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
- package/docs/plans/2026-06-10-core-project-design.md +240 -0
- package/docs/plugin-development.md +827 -0
- package/docs/recruitment-recipe.md +501 -0
- package/docs/scaffolding.md +312 -0
- package/docs/screenshots/calculator-floating.png +0 -0
- package/docs/screenshots/calculator-standard.png +0 -0
- package/docs/screenshots/email-settings.png +0 -0
- package/docs/screenshots/messages-count.png +0 -0
- package/docs/structure/user-types.md +107 -0
- package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
- package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
- package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
- package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
- package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
- package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
- package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
- package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
- package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
- package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
- package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
- package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
- package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
- package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
- package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
- package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
- package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
- package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
- package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
- package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
- package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
- package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
- package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
- package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
- package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
- package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
- package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
- package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
- package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
- package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
- package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
- package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
- package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
- package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
- package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
- package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
- package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
- package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
- package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
- package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
- package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
- package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
- package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
- package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
- package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
- package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
- package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
- package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
- package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
- package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
- package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
- package/docs/theming.md +320 -0
- package/package.json +2 -2
- package/public/js/celebrations/core/canvas.js +1 -1
- package/public/js/celebrations/index.js +1 -1
- package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
- package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
- package/public/js/celebrations/message-attrs.mjs +1 -0
- package/public/js/celebrations/themes/anniversary.js +1 -0
- package/public/js/celebrations/themes/autumn.js +1 -0
- package/public/js/celebrations/themes/birthday.js +1 -0
- package/public/js/celebrations/themes/butterfly-flight.js +1 -0
- package/public/js/celebrations/themes/calendar.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year.js +1 -0
- package/public/js/celebrations/themes/christmas.js +1 -1
- package/public/js/celebrations/themes/easter.js +1 -0
- package/public/js/celebrations/themes/garden-drawing.js +1 -0
- package/public/js/celebrations/themes/garden.js +1 -0
- package/public/js/celebrations/themes/graduation.js +1 -0
- package/public/js/celebrations/themes/greeting.js +1 -0
- package/public/js/celebrations/themes/make-it-rain.js +1 -0
- package/public/js/celebrations/themes/money-rain.js +1 -0
- package/public/js/celebrations/themes/new-year.js +1 -0
- package/public/js/celebrations/themes/occasion-drawing.js +1 -0
- package/public/js/celebrations/themes/occasions.js +1 -0
- package/public/js/celebrations/themes/rain-control.js +1 -0
- package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
- package/public/js/celebrations/themes/sparkler-font.js +1 -0
- package/public/js/celebrations/themes/sparkler-writing.js +1 -0
- package/public/js/celebrations/themes/summer-garden.js +1 -0
- package/public/js/celebrations/themes/winter.js +1 -0
- package/public/js/celebrations/trait-data.mjs +1 -1
- package/public/js/effects.js +1 -1
- package/scripts/build.js +4 -0
- package/server/services/markdown.js +5 -2
- 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, '"');
|
|
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
|
+
```
|