domma-cms 0.2.1 → 0.5.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 (166) hide show
  1. package/README.md +3 -3
  2. package/admin/css/admin.css +1 -1200
  3. package/admin/dist/domma/domma-tools.css +2313 -0
  4. package/admin/dist/domma/domma-tools.min.js +10 -0
  5. package/admin/index.html +4 -0
  6. package/admin/js/api.js +1 -242
  7. package/admin/js/app.js +9 -279
  8. package/admin/js/config/sidebar-config.js +1 -115
  9. package/admin/js/lib/card.js +1 -63
  10. package/admin/js/lib/image-editor.js +1 -869
  11. package/admin/js/lib/markdown-toolbar.js +54 -421
  12. package/admin/js/templates/action-editor.html +171 -0
  13. package/admin/js/templates/actions-list.html +19 -0
  14. package/admin/js/templates/api-reference.html +1411 -0
  15. package/admin/js/templates/block-editor.html +158 -0
  16. package/admin/js/templates/blocks.html +8 -0
  17. package/admin/js/templates/collection-editor.html +47 -0
  18. package/admin/js/templates/collection-entries.html +3 -0
  19. package/admin/js/templates/collections.html +51 -4
  20. package/admin/js/templates/documentation.html +258 -0
  21. package/admin/js/templates/form-editor.html +238 -0
  22. package/{plugins/form-builder/admin → admin/js}/templates/form-submissions.html +30 -30
  23. package/{plugins/form-builder/admin/templates/forms-list.html → admin/js/templates/forms.html} +17 -17
  24. package/admin/js/templates/layouts.html +44 -7
  25. package/admin/js/templates/login.html +29 -4
  26. package/admin/js/templates/my-profile.html +17 -0
  27. package/admin/js/templates/page-editor.html +48 -0
  28. package/admin/js/templates/pages.html +6 -1
  29. package/admin/js/templates/pro-docs.html +259 -0
  30. package/admin/js/templates/role-editor.html +59 -0
  31. package/admin/js/templates/roles.html +10 -0
  32. package/admin/js/templates/settings.html +137 -18
  33. package/admin/js/templates/tutorials.html +81 -0
  34. package/admin/js/templates/user-editor.html +7 -0
  35. package/admin/js/templates/users.html +3 -1
  36. package/admin/js/templates/view-editor.html +201 -0
  37. package/admin/js/templates/view-preview.html +51 -0
  38. package/admin/js/templates/views-list.html +19 -0
  39. package/admin/js/views/action-editor.js +1 -0
  40. package/admin/js/views/actions-list.js +1 -0
  41. package/admin/js/views/api-reference.js +1 -0
  42. package/admin/js/views/block-editor.js +8 -0
  43. package/admin/js/views/blocks.js +4 -0
  44. package/admin/js/views/collection-editor.js +3 -487
  45. package/admin/js/views/collection-entries.js +1 -484
  46. package/admin/js/views/collections.js +1 -153
  47. package/admin/js/views/dashboard.js +1 -56
  48. package/admin/js/views/documentation.js +1 -12
  49. package/admin/js/views/form-editor.js +8 -0
  50. package/admin/js/views/form-submissions.js +1 -0
  51. package/admin/js/views/forms.js +1 -0
  52. package/admin/js/views/index.js +1 -39
  53. package/admin/js/views/layouts.js +9 -42
  54. package/admin/js/views/login.js +7 -251
  55. package/admin/js/views/media.js +1 -240
  56. package/admin/js/views/my-profile.js +1 -0
  57. package/admin/js/views/navigation.js +14 -212
  58. package/admin/js/views/page-editor.js +72 -661
  59. package/admin/js/views/pages.js +5 -72
  60. package/admin/js/views/plugins.js +13 -90
  61. package/admin/js/views/pro-docs.js +1 -0
  62. package/admin/js/views/role-editor.js +1 -0
  63. package/admin/js/views/roles.js +4 -0
  64. package/admin/js/views/settings.js +3 -199
  65. package/admin/js/views/tutorials.js +1 -12
  66. package/admin/js/views/user-editor.js +1 -88
  67. package/admin/js/views/users.js +4 -76
  68. package/admin/js/views/view-editor.js +1 -0
  69. package/admin/js/views/view-preview.js +1 -0
  70. package/admin/js/views/views-list.js +1 -0
  71. package/bin/cli.js +1 -1
  72. package/config/auth.json +2 -17
  73. package/config/connections.json.bak +9 -0
  74. package/config/connections.json.example +9 -0
  75. package/config/navigation.json +15 -0
  76. package/config/plugins.json +19 -29
  77. package/config/server.json +6 -6
  78. package/config/site.json +17 -6
  79. package/package.json +24 -10
  80. package/plugins/domma-effects/public/celebrations/core/canvas.js +2 -104
  81. package/plugins/domma-effects/public/celebrations/core/particles.js +1 -144
  82. package/plugins/domma-effects/public/celebrations/core/physics.js +1 -166
  83. package/plugins/domma-effects/public/celebrations/index.js +1 -535
  84. package/plugins/domma-effects/public/celebrations/themes/christmas.js +1 -1805
  85. package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1 -1477
  86. package/plugins/domma-effects/public/celebrations/themes/halloween.js +1 -1837
  87. package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1 -1175
  88. package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1 -1258
  89. package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1 -1754
  90. package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1 -1290
  91. package/plugins/domma-effects/public/celebrations/themes/valentines.js +1 -1361
  92. package/plugins/example-analytics/stats.json +21 -12
  93. package/plugins/theme-roller/admin/templates/theme-roller.html +71 -0
  94. package/plugins/theme-roller/admin/views/theme-roller-view.js +403 -0
  95. package/plugins/theme-roller/config.js +1 -0
  96. package/plugins/theme-roller/plugin.js +233 -0
  97. package/plugins/theme-roller/plugin.json +31 -0
  98. package/plugins/theme-roller/public/active-theme.css +0 -0
  99. package/plugins/theme-roller/public/inject-head-late.html +1 -0
  100. package/public/css/forms.css +1 -0
  101. package/public/css/site.css +1 -302
  102. package/public/js/btt.js +1 -90
  103. package/public/js/cookie-consent.js +1 -61
  104. package/public/js/form-logic-engine.js +1 -0
  105. package/public/js/forms.js +1 -0
  106. package/public/js/site.js +1 -204
  107. package/scripts/build.js +194 -129
  108. package/scripts/pro.js +254 -0
  109. package/scripts/reset.js +33 -8
  110. package/scripts/seed.js +343 -78
  111. package/scripts/setup.js +5 -4
  112. package/server/middleware/auth.js +136 -97
  113. package/server/routes/api/actions.js +200 -0
  114. package/server/routes/api/auth.js +292 -116
  115. package/server/routes/api/blocks.js +84 -0
  116. package/server/routes/api/collections.js +88 -23
  117. package/{plugins/form-builder/plugin.js → server/routes/api/forms.js} +483 -505
  118. package/server/routes/api/layouts.js +49 -25
  119. package/server/routes/api/media.js +118 -93
  120. package/server/routes/api/navigation.js +40 -37
  121. package/server/routes/api/pages.js +132 -118
  122. package/server/routes/api/plugins.js +6 -3
  123. package/server/routes/api/settings.js +104 -89
  124. package/server/routes/api/users.js +27 -21
  125. package/server/routes/api/views.js +148 -0
  126. package/server/routes/public.js +124 -108
  127. package/server/server.js +269 -173
  128. package/server/services/actions.js +387 -0
  129. package/server/services/adapterRegistry.js +98 -0
  130. package/server/services/adapters/FileAdapter.js +192 -0
  131. package/server/services/adapters/MongoAdapter.js +220 -0
  132. package/server/services/blocks.js +162 -0
  133. package/server/services/collections.js +74 -86
  134. package/server/services/connectionManager.js +102 -0
  135. package/server/services/content.js +312 -307
  136. package/{plugins/form-builder → server/services}/email.js +126 -103
  137. package/server/services/forms.js +173 -0
  138. package/server/services/markdown.js +1378 -648
  139. package/server/services/permissionRegistry.js +173 -0
  140. package/server/services/presetCollections.js +251 -0
  141. package/server/services/renderer.js +75 -1
  142. package/server/services/roles.js +227 -0
  143. package/server/services/rowAccess.js +104 -0
  144. package/server/services/userProfiles.js +199 -0
  145. package/server/services/users.js +281 -212
  146. package/server/services/views.js +280 -0
  147. package/server/templates/page.html +119 -113
  148. package/plugins/form-builder/admin/templates/form-editor.html +0 -171
  149. package/plugins/form-builder/admin/templates/form-settings.html +0 -29
  150. package/plugins/form-builder/admin/views/form-editor.js +0 -1442
  151. package/plugins/form-builder/admin/views/form-settings.js +0 -38
  152. package/plugins/form-builder/admin/views/form-submissions.js +0 -295
  153. package/plugins/form-builder/admin/views/forms-list.js +0 -164
  154. package/plugins/form-builder/config.js +0 -9
  155. package/plugins/form-builder/data/forms/consent.json +0 -104
  156. package/plugins/form-builder/data/forms/contact-details.json +0 -63
  157. package/plugins/form-builder/data/forms/contacts.json +0 -66
  158. package/plugins/form-builder/data/submissions/consent.json +0 -13
  159. package/plugins/form-builder/data/submissions/contact-details.json +0 -1
  160. package/plugins/form-builder/data/submissions/contacts.json +0 -26
  161. package/plugins/form-builder/plugin.json +0 -52
  162. package/plugins/form-builder/public/form-logic-engine.js +0 -568
  163. package/plugins/form-builder/public/inject-body.html +0 -352
  164. package/plugins/form-builder/public/inject-head.html +0 -58
  165. package/plugins/form-builder/public/package.json +0 -1
  166. package/scripts/copy-domma.js +0 -48
