domma-cms 0.2.1 → 0.3.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 (70) hide show
  1. package/admin/css/admin.css +1 -1200
  2. package/admin/js/api.js +1 -242
  3. package/admin/js/app.js +5 -279
  4. package/admin/js/config/sidebar-config.js +1 -115
  5. package/admin/js/lib/card.js +1 -63
  6. package/admin/js/lib/image-editor.js +1 -869
  7. package/admin/js/lib/markdown-toolbar.js +46 -421
  8. package/admin/js/templates/layouts.html +44 -7
  9. package/admin/js/templates/page-editor.html +9 -0
  10. package/admin/js/templates/settings.html +18 -1
  11. package/admin/js/templates/users.html +29 -4
  12. package/admin/js/views/collection-editor.js +3 -487
  13. package/admin/js/views/collection-entries.js +1 -484
  14. package/admin/js/views/collections.js +1 -153
  15. package/admin/js/views/dashboard.js +1 -56
  16. package/admin/js/views/documentation.js +1 -12
  17. package/admin/js/views/index.js +1 -39
  18. package/admin/js/views/layouts.js +9 -42
  19. package/admin/js/views/login.js +7 -251
  20. package/admin/js/views/media.js +1 -240
  21. package/admin/js/views/navigation.js +14 -212
  22. package/admin/js/views/page-editor.js +53 -661
  23. package/admin/js/views/pages.js +5 -72
  24. package/admin/js/views/plugins.js +13 -90
  25. package/admin/js/views/settings.js +1 -199
  26. package/admin/js/views/tutorials.js +1 -12
  27. package/admin/js/views/user-editor.js +1 -88
  28. package/admin/js/views/users.js +7 -76
  29. package/config/auth.json +1 -17
  30. package/config/navigation.json +15 -0
  31. package/config/site.json +5 -4
  32. package/package.json +1 -1
  33. package/plugins/domma-effects/public/celebrations/core/canvas.js +2 -104
  34. package/plugins/domma-effects/public/celebrations/core/particles.js +1 -144
  35. package/plugins/domma-effects/public/celebrations/core/physics.js +1 -166
  36. package/plugins/domma-effects/public/celebrations/index.js +1 -535
  37. package/plugins/domma-effects/public/celebrations/themes/christmas.js +1 -1805
  38. package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1 -1477
  39. package/plugins/domma-effects/public/celebrations/themes/halloween.js +1 -1837
  40. package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1 -1175
  41. package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1 -1258
  42. package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1 -1754
  43. package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1 -1290
  44. package/plugins/domma-effects/public/celebrations/themes/valentines.js +1 -1361
  45. package/plugins/example-analytics/stats.json +16 -12
  46. package/plugins/form-builder/admin/templates/form-editor.html +158 -130
  47. package/plugins/form-builder/admin/views/form-editor.js +3 -1
  48. package/plugins/form-builder/data/forms/contact-details.json +71 -35
  49. package/plugins/form-builder/data/forms/feedback.json +130 -0
  50. package/plugins/form-builder/data/submissions/feedback.json +1 -0
  51. package/plugins/form-builder/public/form-logic-engine.js +1 -568
  52. package/public/css/site.css +1 -302
  53. package/public/js/btt.js +1 -90
  54. package/public/js/cookie-consent.js +1 -61
  55. package/public/js/site.js +1 -204
  56. package/scripts/setup.js +4 -4
  57. package/server/middleware/auth.js +44 -21
  58. package/server/routes/api/auth.js +38 -8
  59. package/server/routes/api/collections.js +18 -5
  60. package/server/routes/api/layouts.js +18 -4
  61. package/server/routes/api/media.js +2 -3
  62. package/server/routes/api/navigation.js +2 -3
  63. package/server/routes/api/pages.js +3 -3
  64. package/server/routes/api/settings.js +2 -3
  65. package/server/routes/api/users.js +4 -6
  66. package/server/routes/public.js +3 -3
  67. package/server/server.js +8 -0
  68. package/server/services/markdown.js +102 -3
  69. package/server/services/userTypes.js +167 -0
  70. package/plugins/form-builder/email.js +0 -103
