domma-cms 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +14 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/crud-tutorial.js +1 -1
- package/admin/js/lib/project-context.js +1 -1
- package/admin/js/templates/api-endpoint-editor.html +120 -0
- package/admin/js/templates/api-endpoints.html +13 -0
- package/admin/js/templates/api-tokens.html +13 -0
- package/admin/js/templates/effects.html +752 -752
- package/admin/js/templates/form-submissions.html +30 -30
- package/admin/js/templates/forms.html +17 -17
- package/admin/js/templates/my-profile.html +17 -17
- package/admin/js/templates/role-editor.html +70 -70
- package/admin/js/templates/roles.html +10 -10
- package/admin/js/views/api-endpoint-editor.js +1 -0
- package/admin/js/views/api-endpoints.js +7 -0
- package/admin/js/views/api-tokens.js +8 -0
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/project-detail.js +1 -1
- package/admin/js/views/roles.js +1 -1
- package/bin/lib/config-merge.js +44 -44
- package/bin/update.js +547 -547
- package/config/menus/admin-sidebar.json +13 -1
- package/package.json +1 -1
- package/server/middleware/auth.js +253 -253
- package/server/routes/api/api-endpoints.js +96 -0
- package/server/routes/api/api-tokens.js +83 -0
- package/server/routes/api/auth.js +309 -309
- package/server/routes/api/collections.js +114 -17
- package/server/routes/api/endpoints-public.js +88 -0
- package/server/routes/api/navigation.js +42 -42
- package/server/routes/api/settings.js +141 -141
- package/server/routes/public.js +202 -202
- package/server/server.js +16 -1
- package/server/services/apiEndpoints.js +402 -0
- package/server/services/apiTokens.js +273 -0
- package/server/services/email.js +167 -167
- package/server/services/permissionRegistry.js +26 -0
- package/server/services/presetCollections.js +54 -0
- package/server/services/projects.js +18 -2
- package/server/services/roles.js +16 -0
- package/server/services/scaffolder.js +54 -1
- package/server/services/sidebar-migration.js +45 -0
- package/server/services/userProfiles.js +199 -199
- package/server/services/users.js +302 -302
- package/config/connections.json.bak +0 -9
package/server/services/users.js
CHANGED
|
@@ -1,302 +1,302 @@
|
|
|
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
|
-
import * as cache from './cache/index.js';
|
|
13
|
-
|
|
14
|
-
const USERS_DIR = path.resolve(config.content.usersDir);
|
|
15
|
-
|
|
16
|
-
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;
|
|
17
|
-
|
|
18
|
-
async function ensureDir() {
|
|
19
|
-
await fs.mkdir(USERS_DIR, { recursive: true });
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Strip the password field from a user object before returning it to callers.
|
|
24
|
-
*
|
|
25
|
-
* @param {object} user
|
|
26
|
-
* @returns {object}
|
|
27
|
-
*/
|
|
28
|
-
function stripPassword(user) {
|
|
29
|
-
const {password, resetTokenHash, resetTokenExpiry, ...safe} = user;
|
|
30
|
-
return safe;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Read a user file by ID.
|
|
35
|
-
*
|
|
36
|
-
* @param {string} id
|
|
37
|
-
* @returns {Promise<object>} Full user object including password hash
|
|
38
|
-
*/
|
|
39
|
-
async function readUserFile(id) {
|
|
40
|
-
if (!UUID_REGEX.test(id)) {
|
|
41
|
-
throw new Error('Invalid user ID');
|
|
42
|
-
}
|
|
43
|
-
const filePath = path.join(USERS_DIR, `${id}.json`);
|
|
44
|
-
const raw = await fs.readFile(filePath, 'utf8');
|
|
45
|
-
return JSON.parse(raw);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Write a user file.
|
|
50
|
-
*
|
|
51
|
-
* @param {object} user
|
|
52
|
-
* @returns {Promise<void>}
|
|
53
|
-
*/
|
|
54
|
-
async function writeUserFile(user) {
|
|
55
|
-
if (!UUID_REGEX.test(user.id)) {
|
|
56
|
-
throw new Error('Invalid user ID');
|
|
57
|
-
}
|
|
58
|
-
await ensureDir();
|
|
59
|
-
const filePath = path.join(USERS_DIR, `${user.id}.json`);
|
|
60
|
-
await fs.writeFile(filePath, JSON.stringify(user, null, 2) + '\n', 'utf8');
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* List all users (password stripped).
|
|
65
|
-
*
|
|
66
|
-
* @returns {Promise<object[]>}
|
|
67
|
-
*/
|
|
68
|
-
export async function listUsers() {
|
|
69
|
-
await ensureDir();
|
|
70
|
-
let files;
|
|
71
|
-
try {
|
|
72
|
-
files = await fs.readdir(USERS_DIR);
|
|
73
|
-
} catch {
|
|
74
|
-
return [];
|
|
75
|
-
}
|
|
76
|
-
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
|
77
|
-
const users = await Promise.all(jsonFiles.map(async (file) => {
|
|
78
|
-
const raw = await fs.readFile(path.join(USERS_DIR, file), 'utf8');
|
|
79
|
-
return stripPassword(JSON.parse(raw));
|
|
80
|
-
}));
|
|
81
|
-
return users.sort((a, b) => a.name.localeCompare(b.name));
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Get a single user by ID (password stripped).
|
|
86
|
-
*
|
|
87
|
-
* @param {string} id
|
|
88
|
-
* @returns {Promise<object|null>}
|
|
89
|
-
*/
|
|
90
|
-
export async function getUserById(id) {
|
|
91
|
-
try {
|
|
92
|
-
return stripPassword(await readUserFile(id));
|
|
93
|
-
} catch {
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Get a user by email address — includes password hash (needed for login).
|
|
100
|
-
*
|
|
101
|
-
* @param {string} email
|
|
102
|
-
* @returns {Promise<object|null>}
|
|
103
|
-
*/
|
|
104
|
-
export async function getUserByEmail(email) {
|
|
105
|
-
await ensureDir();
|
|
106
|
-
let files;
|
|
107
|
-
try {
|
|
108
|
-
files = await fs.readdir(USERS_DIR);
|
|
109
|
-
} catch {
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
for (const file of files.filter(f => f.endsWith('.json'))) {
|
|
113
|
-
const raw = await fs.readFile(path.join(USERS_DIR, file), 'utf8');
|
|
114
|
-
const user = JSON.parse(raw);
|
|
115
|
-
if (user.email.toLowerCase() === email.toLowerCase()) return user;
|
|
116
|
-
}
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Create a new user.
|
|
122
|
-
*
|
|
123
|
-
* @param {object} data - { name, email, password, role }
|
|
124
|
-
* @returns {Promise<object>} Created user (password stripped)
|
|
125
|
-
*/
|
|
126
|
-
export async function createUser({ name, email, password, role = 'user', additionalRoles = [], meta, projects }) {
|
|
127
|
-
const existing = await getUserByEmail(email);
|
|
128
|
-
if (existing) throw new Error('A user with that email already exists');
|
|
129
|
-
|
|
130
|
-
const hash = await bcrypt.hash(password, config.auth.bcryptRounds);
|
|
131
|
-
const now = new Date().toISOString();
|
|
132
|
-
const user = {
|
|
133
|
-
id: uuidv4(),
|
|
134
|
-
email: email.toLowerCase().trim(),
|
|
135
|
-
name: name.trim(),
|
|
136
|
-
password: hash,
|
|
137
|
-
role,
|
|
138
|
-
// Additional roles beyond the primary — permission/visibility checks
|
|
139
|
-
// grant access if ANY role satisfies. The primary `role` is what
|
|
140
|
-
// appears in UI badges and is used for hierarchical level comparisons
|
|
141
|
-
// when a single value is needed.
|
|
142
|
-
additionalRoles: Array.isArray(additionalRoles) ? additionalRoles.filter(r => r && r !== role) : [],
|
|
143
|
-
// Project access-scope: when non-empty, the user is restricted to artefacts
|
|
144
|
-
// tagged with one of these project slugs (see canSeeArtefact in projects.js).
|
|
145
|
-
projects: Array.isArray(projects) ? projects.filter(Boolean) : [],
|
|
146
|
-
isActive: true,
|
|
147
|
-
createdAt: now,
|
|
148
|
-
updatedAt: now,
|
|
149
|
-
lastLogin: null,
|
|
150
|
-
...(meta != null && {meta})
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
await writeUserFile(user);
|
|
154
|
-
await ensureProfile(user.id);
|
|
155
|
-
return stripPassword(user);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Update an existing user.
|
|
160
|
-
* Pass a new `password` field to re-hash; omit to keep the existing hash.
|
|
161
|
-
*
|
|
162
|
-
* @param {string} id
|
|
163
|
-
* @param {object} updates
|
|
164
|
-
* @returns {Promise<object>} Updated user (password stripped)
|
|
165
|
-
*/
|
|
166
|
-
export async function updateUser(id, updates) {
|
|
167
|
-
const user = await readUserFile(id);
|
|
168
|
-
if (!user) throw new Error('User not found');
|
|
169
|
-
|
|
170
|
-
if (updates.password) {
|
|
171
|
-
updates.password = await bcrypt.hash(updates.password, config.auth.bcryptRounds);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const updated = {
|
|
175
|
-
...user,
|
|
176
|
-
...updates,
|
|
177
|
-
id: user.id,
|
|
178
|
-
updatedAt: new Date().toISOString()
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
await writeUserFile(updated);
|
|
182
|
-
|
|
183
|
-
// Invalidate per-user cache when the project access-scope changes — downstream
|
|
184
|
-
// request handlers cache scope-filtered responses keyed by `user:<id>`.
|
|
185
|
-
if (updates.projects !== undefined) {
|
|
186
|
-
const before = JSON.stringify(user.projects || []);
|
|
187
|
-
const after = JSON.stringify(updated.projects || []);
|
|
188
|
-
if (before !== after) {
|
|
189
|
-
await cache.invalidateTags([`user:${id}`]);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
return stripPassword(updated);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Delete a user file.
|
|
198
|
-
*
|
|
199
|
-
* @param {string} id
|
|
200
|
-
* @returns {Promise<void>}
|
|
201
|
-
*/
|
|
202
|
-
export async function deleteUser(id) {
|
|
203
|
-
if (!UUID_REGEX.test(id)) {
|
|
204
|
-
throw new Error('Invalid user ID');
|
|
205
|
-
}
|
|
206
|
-
const filePath = path.join(USERS_DIR, `${id}.json`);
|
|
207
|
-
await fs.unlink(filePath);
|
|
208
|
-
try {
|
|
209
|
-
await deleteProfile(id);
|
|
210
|
-
} catch {
|
|
211
|
-
// Profile deletion is best-effort — never block user deletion
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Count total users.
|
|
217
|
-
*
|
|
218
|
-
* @returns {Promise<number>}
|
|
219
|
-
*/
|
|
220
|
-
export async function countUsers() {
|
|
221
|
-
await ensureDir();
|
|
222
|
-
try {
|
|
223
|
-
const files = await fs.readdir(USERS_DIR);
|
|
224
|
-
return files.filter(f => f.endsWith('.json')).length;
|
|
225
|
-
} catch {
|
|
226
|
-
return 0;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Validate a plain-text password against a bcrypt hash.
|
|
232
|
-
*
|
|
233
|
-
* @param {string} plain
|
|
234
|
-
* @param {string} hash
|
|
235
|
-
* @returns {Promise<boolean>}
|
|
236
|
-
*/
|
|
237
|
-
export async function validatePassword(plain, hash) {
|
|
238
|
-
return bcrypt.compare(plain, hash);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Store a hashed reset token and its expiry on a user file.
|
|
243
|
-
*
|
|
244
|
-
* @param {string} id
|
|
245
|
-
* @param {string} tokenHash - SHA-256 hex digest of the plaintext token
|
|
246
|
-
* @param {string} expiry - ISO timestamp
|
|
247
|
-
* @returns {Promise<void>}
|
|
248
|
-
*/
|
|
249
|
-
export async function setResetToken(id, tokenHash, expiry) {
|
|
250
|
-
const user = await readUserFile(id);
|
|
251
|
-
user.resetTokenHash = tokenHash;
|
|
252
|
-
user.resetTokenExpiry = expiry;
|
|
253
|
-
await writeUserFile(user);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Remove the reset token fields from a user file (single-use enforcement).
|
|
258
|
-
*
|
|
259
|
-
* @param {string} id
|
|
260
|
-
* @returns {Promise<void>}
|
|
261
|
-
*/
|
|
262
|
-
export async function clearResetToken(id) {
|
|
263
|
-
const user = await readUserFile(id);
|
|
264
|
-
delete user.resetTokenHash;
|
|
265
|
-
delete user.resetTokenExpiry;
|
|
266
|
-
await writeUserFile(user);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Find a user whose stored reset token hash matches the given hash.
|
|
271
|
-
* Returns the full user object (including hash) so the caller can check expiry.
|
|
272
|
-
*
|
|
273
|
-
* @param {string} tokenHash - SHA-256 hex digest to look up
|
|
274
|
-
* @returns {Promise<object|null>}
|
|
275
|
-
*/
|
|
276
|
-
export async function getUserByResetToken(tokenHash) {
|
|
277
|
-
await ensureDir();
|
|
278
|
-
let files;
|
|
279
|
-
try {
|
|
280
|
-
files = await fs.readdir(USERS_DIR);
|
|
281
|
-
} catch {
|
|
282
|
-
return null;
|
|
283
|
-
}
|
|
284
|
-
for (const file of files.filter(f => f.endsWith('.json'))) {
|
|
285
|
-
const raw = await fs.readFile(path.join(USERS_DIR, file), 'utf8');
|
|
286
|
-
const user = JSON.parse(raw);
|
|
287
|
-
if (user.resetTokenHash === tokenHash) return user;
|
|
288
|
-
}
|
|
289
|
-
return null;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Record the current timestamp as the user's last login.
|
|
294
|
-
*
|
|
295
|
-
* @param {string} id
|
|
296
|
-
* @returns {Promise<void>}
|
|
297
|
-
*/
|
|
298
|
-
export async function touchLastLogin(id) {
|
|
299
|
-
const user = await readUserFile(id);
|
|
300
|
-
user.lastLogin = new Date().toISOString();
|
|
301
|
-
await writeUserFile(user);
|
|
302
|
-
}
|
|
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
|
+
import * as cache from './cache/index.js';
|
|
13
|
+
|
|
14
|
+
const USERS_DIR = path.resolve(config.content.usersDir);
|
|
15
|
+
|
|
16
|
+
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;
|
|
17
|
+
|
|
18
|
+
async function ensureDir() {
|
|
19
|
+
await fs.mkdir(USERS_DIR, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Strip the password field from a user object before returning it to callers.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} user
|
|
26
|
+
* @returns {object}
|
|
27
|
+
*/
|
|
28
|
+
function stripPassword(user) {
|
|
29
|
+
const {password, resetTokenHash, resetTokenExpiry, ...safe} = user;
|
|
30
|
+
return safe;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read a user file by ID.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} id
|
|
37
|
+
* @returns {Promise<object>} Full user object including password hash
|
|
38
|
+
*/
|
|
39
|
+
async function readUserFile(id) {
|
|
40
|
+
if (!UUID_REGEX.test(id)) {
|
|
41
|
+
throw new Error('Invalid user ID');
|
|
42
|
+
}
|
|
43
|
+
const filePath = path.join(USERS_DIR, `${id}.json`);
|
|
44
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
45
|
+
return JSON.parse(raw);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Write a user file.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} user
|
|
52
|
+
* @returns {Promise<void>}
|
|
53
|
+
*/
|
|
54
|
+
async function writeUserFile(user) {
|
|
55
|
+
if (!UUID_REGEX.test(user.id)) {
|
|
56
|
+
throw new Error('Invalid user ID');
|
|
57
|
+
}
|
|
58
|
+
await ensureDir();
|
|
59
|
+
const filePath = path.join(USERS_DIR, `${user.id}.json`);
|
|
60
|
+
await fs.writeFile(filePath, JSON.stringify(user, null, 2) + '\n', 'utf8');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* List all users (password stripped).
|
|
65
|
+
*
|
|
66
|
+
* @returns {Promise<object[]>}
|
|
67
|
+
*/
|
|
68
|
+
export async function listUsers() {
|
|
69
|
+
await ensureDir();
|
|
70
|
+
let files;
|
|
71
|
+
try {
|
|
72
|
+
files = await fs.readdir(USERS_DIR);
|
|
73
|
+
} catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
|
77
|
+
const users = await Promise.all(jsonFiles.map(async (file) => {
|
|
78
|
+
const raw = await fs.readFile(path.join(USERS_DIR, file), 'utf8');
|
|
79
|
+
return stripPassword(JSON.parse(raw));
|
|
80
|
+
}));
|
|
81
|
+
return users.sort((a, b) => a.name.localeCompare(b.name));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get a single user by ID (password stripped).
|
|
86
|
+
*
|
|
87
|
+
* @param {string} id
|
|
88
|
+
* @returns {Promise<object|null>}
|
|
89
|
+
*/
|
|
90
|
+
export async function getUserById(id) {
|
|
91
|
+
try {
|
|
92
|
+
return stripPassword(await readUserFile(id));
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get a user by email address — includes password hash (needed for login).
|
|
100
|
+
*
|
|
101
|
+
* @param {string} email
|
|
102
|
+
* @returns {Promise<object|null>}
|
|
103
|
+
*/
|
|
104
|
+
export async function getUserByEmail(email) {
|
|
105
|
+
await ensureDir();
|
|
106
|
+
let files;
|
|
107
|
+
try {
|
|
108
|
+
files = await fs.readdir(USERS_DIR);
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
for (const file of files.filter(f => f.endsWith('.json'))) {
|
|
113
|
+
const raw = await fs.readFile(path.join(USERS_DIR, file), 'utf8');
|
|
114
|
+
const user = JSON.parse(raw);
|
|
115
|
+
if (user.email.toLowerCase() === email.toLowerCase()) return user;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Create a new user.
|
|
122
|
+
*
|
|
123
|
+
* @param {object} data - { name, email, password, role }
|
|
124
|
+
* @returns {Promise<object>} Created user (password stripped)
|
|
125
|
+
*/
|
|
126
|
+
export async function createUser({ name, email, password, role = 'user', additionalRoles = [], meta, projects }) {
|
|
127
|
+
const existing = await getUserByEmail(email);
|
|
128
|
+
if (existing) throw new Error('A user with that email already exists');
|
|
129
|
+
|
|
130
|
+
const hash = await bcrypt.hash(password, config.auth.bcryptRounds);
|
|
131
|
+
const now = new Date().toISOString();
|
|
132
|
+
const user = {
|
|
133
|
+
id: uuidv4(),
|
|
134
|
+
email: email.toLowerCase().trim(),
|
|
135
|
+
name: name.trim(),
|
|
136
|
+
password: hash,
|
|
137
|
+
role,
|
|
138
|
+
// Additional roles beyond the primary — permission/visibility checks
|
|
139
|
+
// grant access if ANY role satisfies. The primary `role` is what
|
|
140
|
+
// appears in UI badges and is used for hierarchical level comparisons
|
|
141
|
+
// when a single value is needed.
|
|
142
|
+
additionalRoles: Array.isArray(additionalRoles) ? additionalRoles.filter(r => r && r !== role) : [],
|
|
143
|
+
// Project access-scope: when non-empty, the user is restricted to artefacts
|
|
144
|
+
// tagged with one of these project slugs (see canSeeArtefact in projects.js).
|
|
145
|
+
projects: Array.isArray(projects) ? projects.filter(Boolean) : [],
|
|
146
|
+
isActive: true,
|
|
147
|
+
createdAt: now,
|
|
148
|
+
updatedAt: now,
|
|
149
|
+
lastLogin: null,
|
|
150
|
+
...(meta != null && {meta})
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
await writeUserFile(user);
|
|
154
|
+
await ensureProfile(user.id);
|
|
155
|
+
return stripPassword(user);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Update an existing user.
|
|
160
|
+
* Pass a new `password` field to re-hash; omit to keep the existing hash.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} id
|
|
163
|
+
* @param {object} updates
|
|
164
|
+
* @returns {Promise<object>} Updated user (password stripped)
|
|
165
|
+
*/
|
|
166
|
+
export async function updateUser(id, updates) {
|
|
167
|
+
const user = await readUserFile(id);
|
|
168
|
+
if (!user) throw new Error('User not found');
|
|
169
|
+
|
|
170
|
+
if (updates.password) {
|
|
171
|
+
updates.password = await bcrypt.hash(updates.password, config.auth.bcryptRounds);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const updated = {
|
|
175
|
+
...user,
|
|
176
|
+
...updates,
|
|
177
|
+
id: user.id,
|
|
178
|
+
updatedAt: new Date().toISOString()
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
await writeUserFile(updated);
|
|
182
|
+
|
|
183
|
+
// Invalidate per-user cache when the project access-scope changes — downstream
|
|
184
|
+
// request handlers cache scope-filtered responses keyed by `user:<id>`.
|
|
185
|
+
if (updates.projects !== undefined) {
|
|
186
|
+
const before = JSON.stringify(user.projects || []);
|
|
187
|
+
const after = JSON.stringify(updated.projects || []);
|
|
188
|
+
if (before !== after) {
|
|
189
|
+
await cache.invalidateTags([`user:${id}`]);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return stripPassword(updated);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Delete a user file.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} id
|
|
200
|
+
* @returns {Promise<void>}
|
|
201
|
+
*/
|
|
202
|
+
export async function deleteUser(id) {
|
|
203
|
+
if (!UUID_REGEX.test(id)) {
|
|
204
|
+
throw new Error('Invalid user ID');
|
|
205
|
+
}
|
|
206
|
+
const filePath = path.join(USERS_DIR, `${id}.json`);
|
|
207
|
+
await fs.unlink(filePath);
|
|
208
|
+
try {
|
|
209
|
+
await deleteProfile(id);
|
|
210
|
+
} catch {
|
|
211
|
+
// Profile deletion is best-effort — never block user deletion
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Count total users.
|
|
217
|
+
*
|
|
218
|
+
* @returns {Promise<number>}
|
|
219
|
+
*/
|
|
220
|
+
export async function countUsers() {
|
|
221
|
+
await ensureDir();
|
|
222
|
+
try {
|
|
223
|
+
const files = await fs.readdir(USERS_DIR);
|
|
224
|
+
return files.filter(f => f.endsWith('.json')).length;
|
|
225
|
+
} catch {
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Validate a plain-text password against a bcrypt hash.
|
|
232
|
+
*
|
|
233
|
+
* @param {string} plain
|
|
234
|
+
* @param {string} hash
|
|
235
|
+
* @returns {Promise<boolean>}
|
|
236
|
+
*/
|
|
237
|
+
export async function validatePassword(plain, hash) {
|
|
238
|
+
return bcrypt.compare(plain, hash);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Store a hashed reset token and its expiry on a user file.
|
|
243
|
+
*
|
|
244
|
+
* @param {string} id
|
|
245
|
+
* @param {string} tokenHash - SHA-256 hex digest of the plaintext token
|
|
246
|
+
* @param {string} expiry - ISO timestamp
|
|
247
|
+
* @returns {Promise<void>}
|
|
248
|
+
*/
|
|
249
|
+
export async function setResetToken(id, tokenHash, expiry) {
|
|
250
|
+
const user = await readUserFile(id);
|
|
251
|
+
user.resetTokenHash = tokenHash;
|
|
252
|
+
user.resetTokenExpiry = expiry;
|
|
253
|
+
await writeUserFile(user);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Remove the reset token fields from a user file (single-use enforcement).
|
|
258
|
+
*
|
|
259
|
+
* @param {string} id
|
|
260
|
+
* @returns {Promise<void>}
|
|
261
|
+
*/
|
|
262
|
+
export async function clearResetToken(id) {
|
|
263
|
+
const user = await readUserFile(id);
|
|
264
|
+
delete user.resetTokenHash;
|
|
265
|
+
delete user.resetTokenExpiry;
|
|
266
|
+
await writeUserFile(user);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Find a user whose stored reset token hash matches the given hash.
|
|
271
|
+
* Returns the full user object (including hash) so the caller can check expiry.
|
|
272
|
+
*
|
|
273
|
+
* @param {string} tokenHash - SHA-256 hex digest to look up
|
|
274
|
+
* @returns {Promise<object|null>}
|
|
275
|
+
*/
|
|
276
|
+
export async function getUserByResetToken(tokenHash) {
|
|
277
|
+
await ensureDir();
|
|
278
|
+
let files;
|
|
279
|
+
try {
|
|
280
|
+
files = await fs.readdir(USERS_DIR);
|
|
281
|
+
} catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
for (const file of files.filter(f => f.endsWith('.json'))) {
|
|
285
|
+
const raw = await fs.readFile(path.join(USERS_DIR, file), 'utf8');
|
|
286
|
+
const user = JSON.parse(raw);
|
|
287
|
+
if (user.resetTokenHash === tokenHash) return user;
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Record the current timestamp as the user's last login.
|
|
294
|
+
*
|
|
295
|
+
* @param {string} id
|
|
296
|
+
* @returns {Promise<void>}
|
|
297
|
+
*/
|
|
298
|
+
export async function touchLastLogin(id) {
|
|
299
|
+
const user = await readUserFile(id);
|
|
300
|
+
user.lastLogin = new Date().toISOString();
|
|
301
|
+
await writeUserFile(user);
|
|
302
|
+
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"default": {
|
|
3
|
-
"type": "mongodb",
|
|
4
|
-
"uri": "mongodb://localhost:27017",
|
|
5
|
-
"database": "domma_cms",
|
|
6
|
-
"options": {}
|
|
7
|
-
},
|
|
8
|
-
"_comment": "Copy this file to connections.json and update with your MongoDB connection details. Each key is a named connection. Collections reference connections by name in their schema.json storage config. The 'default' connection is used when no connection name is specified."
|
|
9
|
-
}
|