domma-cms 0.30.0 → 0.32.1

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 (43) hide show
  1. package/admin/dist/domma/domma-tools.css +3 -3
  2. package/admin/dist/domma/domma-tools.min.js +4 -4
  3. package/admin/js/lib/themes.js +1 -1
  4. package/admin/js/templates/effects.html +752 -752
  5. package/admin/js/templates/forms.html +17 -17
  6. package/admin/js/templates/my-profile.html +17 -17
  7. package/admin/js/templates/role-editor.html +70 -70
  8. package/admin/js/templates/roles.html +10 -10
  9. package/admin/js/views/settings.js +1 -1
  10. package/bin/lib/config-merge.js +44 -44
  11. package/config/site.json +1 -0
  12. package/package.json +2 -2
  13. package/plugins/surveys/admin/templates/audience.html +47 -0
  14. package/plugins/surveys/admin/templates/results.html +58 -0
  15. package/plugins/surveys/admin/templates/survey-editor.html +141 -0
  16. package/plugins/surveys/admin/templates/surveys.html +25 -0
  17. package/plugins/surveys/admin/views/audience.js +301 -0
  18. package/plugins/surveys/admin/views/results.js +172 -0
  19. package/plugins/surveys/admin/views/survey-editor.js +211 -0
  20. package/plugins/surveys/admin/views/surveys.js +161 -0
  21. package/plugins/surveys/collections/survey-contacts/schema.json +13 -0
  22. package/plugins/surveys/collections/survey-groups/schema.json +11 -0
  23. package/plugins/surveys/collections/survey-invites/schema.json +16 -0
  24. package/plugins/surveys/collections/surveys/schema.json +23 -0
  25. package/plugins/surveys/config.js +8 -0
  26. package/plugins/surveys/plugin.js +169 -0
  27. package/plugins/surveys/plugin.json +65 -0
  28. package/plugins/surveys/public/dist-shared.mjs +1 -0
  29. package/plugins/surveys/public/survey.css +1 -0
  30. package/plugins/surveys/public/survey.mjs +1 -0
  31. package/plugins/surveys/templates/survey-page.html +63 -0
  32. package/server/middleware/auth.js +253 -253
  33. package/server/routes/api/auth.js +309 -309
  34. package/server/routes/api/navigation.js +42 -42
  35. package/server/routes/api/settings.js +141 -141
  36. package/server/routes/docs-public.js +43 -0
  37. package/server/routes/public.js +2 -2
  38. package/server/server.js +13 -1
  39. package/{scripts/migrate-docs.js → server/services/docs.js} +204 -126
  40. package/server/services/email.js +167 -167
  41. package/server/services/userProfiles.js +199 -199
  42. package/server/services/users.js +302 -302
  43. package/config/connections.json.bak +0 -9
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "surveys",
3
+ "displayName": "Surveys",
4
+ "version": "1.0.0",
5
+ "description": "Targeted surveys over the Forms engine: shared audiences, tokenised invites, reminders, and per-survey statistics.",
6
+ "author": "Darryl Waterhouse",
7
+ "date": "2026-06-26",
8
+ "icon": "clipboard-list",
9
+ "admin": {
10
+ "sidebar": [
11
+ {
12
+ "id": "surveys",
13
+ "text": "Surveys",
14
+ "icon": "clipboard-list",
15
+ "url": "#/plugins/surveys",
16
+ "section": "#/plugins/surveys"
17
+ }
18
+ ],
19
+ "routes": [
20
+ {
21
+ "path": "/plugins/surveys",
22
+ "view": "plugin-surveys",
23
+ "title": "Surveys - Domma CMS"
24
+ },
25
+ {
26
+ "path": "/plugins/surveys/new",
27
+ "view": "plugin-survey-editor",
28
+ "title": "New Survey - Domma CMS"
29
+ },
30
+ {
31
+ "path": "/plugins/surveys/edit/:slug",
32
+ "view": "plugin-survey-editor",
33
+ "title": "Edit Survey - Domma CMS"
34
+ },
35
+ {
36
+ "path": "/plugins/surveys/audience",
37
+ "view": "plugin-survey-audience",
38
+ "title": "Survey Audience - Domma CMS"
39
+ },
40
+ {
41
+ "path": "/plugins/surveys/results/:slug",
42
+ "view": "plugin-survey-results",
43
+ "title": "Survey Results - Domma CMS"
44
+ }
45
+ ],
46
+ "views": {
47
+ "plugin-surveys": {
48
+ "entry": "surveys/admin/views/surveys.js?v=62e94063",
49
+ "exportName": "surveysView"
50
+ },
51
+ "plugin-survey-editor": {
52
+ "entry": "surveys/admin/views/survey-editor.js?v=322a3ad4",
53
+ "exportName": "surveyEditorView"
54
+ },
55
+ "plugin-survey-audience": {
56
+ "entry": "surveys/admin/views/audience.js?v=66eea1d5",
57
+ "exportName": "audienceView"
58
+ },
59
+ "plugin-survey-results": {
60
+ "entry": "surveys/admin/views/results.js?v=13ceda01",
61
+ "exportName": "resultsView"
62
+ }
63
+ }
64
+ }
65
+ }
@@ -0,0 +1 @@
1
+ export function renderDistribution(a){return'<div class="survey-dist">'+a.map(s=>`<div class="survey-dist-row"><span class="survey-dist-label">${e(s.label)}</span><span class="survey-dist-bar"><span class="survey-dist-fill" data-pct="${s.pct}" style="width:${s.pct}%"></span></span><span class="survey-dist-val">${s.value} (${s.pct}%)</span></div>`).join("")+"</div>"}function e(a){return String(a??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}
@@ -0,0 +1 @@
1
+ .survey-container{max-width:720px;margin:0 auto;padding:2rem 1rem 4rem}.survey-title{margin-bottom:1.5rem}.survey-code{margin-bottom:1.5rem;padding:1rem 1.25rem;border:1px solid var(--dm-border-color, #d0d0d0);border-radius:var(--dm-radius, 8px);background:var(--dm-surface, #fafafa)}.survey-code label{display:block;margin-bottom:.5rem;font-weight:600}.survey-code-row{display:flex;gap:.5rem;align-items:stretch;flex-wrap:wrap}.survey-code-row input{flex:1 1 200px;padding:.5rem .75rem;border:1px solid var(--dm-border-color, #d0d0d0);border-radius:var(--dm-radius, 6px);font:inherit}.survey-state{padding:1.5rem;border-radius:var(--dm-radius, 8px);text-align:center;font-size:1.05rem;line-height:1.5}.survey-state-success{background:var(--dm-success-bg, #e8f6ec);color:var(--dm-success-text, #1c6b34);border:1px solid var(--dm-success-border, #b6e0c2)}.survey-state-error{background:var(--dm-danger-bg, #fcebec);color:var(--dm-danger-text, #8a1f28);border:1px solid var(--dm-danger-border, #f0bfc4)}.survey-state-info{background:var(--dm-surface, #f4f4f4);color:var(--dm-text-muted, #555);border:1px solid var(--dm-border-color, #d8d8d8)}.survey-dist{display:flex;flex-direction:column;gap:.5rem;margin:.75rem 0}.survey-dist-row{display:grid;grid-template-columns:minmax(80px,30%) 1fr auto;align-items:center;gap:.75rem}.survey-dist-label{font-size:.9rem;color:var(--dm-text, #333);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.survey-dist-bar{position:relative;height:.9rem;background:var(--dm-surface-alt, #ececec);border-radius:var(--dm-radius, 6px);overflow:hidden}.survey-dist-fill{position:absolute;inset:0 auto 0 0;height:100%;background:var(--dm-primary, #3b6ef0);border-radius:inherit;transition:width .3s ease}.survey-dist-val{font-size:.85rem;color:var(--dm-text-muted, #666);min-width:3.5rem;text-align:right}.survey-intro{margin:0 0 1.5rem}.survey-footer{margin:1.5rem 0 0;padding-top:1rem;border-top:1px solid var(--dm-border, #e5e7eb);color:var(--dm-text-muted, #6b7280);font-size:.92rem}.survey-intro:empty,.survey-footer:empty{display:none;border:0;margin:0;padding:0}
@@ -0,0 +1 @@
1
+ const SKIP_TYPES=new Set(["page-break","spacer","heading","paragraph","html"]);function mapType(e){switch(e){case"textarea":return"textarea";case"select":case"dropdown":return"select";case"radio":return"radio";case"checkbox":case"checkboxes":return"checkbox";case"email":return"email";case"number":return"number";case"date":return"date";case"tel":case"phone":return"tel";default:return"text"}}function buildBlueprint(e){const r={};return(e||[]).forEach(t=>{!t||!t.name||SKIP_TYPES.has(t.type)||(r[t.name]={type:mapType(t.type),label:t.label||t.name,required:!!t.required,options:t.options})}),r}function toast(e,r){typeof E<"u"&&E&&typeof E.toast=="function"&&E.toast(e,{type:r||"info"})}function clear(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function showState(e,r,t){clear(e);const o=document.createElement("div");o.className="survey-state survey-state-"+(t||"info"),o.textContent=r,e.appendChild(o)}async function submitResponse(e,r,t){if(typeof H<"u"&&H&&typeof H.post=="function")return H.post("/api/plugins/surveys/submit/"+e,{token:r,data:t});const o=await fetch("/api/plugins/surveys/submit/"+e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:r,data:t})}),s=await o.json().catch(()=>({}));if(!o.ok){const n=new Error(s.error||"Submission failed");throw n.body=s,n}return s}async function renderForm(e,r,t,o){let s;try{const i=await fetch("/api/forms/"+r+"/public");if(!i.ok)throw new Error("Form not found");s=await i.json()}catch{showState(e,"This survey form could not be loaded.","error");return}const n=s.fields||[],u=s.settings||{},c=buildBlueprint(n),f=async i=>{try{const a=await submitResponse(t,o,i||{});showState(e,a.message||"Thank you for completing this survey.","success"),toast(a.message||"Response recorded","success")}catch(a){const d=a.body&&a.body.error||a.message||"Submission failed";toast(d,"error"),showState(e,d,"error")}};clear(e),typeof Domma<"u"&&Domma.forms&&typeof Domma.forms.render=="function"?Domma.forms.render(e,c,{},{submitText:u.submitText||"Submit",onSubmit:f}):showState(e,"This survey requires JavaScript components that failed to load.","error")}function startMount(e){const r=e.getAttribute("data-survey-slug"),t=e.getAttribute("data-form-slug");if(!r||!t){showState(e,"This survey is misconfigured.","error");return}let s=new URLSearchParams(window.location.search).get("t");if(s){renderForm(e,t,r,s);return}const n=document.getElementById("survey-code");if(!n){showState(e,"Please use the personalised link from your invitation, or enter your code via the survey page.","info");return}n.style.display="",showState(e,"Enter your code above to begin.","info"),n.addEventListener("submit",u=>{u.preventDefault();const c=n.querySelector('[name="code"]');s=c?c.value.trim():"",s&&(n.style.display="none",clear(e),renderForm(e,t,r,s))})}function start(){const e=new Set,r=document.getElementById("survey-mount");r&&e.add(r),document.querySelectorAll(".survey-mount").forEach(t=>e.add(t)),e.forEach(startMount)}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",start):start();
@@ -0,0 +1,63 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en-GB">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{title}}</title>
7
+ <meta name="robots" content="noindex">
8
+
9
+ <!-- DommaJS CSS -->
10
+ <link rel="stylesheet" href="/dist/domma/domma.css">
11
+ <link rel="stylesheet" href="/dist/domma/grid.css">
12
+ <link rel="stylesheet" href="/dist/domma/elements.css">
13
+ <link rel="stylesheet" href="/dist/domma/themes/domma-themes.css">
14
+
15
+ <!-- Site CSS -->
16
+ <link rel="stylesheet" href="/public/css/site.css">
17
+ <link rel="stylesheet" href="/public/css/forms.css">
18
+
19
+ <!-- Survey CSS -->
20
+ <link rel="stylesheet" href="/plugins/surveys/public/survey.css">
21
+ </head>
22
+ <body class="dm-cloaked">
23
+
24
+ <main class="site-main">
25
+ <article class="site-content">
26
+ <div class="container survey-container">
27
+ <h1 class="survey-title">{{title}}</h1>
28
+
29
+ <form id="survey-code" class="survey-code" style="display:none">
30
+ <label for="survey-code-input">Enter your code</label>
31
+ <div class="survey-code-row">
32
+ <input id="survey-code-input" name="code" type="text" placeholder="Enter your code" autocomplete="off">
33
+ <button type="submit" class="btn btn-primary">Start</button>
34
+ </div>
35
+ </form>
36
+
37
+ <div class="survey-intro">{{intro}}</div>
38
+
39
+ <div id="survey-mount" data-survey-slug="{{slug}}" data-form-slug="{{formSlug}}"></div>
40
+
41
+ <div class="survey-footer">{{footer}}</div>
42
+ </div>
43
+ </article>
44
+ </main>
45
+
46
+ <!-- DOMPurify - must load before DommaJS -->
47
+ <script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
48
+
49
+ <!-- DommaJS -->
50
+ <script src="/dist/domma/domma.min.js"></script>
51
+ <script>
52
+ if (window.Domma && typeof window.Domma.init === 'function') {
53
+ window.Domma.init();
54
+ }
55
+ </script>
56
+
57
+ <!-- Core Forms (logic engine must load before renderer) -->
58
+ <script src="/public/js/form-logic-engine.js"></script>
59
+
60
+ <!-- Survey client -->
61
+ <script type="module" src="/plugins/surveys/public/survey.mjs"></script>
62
+ </body>
63
+ </html>
@@ -1,253 +1,253 @@
1
- /**
2
- * Authentication Middleware
3
- * JWT-based authentication with role guards for Domma CMS.
4
- * Role data is read from the roles cache (not config.auth.roles).
5
- */
6
- import {getPermissionsFor, getPermissionsForRole, getRoleHierarchy, getRoleLevel} from '../services/roles.js';
7
- import {getEffectiveLevel, getEffectiveRoles} from '../services/userRoles.js';
8
-
9
- /**
10
- * Verify JWT Bearer token. Populates request.user on success.
11
- *
12
- * @param {FastifyRequest} request
13
- * @param {FastifyReply} reply
14
- * @returns {Promise<void>}
15
- */
16
- export async function authenticate(request, reply) {
17
- try {
18
- const decoded = await request.jwtVerify();
19
- if (decoded.type !== 'access') {
20
- return reply.code(401).send({
21
- statusCode: 401,
22
- error: 'Unauthorised',
23
- message: 'Invalid token type'
24
- });
25
- }
26
- } catch {
27
- return reply.code(401).send({
28
- statusCode: 401,
29
- error: 'Unauthorised',
30
- message: 'Invalid or missing authentication token'
31
- });
32
- }
33
- }
34
-
35
- /**
36
- * Return a preHandler that enforces one of the specified roles.
37
- * Must be used after authenticate.
38
- *
39
- * @param {string[]} allowedRoles
40
- * @returns {Function}
41
- */
42
- export function requireRole(allowedRoles) {
43
- const allowed = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles];
44
-
45
- return async (request, reply) => {
46
- if (!request.user) {
47
- return reply.code(401).send({
48
- statusCode: 401,
49
- error: 'Unauthorised',
50
- message: 'Authentication required'
51
- });
52
- }
53
-
54
- if (!allowed.includes(request.user.role)) {
55
- return reply.code(403).send({
56
- statusCode: 403,
57
- error: 'Forbidden',
58
- message: `Access denied. Required role: ${allowed.join(' or ')}`
59
- });
60
- }
61
- };
62
- }
63
-
64
- /**
65
- * Return a preHandler that checks the current user's role has access to a resource.
66
- * Reads from the roles cache at request time — reflects live role changes.
67
- *
68
- * @param {string} resource - Resource key (e.g. 'pages', 'users')
69
- * @param {string} [action] - Optional action (read | create | update | delete)
70
- * @returns {Function}
71
- */
72
- export function requirePermission(resource, action) {
73
- return async (request, reply) => {
74
- if (!request.user) {
75
- return reply.code(401).send({
76
- statusCode: 401,
77
- error: 'Unauthorised',
78
- message: 'Authentication required'
79
- });
80
- }
81
-
82
- // Check ANY of the user's effective roles (primary + additional) against
83
- // the resource's permission list — union semantics so a user with both
84
- // candidate and recruiter roles can do either role's actions.
85
- const allowed = getPermissionsFor(resource, action);
86
- const roles = getEffectiveRoles(request.user);
87
- if (!roles.some(r => allowed.includes(r))) {
88
- return reply.code(403).send({
89
- statusCode: 403,
90
- error: 'Forbidden',
91
- message: 'Insufficient permissions'
92
- });
93
- }
94
- };
95
- }
96
-
97
- /**
98
- * Export getPermissionsForRole for use in route handlers.
99
- *
100
- * @param {string} roleName
101
- * @returns {string[]}
102
- */
103
- export {getPermissionsForRole};
104
-
105
- /**
106
- * Shorthand preHandler — admin-tier role (level ≤ 1) or above.
107
- * Matches the base role hierarchy documented in roles.js:
108
- * super-admin (0), admin (1), user (2).
109
- * Both super-admin and admin pass; regular users and anything below do not.
110
- *
111
- * @param {FastifyRequest} request
112
- * @param {FastifyReply} reply
113
- * @returns {Promise<void>}
114
- */
115
- export async function requireAdmin(request, reply) {
116
- if (!request.user) {
117
- return reply.code(401).send({ statusCode: 401, error: 'Unauthorised', message: 'Authentication required' });
118
- }
119
- // Use effective level — a user with "additionalRoles: ['admin']" can do admin work
120
- // even if their primary role is something lower-privilege.
121
- if (getEffectiveLevel(request.user) > 1) {
122
- return reply.code(403).send({ statusCode: 403, error: 'Forbidden', message: 'Admin access required' });
123
- }
124
- }
125
-
126
- /**
127
- * Determine whether an actor can manage a target user.
128
- * Managers cannot create, edit, or delete users with a lower level number (higher privilege).
129
- *
130
- * Accepts either user objects (preferred — uses effective level across all roles)
131
- * or bare role-name strings (legacy compat). Strings are looked up via
132
- * `getRoleLevel`; objects via `getEffectiveLevel` so multi-role actors and
133
- * targets are compared by their HIGHEST-privilege role.
134
- *
135
- * @param {string|object} actor - Role name OR user object
136
- * @param {string|object} target - Role name OR user object
137
- * @returns {boolean}
138
- */
139
- export function canManageUser(actor, target) {
140
- const actorLevel = typeof actor === 'string' ? getRoleLevel(actor) : getEffectiveLevel(actor);
141
- const targetLevel = typeof target === 'string' ? getRoleLevel(target) : getEffectiveLevel(target);
142
- return actorLevel < targetLevel;
143
- }
144
-
145
- /**
146
- * Check whether a user role satisfies a visibility requirement.
147
- * Used by both requireVisibility() and the public page renderer.
148
- *
149
- * Visibility may be either:
150
- * - A single string ('public', 'private', or a role name)
151
- * - An array of role names — granted if ANY entry passes the per-role check
152
- *
153
- * Per-role semantics are unchanged: each role check passes if the visitor's
154
- * role level is at or above the required role (lower or equal level number).
155
- * 'private' resolves to super-admin only (Infinity → level 0).
156
- *
157
- * The "any of" semantics for arrays means siblings at different tiers of the
158
- * hierarchy are all granted access — e.g. `visibility: [candidate, employer]`
159
- * lets both roles in, plus anyone more privileged than either (typically
160
- * admins inherit access automatically via the level comparison).
161
- *
162
- * @param {string|null} userRole - The visitor's role, or null if unauthenticated
163
- * @param {string|string[]} visibility - Required visibility — single value or array
164
- * @returns {boolean} true if access is granted
165
- */
166
- export function checkVisibility(userRoleOrObj, visibility) {
167
- if (!visibility) return true;
168
-
169
- // Accept either a bare role name (legacy) or a user object with multi-role
170
- // support. When given an object we walk every effective role and grant
171
- // access if ANY satisfies — same union semantics as permissions.
172
- const roles = typeof userRoleOrObj === 'string'
173
- ? (userRoleOrObj ? [userRoleOrObj] : [])
174
- : getEffectiveRoles(userRoleOrObj);
175
-
176
- if (Array.isArray(visibility)) {
177
- if (visibility.length === 0) return true;
178
- if (visibility.includes('public')) return true;
179
- if (!roles.length) return false;
180
- return roles.some(r => visibility.some(v => checkSingleVisibility(r, v)));
181
- }
182
-
183
- if (visibility === 'public') return true;
184
- if (!roles.length) return false;
185
- return roles.some(r => checkSingleVisibility(r, visibility));
186
- }
187
-
188
- /**
189
- * Internal helper — single-role visibility check.
190
- * Returns true if the user role is at or above the required role's level.
191
- *
192
- * @param {string} userRole - Must not be null
193
- * @param {string} visibility - Single visibility token (role name or 'private')
194
- * @returns {boolean}
195
- */
196
- function checkSingleVisibility(userRole, visibility) {
197
- const userLevel = getRoleLevel(userRole);
198
- const requiredLevel = getRoleLevel(visibility);
199
- const threshold = requiredLevel === Infinity ? 0 : requiredLevel;
200
- return userLevel <= threshold;
201
- }
202
-
203
- /**
204
- * Fastify preHandler factory — gates a route by visibility level.
205
- * Works identically to the content-page visibility system; accepts the same
206
- * single-string or array-of-roles syntax as checkVisibility().
207
- *
208
- * Returns a no-op for 'public' (or any array containing 'public') so it is
209
- * safe to apply unconditionally.
210
- *
211
- * @param {string|string[]} visibility - 'public' | 'private' | role name | array of role names
212
- * @returns {Function} Fastify preHandler
213
- */
214
- export function requireVisibility(visibility) {
215
- const isPublic = !visibility
216
- || visibility === 'public'
217
- || (Array.isArray(visibility) && (visibility.length === 0 || visibility.includes('public')));
218
-
219
- if (isPublic) {
220
- return (_request, _reply, done) => { if (done) done(); };
221
- }
222
-
223
- return async (request, reply) => {
224
- // Build a user-shaped object so checkVisibility can see multi-role
225
- // (primary + additional). Unauthenticated → null → public-only access.
226
- let userObj = null;
227
- try {
228
- const decoded = await request.jwtVerify();
229
- if (decoded.type === 'access') {
230
- userObj = { role: decoded.role, additionalRoles: decoded.additionalRoles || [] };
231
- }
232
- } catch { /* unauthenticated */ }
233
-
234
- if (!checkVisibility(userObj, visibility)) {
235
- const code = userObj ? 403 : 401;
236
- return reply.code(code).send({
237
- statusCode: code,
238
- error: code === 403 ? 'Forbidden' : 'Unauthorised',
239
- message: code === 403 ? 'Insufficient role for this resource' : 'Authentication required'
240
- });
241
- }
242
- };
243
- }
244
-
245
- /**
246
- * Return role names ordered from most to least privileged.
247
- * Computed from the roles cache.
248
- *
249
- * @returns {string[]}
250
- */
251
- export function getRoleHierarchyList() {
252
- return getRoleHierarchy();
253
- }
1
+ /**
2
+ * Authentication Middleware
3
+ * JWT-based authentication with role guards for Domma CMS.
4
+ * Role data is read from the roles cache (not config.auth.roles).
5
+ */
6
+ import {getPermissionsFor, getPermissionsForRole, getRoleHierarchy, getRoleLevel} from '../services/roles.js';
7
+ import {getEffectiveLevel, getEffectiveRoles} from '../services/userRoles.js';
8
+
9
+ /**
10
+ * Verify JWT Bearer token. Populates request.user on success.
11
+ *
12
+ * @param {FastifyRequest} request
13
+ * @param {FastifyReply} reply
14
+ * @returns {Promise<void>}
15
+ */
16
+ export async function authenticate(request, reply) {
17
+ try {
18
+ const decoded = await request.jwtVerify();
19
+ if (decoded.type !== 'access') {
20
+ return reply.code(401).send({
21
+ statusCode: 401,
22
+ error: 'Unauthorised',
23
+ message: 'Invalid token type'
24
+ });
25
+ }
26
+ } catch {
27
+ return reply.code(401).send({
28
+ statusCode: 401,
29
+ error: 'Unauthorised',
30
+ message: 'Invalid or missing authentication token'
31
+ });
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Return a preHandler that enforces one of the specified roles.
37
+ * Must be used after authenticate.
38
+ *
39
+ * @param {string[]} allowedRoles
40
+ * @returns {Function}
41
+ */
42
+ export function requireRole(allowedRoles) {
43
+ const allowed = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles];
44
+
45
+ return async (request, reply) => {
46
+ if (!request.user) {
47
+ return reply.code(401).send({
48
+ statusCode: 401,
49
+ error: 'Unauthorised',
50
+ message: 'Authentication required'
51
+ });
52
+ }
53
+
54
+ if (!allowed.includes(request.user.role)) {
55
+ return reply.code(403).send({
56
+ statusCode: 403,
57
+ error: 'Forbidden',
58
+ message: `Access denied. Required role: ${allowed.join(' or ')}`
59
+ });
60
+ }
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Return a preHandler that checks the current user's role has access to a resource.
66
+ * Reads from the roles cache at request time — reflects live role changes.
67
+ *
68
+ * @param {string} resource - Resource key (e.g. 'pages', 'users')
69
+ * @param {string} [action] - Optional action (read | create | update | delete)
70
+ * @returns {Function}
71
+ */
72
+ export function requirePermission(resource, action) {
73
+ return async (request, reply) => {
74
+ if (!request.user) {
75
+ return reply.code(401).send({
76
+ statusCode: 401,
77
+ error: 'Unauthorised',
78
+ message: 'Authentication required'
79
+ });
80
+ }
81
+
82
+ // Check ANY of the user's effective roles (primary + additional) against
83
+ // the resource's permission list — union semantics so a user with both
84
+ // candidate and recruiter roles can do either role's actions.
85
+ const allowed = getPermissionsFor(resource, action);
86
+ const roles = getEffectiveRoles(request.user);
87
+ if (!roles.some(r => allowed.includes(r))) {
88
+ return reply.code(403).send({
89
+ statusCode: 403,
90
+ error: 'Forbidden',
91
+ message: 'Insufficient permissions'
92
+ });
93
+ }
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Export getPermissionsForRole for use in route handlers.
99
+ *
100
+ * @param {string} roleName
101
+ * @returns {string[]}
102
+ */
103
+ export {getPermissionsForRole};
104
+
105
+ /**
106
+ * Shorthand preHandler — admin-tier role (level ≤ 1) or above.
107
+ * Matches the base role hierarchy documented in roles.js:
108
+ * super-admin (0), admin (1), user (2).
109
+ * Both super-admin and admin pass; regular users and anything below do not.
110
+ *
111
+ * @param {FastifyRequest} request
112
+ * @param {FastifyReply} reply
113
+ * @returns {Promise<void>}
114
+ */
115
+ export async function requireAdmin(request, reply) {
116
+ if (!request.user) {
117
+ return reply.code(401).send({ statusCode: 401, error: 'Unauthorised', message: 'Authentication required' });
118
+ }
119
+ // Use effective level — a user with "additionalRoles: ['admin']" can do admin work
120
+ // even if their primary role is something lower-privilege.
121
+ if (getEffectiveLevel(request.user) > 1) {
122
+ return reply.code(403).send({ statusCode: 403, error: 'Forbidden', message: 'Admin access required' });
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Determine whether an actor can manage a target user.
128
+ * Managers cannot create, edit, or delete users with a lower level number (higher privilege).
129
+ *
130
+ * Accepts either user objects (preferred — uses effective level across all roles)
131
+ * or bare role-name strings (legacy compat). Strings are looked up via
132
+ * `getRoleLevel`; objects via `getEffectiveLevel` so multi-role actors and
133
+ * targets are compared by their HIGHEST-privilege role.
134
+ *
135
+ * @param {string|object} actor - Role name OR user object
136
+ * @param {string|object} target - Role name OR user object
137
+ * @returns {boolean}
138
+ */
139
+ export function canManageUser(actor, target) {
140
+ const actorLevel = typeof actor === 'string' ? getRoleLevel(actor) : getEffectiveLevel(actor);
141
+ const targetLevel = typeof target === 'string' ? getRoleLevel(target) : getEffectiveLevel(target);
142
+ return actorLevel < targetLevel;
143
+ }
144
+
145
+ /**
146
+ * Check whether a user role satisfies a visibility requirement.
147
+ * Used by both requireVisibility() and the public page renderer.
148
+ *
149
+ * Visibility may be either:
150
+ * - A single string ('public', 'private', or a role name)
151
+ * - An array of role names — granted if ANY entry passes the per-role check
152
+ *
153
+ * Per-role semantics are unchanged: each role check passes if the visitor's
154
+ * role level is at or above the required role (lower or equal level number).
155
+ * 'private' resolves to super-admin only (Infinity → level 0).
156
+ *
157
+ * The "any of" semantics for arrays means siblings at different tiers of the
158
+ * hierarchy are all granted access — e.g. `visibility: [candidate, employer]`
159
+ * lets both roles in, plus anyone more privileged than either (typically
160
+ * admins inherit access automatically via the level comparison).
161
+ *
162
+ * @param {string|null} userRole - The visitor's role, or null if unauthenticated
163
+ * @param {string|string[]} visibility - Required visibility — single value or array
164
+ * @returns {boolean} true if access is granted
165
+ */
166
+ export function checkVisibility(userRoleOrObj, visibility) {
167
+ if (!visibility) return true;
168
+
169
+ // Accept either a bare role name (legacy) or a user object with multi-role
170
+ // support. When given an object we walk every effective role and grant
171
+ // access if ANY satisfies — same union semantics as permissions.
172
+ const roles = typeof userRoleOrObj === 'string'
173
+ ? (userRoleOrObj ? [userRoleOrObj] : [])
174
+ : getEffectiveRoles(userRoleOrObj);
175
+
176
+ if (Array.isArray(visibility)) {
177
+ if (visibility.length === 0) return true;
178
+ if (visibility.includes('public')) return true;
179
+ if (!roles.length) return false;
180
+ return roles.some(r => visibility.some(v => checkSingleVisibility(r, v)));
181
+ }
182
+
183
+ if (visibility === 'public') return true;
184
+ if (!roles.length) return false;
185
+ return roles.some(r => checkSingleVisibility(r, visibility));
186
+ }
187
+
188
+ /**
189
+ * Internal helper — single-role visibility check.
190
+ * Returns true if the user role is at or above the required role's level.
191
+ *
192
+ * @param {string} userRole - Must not be null
193
+ * @param {string} visibility - Single visibility token (role name or 'private')
194
+ * @returns {boolean}
195
+ */
196
+ function checkSingleVisibility(userRole, visibility) {
197
+ const userLevel = getRoleLevel(userRole);
198
+ const requiredLevel = getRoleLevel(visibility);
199
+ const threshold = requiredLevel === Infinity ? 0 : requiredLevel;
200
+ return userLevel <= threshold;
201
+ }
202
+
203
+ /**
204
+ * Fastify preHandler factory — gates a route by visibility level.
205
+ * Works identically to the content-page visibility system; accepts the same
206
+ * single-string or array-of-roles syntax as checkVisibility().
207
+ *
208
+ * Returns a no-op for 'public' (or any array containing 'public') so it is
209
+ * safe to apply unconditionally.
210
+ *
211
+ * @param {string|string[]} visibility - 'public' | 'private' | role name | array of role names
212
+ * @returns {Function} Fastify preHandler
213
+ */
214
+ export function requireVisibility(visibility) {
215
+ const isPublic = !visibility
216
+ || visibility === 'public'
217
+ || (Array.isArray(visibility) && (visibility.length === 0 || visibility.includes('public')));
218
+
219
+ if (isPublic) {
220
+ return (_request, _reply, done) => { if (done) done(); };
221
+ }
222
+
223
+ return async (request, reply) => {
224
+ // Build a user-shaped object so checkVisibility can see multi-role
225
+ // (primary + additional). Unauthenticated → null → public-only access.
226
+ let userObj = null;
227
+ try {
228
+ const decoded = await request.jwtVerify();
229
+ if (decoded.type === 'access') {
230
+ userObj = { role: decoded.role, additionalRoles: decoded.additionalRoles || [] };
231
+ }
232
+ } catch { /* unauthenticated */ }
233
+
234
+ if (!checkVisibility(userObj, visibility)) {
235
+ const code = userObj ? 403 : 401;
236
+ return reply.code(code).send({
237
+ statusCode: code,
238
+ error: code === 403 ? 'Forbidden' : 'Unauthorised',
239
+ message: code === 403 ? 'Insufficient role for this resource' : 'Authentication required'
240
+ });
241
+ }
242
+ };
243
+ }
244
+
245
+ /**
246
+ * Return role names ordered from most to least privileged.
247
+ * Computed from the roles cache.
248
+ *
249
+ * @returns {string[]}
250
+ */
251
+ export function getRoleHierarchyList() {
252
+ return getRoleHierarchy();
253
+ }