package/public/js/site.js CHANGED
@@ -1,204 +1 @@
1
- /**
2
- * Public Site - Client-side Domma init
3
- * Reads config injected by the server and initialises navbar, footer, and features.
4
- */
5
-
6
- $(() => {
7
- const nav = window.__CMS_NAV__ || {};
8
- const site = window.__CMS_SITE__ || {};
9
-
10
- // Initialise navbar
11
- const $navbar = $('#site-navbar');
12
- if ($navbar.length && nav.brand) {
13
- // Build brand.html when an icon is configured — Domma navbar doesn't natively support data-icon spans
14
- const brand = {...nav.brand};
15
- if (brand.icon) {
16
- let html = `<span data-icon="${brand.icon}" style="width:1.1em;height:1.1em;margin-right:.35em;vertical-align:middle;"></span>`;
17
- if (brand.text) html += `<span class="navbar-brand-text">${brand.text}</span>`;
18
- brand.html = html;
19
- }
20
- Domma.elements.navbar('#site-navbar', {
21
- brand,
22
- items: nav.items || [],
23
- variant: nav.variant || 'dark',
24
- position: nav.position || 'sticky',
25
- collapsible: true
26
- });
27
- Domma.icons.scan('#site-navbar');
28
- }
29
-
30
- // Initialise footer
31
- const $footer = $('#site-footer');
32
- if ($footer.length) {
33
- const social = site.social || {};
34
- const SOCIAL_ICONS = {
35
- twitter: {
36
- label: 'X / Twitter',
37
- svg: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.742l7.73-8.835L1.254 2.25H8.08l4.259 5.629L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>'
38
- },
39
- facebook: {
40
- label: 'Facebook',
41
- svg: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>'
42
- },
43
- instagram: {
44
- label: 'Instagram',
45
- svg: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg>'
46
- },
47
- linkedin: {
48
- label: 'LinkedIn',
49
- svg: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>'
50
- },
51
- github: {
52
- label: 'GitHub',
53
- svg: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>'
54
- },
55
- youtube: {
56
- label: 'YouTube',
57
- svg: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.495 6.205a3.007 3.007 0 00-2.088-2.088c-1.87-.501-9.396-.501-9.396-.501s-7.507-.01-9.396.501A3.007 3.007 0 00.527 6.205a31.247 31.247 0 00-.522 5.805 31.247 31.247 0 00.522 5.783 3.007 3.007 0 002.088 2.088c1.868.502 9.396.502 9.396.502s7.506 0 9.396-.502a3.007 3.007 0 002.088-2.088 31.247 31.247 0 00.5-5.783 31.247 31.247 0 00-.5-5.805zM9.609 15.601V8.408l6.264 3.602z"/></svg>'
58
- }
59
- };
60
-
61
- let html = '<div class="footer-inner container">';
62
-
63
- if (site.footer) {
64
- const footer = site.footer;
65
- html += `<p>${footer.copyright || ''}</p>`;
66
- if (footer.links?.length) {
67
- html += `<nav class="footer-links">`;
68
- footer.links.forEach(link => {
69
- html += `<a href="${link.url}">${link.text}</a>`;
70
- });
71
- html += `</nav>`;
72
- }
73
- const activeSocial = Object.keys(SOCIAL_ICONS).filter(k => social[k]);
74
- if (activeSocial.length) {
75
- html += `<div class="footer-social">`;
76
- activeSocial.forEach(k => {
77
- const {label, svg} = SOCIAL_ICONS[k];
78
- html += `<a href="${social[k]}" target="_blank" rel="noopener noreferrer" aria-label="${label}" class="footer-social-link">${svg}</a>`;
79
- });
80
- html += `</div>`;
81
- }
82
- }
83
-
84
- const storedPref = S.get('reduced_motion');
85
- const motionOn = storedPref !== null
86
- ? !!storedPref
87
- : !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
88
- html += `<label class="form-switch footer-motion-switch" title="Reduce motion">` +
89
- `<input type="checkbox" class="form-switch-input" id="dm-motion-switch"${motionOn ? ' checked' : ''}>` +
90
- `<span class="form-switch-label">Reduce motion</span>` +
91
- `</label>`;
92
-
93
- html += '</div>';
94
- $footer.html(html, { safe: false });
95
-
96
- document.getElementById('dm-motion-switch').addEventListener('change', function () {
97
- S.set('reduced_motion', this.checked);
98
- window.location.reload();
99
- });
100
- }
101
-
102
- // Initialise sidebar (auto-generate from headings if enabled)
103
- const $sidebar = $('#site-sidebar');
104
- if ($sidebar.length) {
105
- Domma.elements.sidebar('#site-sidebar', {
106
- autoGenerate: true,
107
- selector: 'h2, h3',
108
- collapsible: false,
109
- push: true,
110
- contentSelector: '.site-content'
111
- });
112
- }
113
-
114
- // Icons
115
- Domma.icons.scan();
116
-
117
- // -------------------------------------------------------
118
- // Auto-initialise Domma components inside page content
119
- // -------------------------------------------------------
120
- const $pageBody = $('.page-body');
121
- if ($pageBody.length) {
122
-
123
- // Accordion — reads data-multi attribute set by [accordion multiple="true"] shortcode
124
- $pageBody.find('.accordion').each(function () {
125
- Domma.elements.accordion(this, {
126
- allowMultiple: this.dataset.multi === 'true'
127
- });
128
- });
129
-
130
- // Tabs
131
- $pageBody.find('.tabs').each(function () {
132
- Domma.elements.tabs(this);
133
- });
134
-
135
- // Carousel — reads data attributes set by [carousel] shortcode
136
- $pageBody.find('.carousel').each(function () {
137
- Domma.elements.carousel(this, {
138
- autoplay: this.dataset.autoplay === 'true',
139
- interval: parseInt(this.dataset.interval, 10) || 5000,
140
- loop: this.dataset.loop !== 'false',
141
- animation: this.dataset.animation || 'slide'
142
- });
143
- });
144
-
145
- // Countdown — initialises .dm-countdown elements via E.timer()
146
- $pageBody.find('.dm-countdown').each(function () {
147
- const opts = {autoStart: true};
148
- if (this.dataset.to) opts.targetDate = new Date(this.dataset.to);
149
- if (this.dataset.duration) opts.duration = parseInt(this.dataset.duration, 10);
150
- if (this.dataset.format) opts.format = this.dataset.format;
151
- Domma.elements.timer(this, opts);
152
- });
153
-
154
- // Tooltips
155
- $pageBody.find('[data-tooltip]').each(function () {
156
- Domma.elements.tooltip(this, {
157
- content: $(this).data('tooltip'),
158
- position: $(this).data('tooltip-position') || 'top'
159
- });
160
- });
161
-
162
- // Collapsible cards
163
- $pageBody.find('.card[data-collapsible]').each(function () {
164
- const header = this.querySelector('.card-header');
165
- if (header) {
166
- header.addEventListener('click', () => this.classList.toggle('is-collapsed'));
167
- }
168
- });
169
-
170
- // Slideover triggers — wired from [slideover] shortcode
171
- $pageBody.find('.dm-so-trigger').each(function () {
172
- this.addEventListener('click', () => {
173
- const targetId = this.dataset.soTarget;
174
- const contentEl = document.getElementById(targetId);
175
- if (!contentEl) return;
176
- const so = E.slideover({
177
- title: contentEl.dataset.soTitle || '',
178
- size: contentEl.dataset.soSize || 'md',
179
- position: contentEl.dataset.soPosition || 'right'
180
- });
181
- contentEl.style.display = '';
182
- so.setContent(contentEl);
183
- so.open();
184
- });
185
- });
186
- }
187
-
188
- // DConfig — merge window.__CMS_DCONFIG__ (editor section) with inline [dconfig] shortcodes
189
- // Inline shortcodes win on selector conflict (assigned last via Object.assign)
190
- if (typeof $.setup === 'function') {
191
- const merged = Object.assign({}, window.__CMS_DCONFIG__ || {});
192
- document.querySelectorAll('.dm-page-config[data-config]').forEach(el => {
193
- try {
194
- const decoded = atob(el.dataset.config);
195
- const parsed = JSON.parse(decoded);
196
- Object.assign(merged, parsed);
197
- } catch { /* skip malformed blocks */
198
- }
199
- });
200
- if (Object.keys(merged).length) {
201
- $.setup(merged);
202
- }
203
- }
204
- });
1
+ $(()=>{const r=window.__CMS_NAV__||{},h=window.__CMS_SITE__||{};if($("#site-navbar").length&&r.brand){const t={...r.brand};if(t.icon){let a=`<span data-icon="${t.icon}" style="width:1.1em;height:1.1em;margin-right:.35em;vertical-align:middle;"></span>`;t.text&&(a+=`<span class="navbar-brand-text">${t.text}</span>`),t.html=a}Domma.elements.navbar("#site-navbar",{brand:t,items:r.items||[],variant:r.variant||"dark",position:r.position||"sticky",collapsible:!0}),Domma.icons.scan("#site-navbar")}const m=$("#site-footer");if(m.length){const t=h.social||{},a={twitter:{label:"X / Twitter",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.742l7.73-8.835L1.254 2.25H8.08l4.259 5.629L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>'},facebook:{label:"Facebook",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>'},instagram:{label:"Instagram",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg>'},linkedin:{label:"LinkedIn",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>'},github:{label:"GitHub",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>'},youtube:{label:"YouTube",svg:'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.495 6.205a3.007 3.007 0 00-2.088-2.088c-1.87-.501-9.396-.501-9.396-.501s-7.507-.01-9.396.501A3.007 3.007 0 00.527 6.205a31.247 31.247 0 00-.522 5.805 31.247 31.247 0 00.522 5.783 3.007 3.007 0 002.088 2.088c1.868.502 9.396.502 9.396.502s7.506 0 9.396-.502a3.007 3.007 0 002.088-2.088 31.247 31.247 0 00.5-5.783 31.247 31.247 0 00-.5-5.805zM9.609 15.601V8.408l6.264 3.602z"/></svg>'}};let e='<div class="footer-inner container">';if(h.footer){const c=h.footer;e+=`<p>${c.copyright||""}</p>`,c.links?.length&&(e+='<nav class="footer-links">',c.links.forEach(o=>{e+=`<a href="${o.url}">${o.text}</a>`}),e+="</nav>");const l=Object.keys(a).filter(o=>t[o]);l.length&&(e+='<div class="footer-social">',l.forEach(o=>{const{label:s,svg:g}=a[o];e+=`<a href="${t[o]}" target="_blank" rel="noopener noreferrer" aria-label="${s}" class="footer-social-link">${g}</a>`}),e+="</div>")}const i=S.get("reduced_motion"),f=i!==null?!!i:!!(window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches);e+=`<label class="form-switch footer-motion-switch" title="Reduce motion"><input type="checkbox" class="form-switch-input" id="dm-motion-switch"${f?" checked":""}><span class="form-switch-label">Reduce motion</span></label>`,e+="</div>",m.html(e,{safe:!1}),document.getElementById("dm-motion-switch").addEventListener("change",function(){S.set("reduced_motion",this.checked),window.location.reload()})}$("#site-sidebar").length&&Domma.elements.sidebar("#site-sidebar",{autoGenerate:!0,selector:"h2, h3",collapsible:!1,push:!0,contentSelector:".site-content"}),Domma.icons.scan();const n=$(".page-body");if(n.length&&(n.find(".accordion").each(function(){Domma.elements.accordion(this,{allowMultiple:this.dataset.multi==="true"})}),n.find(".tabs").each(function(){Domma.elements.tabs(this)}),n.find(".carousel").each(function(){Domma.elements.carousel(this,{autoplay:this.dataset.autoplay==="true",interval:parseInt(this.dataset.interval,10)||5e3,loop:this.dataset.loop!=="false",animation:this.dataset.animation||"slide"})}),n.find(".dm-countdown").each(function(){const t={autoStart:!0};this.dataset.to&&(t.targetDate=new Date(this.dataset.to)),this.dataset.duration&&(t.duration=parseInt(this.dataset.duration,10)),this.dataset.format&&(t.format=this.dataset.format),Domma.elements.timer(this,t)}),n.find("[data-tooltip]").each(function(){Domma.elements.tooltip(this,{content:$(this).data("tooltip"),position:$(this).data("tooltip-position")||"top"})}),n.find(".card[data-collapsible]").each(function(){const t=this.querySelector(".card-header");t&&t.addEventListener("click",()=>this.classList.toggle("is-collapsed"))}),n.find(".dm-so-trigger").each(function(){this.addEventListener("click",()=>{const t=this.dataset.soTarget,a=document.getElementById(t);if(!a)return;const e=E.slideover({title:a.dataset.soTitle||"",size:a.dataset.soSize||"md",position:a.dataset.soPosition||"right"});a.style.display="",e.setContent(a),e.open()})})),typeof $.setup=="function"){const t=Object.assign({},window.__CMS_DCONFIG__||{});if(document.querySelectorAll(".dm-page-config[data-config]").forEach(a=>{try{const e=atob(a.dataset.config),i=JSON.parse(e);Object.assign(t,i)}catch{}}),Object.keys(t).length){const a={};for(const[e,i]of Object.entries(t)){const f=i?.events?.click,{confirm:c,toast:l,alert:o,...s}=f||{};c||l||o?($(e).on("click",async function(v){if(v.preventDefault(),!(c&&!await E.confirm(c))){if(s.target){const d=$(s.target);s.toggleClass&&d.toggleClass(s.toggleClass),s.addClass&&d.addClass(s.addClass),s.removeClass&&d.removeClass(s.removeClass)}s.href&&(window.location.href=s.href),l&&E.toast(l,{type:s.toastType||"success"}),o&&E.alert(o)}}),Object.keys(s).length&&(a[e]={...i,events:{...i.events,click:s}})):a[e]=i}$.setup(a)}}});
package/scripts/setup.js CHANGED
@@ -38,10 +38,10 @@ const THEMES = [
38
38
  'royal-dark', 'royal-light',
39
39
  'lemon-dark', 'lemon-light',
40
40
  'silver-dark', 'silver-light',
41
- 'grayve-dark', 'grayve-light',
42
- 'christmas-dark', 'christmas-light',
43
- 'unicorn-dark', 'unicorn-light',
44
- 'dreamy-dark', 'dreamy-light',
41
+ 'grayve-dark', 'grayve-light',
42
+ 'christmas-dark', 'christmas-light',
43
+ 'unicorn-dark', 'unicorn-light',
44
+ 'dreamy-dark', 'dreamy-light',
45
45
  ];
46
46
 
47
47
  // ---------------------------------------------------------------------------
@@ -1,22 +1,9 @@
1
1
  /**
2
2
  * Authentication Middleware
3
3
  * JWT-based authentication with role guards for Domma CMS.
4
+ * Role data is read from the userTypes cache (not config.auth.roles).
4
5
  */
5
- import { config } from '../config.js';
6
-
7
- const { roles } = config.auth;
8
-
9
- /**
10
- * Role hierarchy ordered from most to least privileged.
11
- * Used by canManageUser() to compare privilege levels.
12
- */
13
- export const ROLE_HIERARCHY = Object.entries(roles)
14
- .sort((a, b) => a[1].level - b[1].level)
15
- .map(([key]) => key);
16
-
17
- export const ROLES = Object.fromEntries(
18
- Object.entries(roles).map(([key]) => [key.toUpperCase(), key])
19
- );
6
+ import { getRoleLevel, getRoleHierarchy, getPermissionsFor } from '../services/userTypes.js';
20
7
 
21
8
  /**
22
9
  * Verify JWT Bearer token. Populates request.user on success.
@@ -67,7 +54,35 @@ export function requireRole(allowedRoles) {
67
54
  }
68
55
 
69
56
  /**
70
- * Shorthand preHandler admin only.
57
+ * Return a preHandler that checks the current user's role has access to a resource.
58
+ * Reads from the userTypes cache at request time — reflects live role changes.
59
+ *
60
+ * @param {string} resource - Resource key (e.g. 'pages', 'users')
61
+ * @returns {Function}
62
+ */
63
+ export function requirePermission(resource) {
64
+ return async (request, reply) => {
65
+ if (!request.user) {
66
+ return reply.code(401).send({
67
+ statusCode: 401,
68
+ error: 'Unauthorised',
69
+ message: 'Authentication required'
70
+ });
71
+ }
72
+
73
+ const allowed = getPermissionsFor(resource);
74
+ if (!allowed.includes(request.user.role)) {
75
+ return reply.code(403).send({
76
+ statusCode: 403,
77
+ error: 'Forbidden',
78
+ message: 'Insufficient permissions'
79
+ });
80
+ }
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Shorthand preHandler — level-0 role (admin) only.
71
86
  *
72
87
  * @param {FastifyRequest} request
73
88
  * @param {FastifyReply} reply
@@ -77,21 +92,29 @@ export async function requireAdmin(request, reply) {
77
92
  if (!request.user) {
78
93
  return reply.code(401).send({ statusCode: 401, error: 'Unauthorised', message: 'Authentication required' });
79
94
  }
80
- if (request.user.role !== 'admin') {
95
+ if (getRoleLevel(request.user.role) !== 0) {
81
96
  return reply.code(403).send({ statusCode: 403, error: 'Forbidden', message: 'Admin access required' });
82
97
  }
83
98
  }
84
99
 
85
100
  /**
86
101
  * Determine whether an actor can manage a target user.
87
- * Managers cannot create, edit, or delete admins.
102
+ * Managers cannot create, edit, or delete users with a lower level number (higher privilege).
88
103
  *
89
104
  * @param {string} actorRole - Role of the user performing the action
90
105
  * @param {string} targetRole - Role of the user being acted upon
91
106
  * @returns {boolean}
92
107
  */
93
108
  export function canManageUser(actorRole, targetRole) {
94
- const actorLevel = roles[actorRole]?.level ?? Infinity;
95
- const targetLevel = roles[targetRole]?.level ?? Infinity;
96
- return actorLevel < targetLevel;
109
+ return getRoleLevel(actorRole) < getRoleLevel(targetRole);
110
+ }
111
+
112
+ /**
113
+ * Return role names ordered from most to least privileged.
114
+ * Computed from the userTypes cache.
115
+ *
116
+ * @returns {string[]}
117
+ */
118
+ export function getRoleHierarchyList() {
119
+ return getRoleHierarchy();
97
120
  }
@@ -5,21 +5,40 @@
5
5
  * POST /api/auth/login - { email, password } → { token, refreshToken, user }
6
6
  * GET /api/auth/me - return current user from token
7
7
  * POST /api/auth/refresh - { refreshToken } → { token }
8
+ * POST /api/auth/logout - { refreshToken } → { ok: true } — blacklists the refresh token
8
9
  */
9
- import { config } from '../../config.js';
10
- import { authenticate } from '../../middleware/auth.js';
10
+ import {config} from '../../config.js';
11
+ import {authenticate} from '../../middleware/auth.js';
11
12
  import {
12
- countUsers,
13
- createUser,
14
- getUserByEmail,
15
- getUserById,
16
- touchLastLogin,
17
- validatePassword
13
+ countUsers,
14
+ createUser,
15
+ getUserByEmail,
16
+ getUserById,
17
+ touchLastLogin,
18
+ validatePassword
18
19
  } from '../../services/users.js';
19
20
 
20
21
  const { accessTokenExpiry, refreshTokenExpiry } = config.auth;
21
22
 
23
+ /** In-memory blacklist of invalidated refresh tokens (cleared on server restart). */
24
+ const blacklistedRefreshTokens = new Set();
25
+
26
+ // Purge expired tokens every 15 minutes to avoid unbounded growth.
27
+ setInterval(() => {
28
+ for (const token of blacklistedRefreshTokens) {
29
+ try {
30
+ fastifyRef.jwt.verify(token);
31
+ } catch {
32
+ blacklistedRefreshTokens.delete(token);
33
+ }
34
+ }
35
+ }, 15 * 60 * 1000);
36
+
37
+ /** Holds the fastify instance once routes are registered (needed by the cleanup interval). */
38
+ let fastifyRef;
39
+
22
40
  export async function authRoutes(fastify) {
41
+ fastifyRef = fastify;
23
42
  // GET /api/auth/setup-status
24
43
  fastify.get('/auth/setup-status', async () => {
25
44
  const count = await countUsers();
@@ -77,6 +96,13 @@ export async function authRoutes(fastify) {
77
96
  return user;
78
97
  });
79
98
 
99
+ // POST /api/auth/logout — blacklists the refresh token (fire-and-forget safe: no auth required)
100
+ fastify.post('/auth/logout', async (request) => {
101
+ const {refreshToken} = request.body || {};
102
+ if (refreshToken) blacklistedRefreshTokens.add(refreshToken);
103
+ return {ok: true};
104
+ });
105
+
80
106
  // POST /api/auth/refresh
81
107
  fastify.post('/auth/refresh', async (request, reply) => {
82
108
  const { refreshToken } = request.body || {};
@@ -84,6 +110,10 @@ export async function authRoutes(fastify) {
84
110
  return reply.status(400).send({ error: 'refreshToken is required' });
85
111
  }
86
112
 
113
+ if (blacklistedRefreshTokens.has(refreshToken)) {
114
+ return reply.status(401).send({error: 'Refresh token has been revoked'});
115
+ }
116
+
87
117
  let payload;
88
118
  try {
89
119
  payload = fastify.jwt.verify(refreshToken);
@@ -28,8 +28,8 @@ import {
28
28
  listEntries, getEntry, createEntry, updateEntry, deleteEntry, clearEntries,
29
29
  exportEntries, importEntries
30
30
  } from '../../services/collections.js';
31
- import { authenticate, requireRole } from '../../middleware/auth.js';
32
- import { config } from '../../config.js';
31
+ import { authenticate, requirePermission } from '../../middleware/auth.js';
32
+ import { getRoleLevel, invalidate as invalidateUserTypes } from '../../services/userTypes.js';
33
33
 
34
34
  /**
35
35
  * Resolve the role level number for a named role.
@@ -38,7 +38,7 @@ import { config } from '../../config.js';
38
38
  * @returns {number}
39
39
  */
40
40
  function roleLevel(roleName) {
41
- return config.auth.roles[roleName]?.level ?? 99;
41
+ return getRoleLevel(roleName);
42
42
  }
43
43
 
44
44
  /**
@@ -76,7 +76,7 @@ async function checkPublicAccess(schema, operation, request, reply) {
76
76
  }
77
77
 
78
78
  export async function collectionsRoutes(fastify) {
79
- const guard = { preHandler: [authenticate, requireRole(config.auth.permissions.collections)] };
79
+ const guard = { preHandler: [authenticate, requirePermission('collections')] };
80
80
 
81
81
  // -------------------------------------------------------------------------
82
82
  // Collection CRUD (schema management)
@@ -112,6 +112,9 @@ export async function collectionsRoutes(fastify) {
112
112
  });
113
113
 
114
114
  fastify.delete('/collections/:slug', guard, async (request, reply) => {
115
+ if (request.params.slug === 'user-types') {
116
+ return reply.status(403).send({ error: 'Cannot delete a preset collection' });
117
+ }
115
118
  try {
116
119
  await deleteCollection(request.params.slug);
117
120
  return { success: true };
@@ -150,6 +153,7 @@ export async function collectionsRoutes(fastify) {
150
153
  createdBy: user?.id || null,
151
154
  source: 'admin'
152
155
  });
156
+ if (request.params.slug === 'user-types') await invalidateUserTypes();
153
157
  return reply.status(201).send(entry);
154
158
  } catch (err) {
155
159
  return reply.status(400).send({ error: err.message });
@@ -158,7 +162,9 @@ export async function collectionsRoutes(fastify) {
158
162
 
159
163
  fastify.put('/collections/:slug/entries/:id', guard, async (request, reply) => {
160
164
  try {
161
- return await updateEntry(request.params.slug, request.params.id, request.body?.data || {});
165
+ const entry = await updateEntry(request.params.slug, request.params.id, request.body?.data || {});
166
+ if (request.params.slug === 'user-types') await invalidateUserTypes();
167
+ return entry;
162
168
  } catch (err) {
163
169
  const status = err.message === 'Entry not found' ? 404 : 400;
164
170
  return reply.status(status).send({ error: err.message });
@@ -166,8 +172,15 @@ export async function collectionsRoutes(fastify) {
166
172
  });
167
173
 
168
174
  fastify.delete('/collections/:slug/entries/:id', guard, async (request, reply) => {
175
+ if (request.params.slug === 'user-types') {
176
+ const entry = await getEntry('user-types', request.params.id);
177
+ if (entry?.data?.level === 0) {
178
+ return reply.status(403).send({ error: 'Cannot delete the root admin role' });
179
+ }
180
+ }
169
181
  try {
170
182
  await deleteEntry(request.params.slug, request.params.id);
183
+ if (request.params.slug === 'user-types') await invalidateUserTypes();
171
184
  return { success: true };
172
185
  } catch (err) {
173
186
  return reply.status(404).send({ error: err.message });
@@ -3,12 +3,11 @@
3
3
  * GET /api/layouts - get all layout presets
4
4
  * PUT /api/layouts - save layout presets
5
5
  */
6
- import { getConfig, saveConfig } from '../../config.js';
7
- import { authenticate, requireRole } from '../../middleware/auth.js';
8
- import { config } from '../../config.js';
6
+ import {getConfig, saveConfig} from '../../config.js';
7
+ import {authenticate, requirePermission} from '../../middleware/auth.js';
9
8
 
10
9
  export async function layoutsRoutes(fastify) {
11
- const guard = { preHandler: [authenticate, requireRole(config.auth.permissions.layouts)] };
10
+ const guard = { preHandler: [authenticate, requirePermission('layouts')] };
12
11
 
13
12
  fastify.get('/layouts', guard, async () => {
14
13
  return getConfig('presets');
@@ -22,4 +21,19 @@ export async function layoutsRoutes(fastify) {
22
21
  saveConfig('presets', data);
23
22
  return { success: true };
24
23
  });
24
+
25
+ fastify.get('/layouts/options', guard, async () => {
26
+ return getConfig('site')?.layoutOptions ?? {spacerSize: 8};
27
+ });
28
+
29
+ fastify.put('/layouts/options', guard, async (request, reply) => {
30
+ const data = request.body;
31
+ if (!data || typeof data !== 'object') {
32
+ return reply.status(400).send({error: 'Invalid layout options'});
33
+ }
34
+ const site = getConfig('site');
35
+ site.layoutOptions = {...site.layoutOptions, ...data};
36
+ saveConfig('site', site);
37
+ return {success: true};
38
+ });
25
39
  }
@@ -7,8 +7,7 @@
7
7
  import path from 'path';
8
8
  import {deleteMedia, listMedia, renameMedia, saveMedia} from '../../services/content.js';
9
9
  import {getImageInfo, isEditableImage, transformImage} from '../../services/images.js';
10
- import {authenticate, requireRole} from '../../middleware/auth.js';
11
- import {config} from '../../config.js';
10
+ import {authenticate, requirePermission} from '../../middleware/auth.js';
12
11
 
13
12
  // Safe filename: strip path traversal and restrict to alphanumeric + safe chars
14
13
  function sanitiseFilename(name) {
@@ -16,7 +15,7 @@ function sanitiseFilename(name) {
16
15
  }
17
16
 
18
17
  export async function mediaRoutes(fastify) {
19
- const guard = { preHandler: [authenticate, requireRole(config.auth.permissions.media)] };
18
+ const guard = { preHandler: [authenticate, requirePermission('media')] };
20
19
 
21
20
  fastify.get('/media', guard, async () => {
22
21
  return listMedia();
@@ -4,11 +4,10 @@
4
4
  * PUT /api/navigation - save navigation config
5
5
  */
6
6
  import { getConfig, saveConfig } from '../../config.js';
7
- import { authenticate, requireRole } from '../../middleware/auth.js';
8
- import { config } from '../../config.js';
7
+ import { authenticate, requirePermission } from '../../middleware/auth.js';
9
8
 
10
9
  export async function navigationRoutes(fastify) {
11
- const guard = { preHandler: [authenticate, requireRole(config.auth.permissions.navigation)] };
10
+ const guard = { preHandler: [authenticate, requirePermission('navigation')] };
12
11
 
13
12
  fastify.get('/navigation', guard, async () => {
14
13
  return getConfig('navigation');
@@ -8,11 +8,11 @@
8
8
  */
9
9
  import {createPage, deletePage, getPage, listPages, renamePage, updatePage} from '../../services/content.js';
10
10
  import {parseMarkdown} from '../../services/markdown.js';
11
- import {authenticate, requireRole} from '../../middleware/auth.js';
12
- import {config, getConfig, saveConfig} from '../../config.js';
11
+ import {authenticate, requirePermission} from '../../middleware/auth.js';
12
+ import {getConfig, saveConfig} from '../../config.js';
13
13
 
14
14
  export async function pagesRoutes(fastify) {
15
- const guard = { preHandler: [authenticate, requireRole(config.auth.permissions.pages)] };
15
+ const guard = { preHandler: [authenticate, requirePermission('pages')] };
16
16
 
17
17
  // Render markdown preview (shortcodes + sanitize, no frontmatter)
18
18
  fastify.post('/pages/preview', guard, async (request, reply) => {
@@ -5,8 +5,7 @@
5
5
  * POST /api/settings/test-email - send a test email using stored SMTP config
6
6
  */
7
7
  import { getConfig, saveConfig } from '../../config.js';
8
- import { authenticate, requireRole } from '../../middleware/auth.js';
9
- import { config } from '../../config.js';
8
+ import { authenticate, requirePermission } from '../../middleware/auth.js';
10
9
  import nodemailer from 'nodemailer';
11
10
  import fs from 'fs/promises';
12
11
  import path from 'path';
@@ -17,7 +16,7 @@ const CUSTOM_CSS_FILE = path.resolve(__dirname, '../../../content/custom.css');
17
16
  const CUSTOM_CSS_MAX = 100 * 1024; // 100 KB
18
17
 
19
18
  export async function settingsRoutes(fastify) {
20
- const guard = { preHandler: [authenticate, requireRole(config.auth.permissions.settings)] };
19
+ const guard = { preHandler: [authenticate, requirePermission('settings')] };
21
20
 
22
21
  fastify.get('/settings', guard, async () => {
23
22
  return getConfig('site');
@@ -6,8 +6,8 @@
6
6
  * PUT /api/users/:id - update user (admin, manager — manager cannot edit admin)
7
7
  * DELETE /api/users/:id - delete user (admin, manager — manager cannot delete admin, no self-delete)
8
8
  */
9
- import { authenticate, requireRole, canManageUser } from '../../middleware/auth.js';
10
- import { config } from '../../config.js';
9
+ import { authenticate, requirePermission, canManageUser } from '../../middleware/auth.js';
10
+ import { getPermissionsFor } from '../../services/userTypes.js';
11
11
  import {
12
12
  listUsers,
13
13
  getUserById,
@@ -16,10 +16,8 @@ import {
16
16
  deleteUser
17
17
  } from '../../services/users.js';
18
18
 
19
- const { permissions } = config.auth;
20
-
21
19
  export async function usersRoutes(fastify) {
22
- const guard = [authenticate, requireRole(permissions.users)];
20
+ const guard = [authenticate, requirePermission('users')];
23
21
 
24
22
  // List all users
25
23
  fastify.get('/users', { preHandler: guard }, async () => {
@@ -32,7 +30,7 @@ export async function usersRoutes(fastify) {
32
30
  const actor = request.user;
33
31
 
34
32
  const isSelf = actor.id === id;
35
- const canManage = permissions.users.includes(actor.role);
33
+ const canManage = getPermissionsFor('users').includes(actor.role);
36
34
 
37
35
  if (!isSelf && !canManage) {
38
36
  return reply.code(403).send({ error: 'Forbidden' });
@@ -7,6 +7,7 @@
7
7
  import {getPage} from '../services/content.js';
8
8
  import {renderPage} from '../services/renderer.js';
9
9
  import {config} from '../config.js';
10
+ import { getRoleLevel } from '../services/userTypes.js';
10
11
 
11
12
  export async function publicRoutes(fastify) {
12
13
  // Admin panel: serve index.html for all /admin/* paths (SPA fallback)
@@ -56,9 +57,8 @@ export async function publicRoutes(fastify) {
56
57
  } catch { /* no token — treat as unauthenticated */
57
58
  }
58
59
 
59
- const roles = config.auth.roles;
60
- const userLevel = roles[userRole]?.level ?? Infinity;
61
- const requiredLevel = roles[page.visibility]?.level ?? 0;
60
+ const userLevel = getRoleLevel(userRole);
61
+ const requiredLevel = getRoleLevel(page.visibility) === Infinity ? 0 : getRoleLevel(page.visibility);
62
62
 
63
63
  if (userLevel > requiredLevel) {
64
64
  reply.status(403);