@@ -1,212 +1,281 @@
1
- /**
2
- * User Storage Service
3
- * File-based CRUD — one JSON file per user in content/users/{id}.json.
4
- * Mirrors the page-per-file pattern from the content service.
5
- */
6
- import fs from 'fs/promises';
7
- import path from 'path';
8
- import bcrypt from 'bcryptjs';
9
- import { v4 as uuidv4 } from 'uuid';
10
- import { config } from '../config.js';
11
-
12
- const USERS_DIR = path.resolve(config.content.usersDir);
13
-
14
- async function ensureDir() {
15
- await fs.mkdir(USERS_DIR, { recursive: true });
16
- }
17
-
18
- /**
19
- * Strip the password field from a user object before returning it to callers.
20
- *
21
- * @param {object} user
22
- * @returns {object}
23
- */
24
- function stripPassword(user) {
25
- const { password, ...safe } = user;
26
- return safe;
27
- }
28
-
29
- /**
30
- * Read a user file by ID.
31
- *
32
- * @param {string} id
33
- * @returns {Promise<object>} Full user object including password hash
34
- */
35
- async function readUserFile(id) {
36
- const filePath = path.join(USERS_DIR, `${id}.json`);
37
- const raw = await fs.readFile(filePath, 'utf8');
38
- return JSON.parse(raw);
39
- }
40
-
41
- /**
42
- * Write a user file.
43
- *
44
- * @param {object} user
45
- * @returns {Promise<void>}
46
- */
47
- async function writeUserFile(user) {
48
- await ensureDir();
49
- const filePath = path.join(USERS_DIR, `${user.id}.json`);
50
- await fs.writeFile(filePath, JSON.stringify(user, null, 2) + '\n', 'utf8');
51
- }
52
-
53
- /**
54
- * List all users (password stripped).
55
- *
56
- * @returns {Promise<object[]>}
57
- */
58
- export async function listUsers() {
59
- await ensureDir();
60
- let files;
61
- try {
62
- files = await fs.readdir(USERS_DIR);
63
- } catch {
64
- return [];
65
- }
66
- const jsonFiles = files.filter(f => f.endsWith('.json'));
67
- const users = await Promise.all(jsonFiles.map(async (file) => {
68
- const raw = await fs.readFile(path.join(USERS_DIR, file), 'utf8');
69
- return stripPassword(JSON.parse(raw));
70
- }));
71
- return users.sort((a, b) => a.name.localeCompare(b.name));
72
- }
73
-
74
- /**
75
- * Get a single user by ID (password stripped).
76
- *
77
- * @param {string} id
78
- * @returns {Promise<object|null>}
79
- */
80
- export async function getUserById(id) {
81
- try {
82
- return stripPassword(await readUserFile(id));
83
- } catch {
84
- return null;
85
- }
86
- }
87
-
88
- /**
89
- * Get a user by email address — includes password hash (needed for login).
90
- *
91
- * @param {string} email
92
- * @returns {Promise<object|null>}
93
- */
94
- export async function getUserByEmail(email) {
95
- await ensureDir();
96
- let files;
97
- try {
98
- files = await fs.readdir(USERS_DIR);
99
- } catch {
100
- return null;
101
- }
102
- for (const file of files.filter(f => f.endsWith('.json'))) {
103
- const raw = await fs.readFile(path.join(USERS_DIR, file), 'utf8');
104
- const user = JSON.parse(raw);
105
- if (user.email.toLowerCase() === email.toLowerCase()) return user;
106
- }
107
- return null;
108
- }
109
-
110
- /**
111
- * Create a new user.
112
- *
113
- * @param {object} data - { name, email, password, role }
114
- * @returns {Promise<object>} Created user (password stripped)
115
- */
116
- export async function createUser({ name, email, password, role = 'editor' }) {
117
- const existing = await getUserByEmail(email);
118
- if (existing) throw new Error('A user with that email already exists');
119
-
120
- const hash = await bcrypt.hash(password, config.auth.bcryptRounds);
121
- const now = new Date().toISOString();
122
- const user = {
123
- id: uuidv4(),
124
- email: email.toLowerCase().trim(),
125
- name: name.trim(),
126
- password: hash,
127
- role,
128
- isActive: true,
129
- createdAt: now,
130
- updatedAt: now,
131
- lastLogin: null
132
- };
133
-
134
- await writeUserFile(user);
135
- return stripPassword(user);
136
- }
137
-
138
- /**
139
- * Update an existing user.
140
- * Pass a new `password` field to re-hash; omit to keep the existing hash.
141
- *
142
- * @param {string} id
143
- * @param {object} updates
144
- * @returns {Promise<object>} Updated user (password stripped)
145
- */
146
- export async function updateUser(id, updates) {
147
- const user = await readUserFile(id);
148
- if (!user) throw new Error('User not found');
149
-
150
- if (updates.password) {
151
- updates.password = await bcrypt.hash(updates.password, config.auth.bcryptRounds);
152
- }
153
-
154
- const updated = {
155
- ...user,
156
- ...updates,
157
- id: user.id,
158
- updatedAt: new Date().toISOString()
159
- };
160
-
161
- await writeUserFile(updated);
162
- return stripPassword(updated);
163
- }
164
-
165
- /**
166
- * Delete a user file.
167
- *
168
- * @param {string} id
169
- * @returns {Promise<void>}
170
- */
171
- export async function deleteUser(id) {
172
- const filePath = path.join(USERS_DIR, `${id}.json`);
173
- await fs.unlink(filePath);
174
- }
175
-
176
- /**
177
- * Count total users.
178
- *
179
- * @returns {Promise<number>}
180
- */
181
- export async function countUsers() {
182
- await ensureDir();
183
- try {
184
- const files = await fs.readdir(USERS_DIR);
185
- return files.filter(f => f.endsWith('.json')).length;
186
- } catch {
187
- return 0;
188
- }
189
- }
190
-
191
- /**
192
- * Validate a plain-text password against a bcrypt hash.
193
- *
194
- * @param {string} plain
195
- * @param {string} hash
196
- * @returns {Promise<boolean>}
197
- */
198
- export async function validatePassword(plain, hash) {
199
- return bcrypt.compare(plain, hash);
200
- }
201
-
202
- /**
203
- * Record the current timestamp as the user's last login.
204
- *
205
- * @param {string} id
206
- * @returns {Promise<void>}
207
- */
208
- export async function touchLastLogin(id) {
209
- const user = await readUserFile(id);
210
- user.lastLogin = new Date().toISOString();
211
- await writeUserFile(user);
212
- }
1
+ /**
2
+ * User Storage Service
3
+ * File-based CRUD — one JSON file per user in content/users/{id}.json.
4
+ * Mirrors the page-per-file pattern from the content service.
5
+ */
6
+ import fs from 'fs/promises';
7
+ import path from 'path';
8
+ import bcrypt from 'bcryptjs';
9
+ import {v4 as uuidv4} from 'uuid';
10
+ import {config} from '../config.js';
11
+ import {deleteProfile, ensureProfile} from './userProfiles.js';
12
+
13
+ const USERS_DIR = path.resolve(config.content.usersDir);
14
+
15
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
16
+
17
+ async function ensureDir() {
18
+ await fs.mkdir(USERS_DIR, { recursive: true });
19
+ }
20
+
21
+ /**
22
+ * Strip the password field from a user object before returning it to callers.
23
+ *
24
+ * @param {object} user
25
+ * @returns {object}
26
+ */
27
+ function stripPassword(user) {
28
+ const {password, resetTokenHash, resetTokenExpiry, ...safe} = user;
29
+ return safe;
30
+ }
31
+
32
+ /**
33
+ * Read a user file by ID.
34
+ *
35
+ * @param {string} id
36
+ * @returns {Promise<object>} Full user object including password hash
37
+ */
38
+ async function readUserFile(id) {
39
+ if (!UUID_REGEX.test(id)) {
40
+ throw new Error('Invalid user ID');
41
+ }
42
+ const filePath = path.join(USERS_DIR, `${id}.json`);
43
+ const raw = await fs.readFile(filePath, 'utf8');
44
+ return JSON.parse(raw);
45
+ }
46
+
47
+ /**
48
+ * Write a user file.
49
+ *
50
+ * @param {object} user
51
+ * @returns {Promise<void>}
52
+ */
53
+ async function writeUserFile(user) {
54
+ if (!UUID_REGEX.test(user.id)) {
55
+ throw new Error('Invalid user ID');
56
+ }
57
+ await ensureDir();
58
+ const filePath = path.join(USERS_DIR, `${user.id}.json`);
59
+ await fs.writeFile(filePath, JSON.stringify(user, null, 2) + '\n', 'utf8');
60
+ }
61
+
62
+ /**
63
+ * List all users (password stripped).
64
+ *
65
+ * @returns {Promise<object[]>}
66
+ */
67
+ export async function listUsers() {
68
+ await ensureDir();
69
+ let files;
70
+ try {
71
+ files = await fs.readdir(USERS_DIR);
72
+ } catch {
73
+ return [];
74
+ }
75
+ const jsonFiles = files.filter(f => f.endsWith('.json'));
76
+ const users = await Promise.all(jsonFiles.map(async (file) => {
77
+ const raw = await fs.readFile(path.join(USERS_DIR, file), 'utf8');
78
+ return stripPassword(JSON.parse(raw));
79
+ }));
80
+ return users.sort((a, b) => a.name.localeCompare(b.name));
81
+ }
82
+
83
+ /**
84
+ * Get a single user by ID (password stripped).
85
+ *
86
+ * @param {string} id
87
+ * @returns {Promise<object|null>}
88
+ */
89
+ export async function getUserById(id) {
90
+ try {
91
+ return stripPassword(await readUserFile(id));
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Get a user by email address — includes password hash (needed for login).
99
+ *
100
+ * @param {string} email
101
+ * @returns {Promise<object|null>}
102
+ */
103
+ export async function getUserByEmail(email) {
104
+ await ensureDir();
105
+ let files;
106
+ try {
107
+ files = await fs.readdir(USERS_DIR);
108
+ } catch {
109
+ return null;
110
+ }
111
+ for (const file of files.filter(f => f.endsWith('.json'))) {
112
+ const raw = await fs.readFile(path.join(USERS_DIR, file), 'utf8');
113
+ const user = JSON.parse(raw);
114
+ if (user.email.toLowerCase() === email.toLowerCase()) return user;
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Create a new user.
121
+ *
122
+ * @param {object} data - { name, email, password, role }
123
+ * @returns {Promise<object>} Created user (password stripped)
124
+ */
125
+ export async function createUser({ name, email, password, role = 'editor' }) {
126
+ const existing = await getUserByEmail(email);
127
+ if (existing) throw new Error('A user with that email already exists');
128
+
129
+ const hash = await bcrypt.hash(password, config.auth.bcryptRounds);
130
+ const now = new Date().toISOString();
131
+ const user = {
132
+ id: uuidv4(),
133
+ email: email.toLowerCase().trim(),
134
+ name: name.trim(),
135
+ password: hash,
136
+ role,
137
+ isActive: true,
138
+ createdAt: now,
139
+ updatedAt: now,
140
+ lastLogin: null
141
+ };
142
+
143
+ await writeUserFile(user);
144
+ await ensureProfile(user.id);
145
+ return stripPassword(user);
146
+ }
147
+
148
+ /**
149
+ * Update an existing user.
150
+ * Pass a new `password` field to re-hash; omit to keep the existing hash.
151
+ *
152
+ * @param {string} id
153
+ * @param {object} updates
154
+ * @returns {Promise<object>} Updated user (password stripped)
155
+ */
156
+ export async function updateUser(id, updates) {
157
+ const user = await readUserFile(id);
158
+ if (!user) throw new Error('User not found');
159
+
160
+ if (updates.password) {
161
+ updates.password = await bcrypt.hash(updates.password, config.auth.bcryptRounds);
162
+ }
163
+
164
+ const updated = {
165
+ ...user,
166
+ ...updates,
167
+ id: user.id,
168
+ updatedAt: new Date().toISOString()
169
+ };
170
+
171
+ await writeUserFile(updated);
172
+ return stripPassword(updated);
173
+ }
174
+
175
+ /**
176
+ * Delete a user file.
177
+ *
178
+ * @param {string} id
179
+ * @returns {Promise<void>}
180
+ */
181
+ export async function deleteUser(id) {
182
+ if (!UUID_REGEX.test(id)) {
183
+ throw new Error('Invalid user ID');
184
+ }
185
+ const filePath = path.join(USERS_DIR, `${id}.json`);
186
+ await fs.unlink(filePath);
187
+ try {
188
+ await deleteProfile(id);
189
+ } catch {
190
+ // Profile deletion is best-effort — never block user deletion
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Count total users.
196
+ *
197
+ * @returns {Promise<number>}
198
+ */
199
+ export async function countUsers() {
200
+ await ensureDir();
201
+ try {
202
+ const files = await fs.readdir(USERS_DIR);
203
+ return files.filter(f => f.endsWith('.json')).length;
204
+ } catch {
205
+ return 0;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Validate a plain-text password against a bcrypt hash.
211
+ *
212
+ * @param {string} plain
213
+ * @param {string} hash
214
+ * @returns {Promise<boolean>}
215
+ */
216
+ export async function validatePassword(plain, hash) {
217
+ return bcrypt.compare(plain, hash);
218
+ }
219
+
220
+ /**
221
+ * Store a hashed reset token and its expiry on a user file.
222
+ *
223
+ * @param {string} id
224
+ * @param {string} tokenHash - SHA-256 hex digest of the plaintext token
225
+ * @param {string} expiry - ISO timestamp
226
+ * @returns {Promise<void>}
227
+ */
228
+ export async function setResetToken(id, tokenHash, expiry) {
229
+ const user = await readUserFile(id);
230
+ user.resetTokenHash = tokenHash;
231
+ user.resetTokenExpiry = expiry;
232
+ await writeUserFile(user);
233
+ }
234
+
235
+ /**
236
+ * Remove the reset token fields from a user file (single-use enforcement).
237
+ *
238
+ * @param {string} id
239
+ * @returns {Promise<void>}
240
+ */
241
+ export async function clearResetToken(id) {
242
+ const user = await readUserFile(id);
243
+ delete user.resetTokenHash;
244
+ delete user.resetTokenExpiry;
245
+ await writeUserFile(user);
246
+ }
247
+
248
+ /**
249
+ * Find a user whose stored reset token hash matches the given hash.
250
+ * Returns the full user object (including hash) so the caller can check expiry.
251
+ *
252
+ * @param {string} tokenHash - SHA-256 hex digest to look up
253
+ * @returns {Promise<object|null>}
254
+ */
255
+ export async function getUserByResetToken(tokenHash) {
256
+ await ensureDir();
257
+ let files;
258
+ try {
259
+ files = await fs.readdir(USERS_DIR);
260
+ } catch {
261
+ return null;
262
+ }
263
+ for (const file of files.filter(f => f.endsWith('.json'))) {
264
+ const raw = await fs.readFile(path.join(USERS_DIR, file), 'utf8');
265
+ const user = JSON.parse(raw);
266
+ if (user.resetTokenHash === tokenHash) return user;
267
+ }
268
+ return null;
269
+ }
270
+
271
+ /**
272
+ * Record the current timestamp as the user's last login.
273
+ *
274
+ * @param {string} id
275
+ * @returns {Promise<void>}
276
+ */
277
+ export async function touchLastLogin(id) {
278
+ const user = await readUserFile(id);
279
+ user.lastLogin = new Date().toISOString();
280
+ await writeUserFile(user);
281
+ }