artifacty 0.9.0 → 0.10.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/README.md +15 -0
- package/docs/central-team-deployment-design.md +8 -4
- package/docs/integrations.md +17 -0
- package/package.json +1 -1
- package/src/cli.js +15 -1
- package/src/lib/render.js +123 -2
- package/src/lib/storage.js +264 -31
- package/src/server.js +97 -2
package/README.md
CHANGED
|
@@ -92,6 +92,21 @@ artifacty serve --host 10.0.0.50 --share-mode team --api-token "$ARTIFACTY_BOOTS
|
|
|
92
92
|
artifacty install all --mcp-url http://10.0.0.50:8787/mcp --api-token "$ARTIFACTY_PERSONAL_TOKEN"
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
Administrators can create users individually at `/admin/users` or import them
|
|
96
|
+
from CSV. Use `email,name,role,password,password_reset_required` headers. If
|
|
97
|
+
`password` is empty, Artifacty generates a temporary password, shows it once in
|
|
98
|
+
the import result, and requires the user to change it on first sign-in.
|
|
99
|
+
|
|
100
|
+
```csv
|
|
101
|
+
email,name,role
|
|
102
|
+
user@example.com,User,user
|
|
103
|
+
admin2@example.com,Admin Two,admin
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
artifacty users import --file users.csv
|
|
108
|
+
```
|
|
109
|
+
|
|
95
110
|
Run diagnostics for the local runtime, store, server, service definitions, and MCP discovery:
|
|
96
111
|
|
|
97
112
|
```bash
|
|
@@ -121,9 +121,12 @@ artifacty serve \
|
|
|
121
121
|
|
|
122
122
|
Open `/login` after the server starts. If no users exist, the first successful
|
|
123
123
|
login form creates an administrator. Administrators can create users from
|
|
124
|
-
`/admin/users`, and every user can create or
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
`/admin/users`, paste-import users from CSV, and every user can create or
|
|
125
|
+
revoke personal API tokens from `/account`. CSV imports support
|
|
126
|
+
`email,name,role,password,password_reset_required`; blank passwords are
|
|
127
|
+
generated once and require a password change on first sign-in. Use personal
|
|
128
|
+
tokens for `artifacty install ... --api-token` so MCP and API audit logs record
|
|
129
|
+
the user's email as `actor`.
|
|
127
130
|
|
|
128
131
|
Production-like internal deployments should run Artifacty behind a TLS reverse
|
|
129
132
|
proxy, keep `ARTIFACTY_ENABLE_REACT_RENDERER` disabled unless the team trusts
|
|
@@ -185,7 +188,8 @@ token rotation policy, and SSO/OIDC.
|
|
|
185
188
|
- `artifacty install <agent> --mcp-url ... --api-token ...` writes bridge env
|
|
186
189
|
config for Claude, Codex, Gemini, GitHub Copilot, and Cursor.
|
|
187
190
|
- `/login`, `/account`, and `/admin/users` provide server-side user management,
|
|
188
|
-
administrator/user roles,
|
|
191
|
+
administrator/user roles, CSV user import, required password reset, password
|
|
192
|
+
change, and personal token issue/revoke flows.
|
|
189
193
|
- Remote MCP requests use header auth and never put tokens in URLs.
|
|
190
194
|
- Tests cover direct HTTP MCP calls and stdio bridge calls to a token-protected
|
|
191
195
|
central server.
|
package/docs/integrations.md
CHANGED
|
@@ -103,6 +103,23 @@ node src/cli.js install all \
|
|
|
103
103
|
--api-token "$ARTIFACTY_PERSONAL_TOKEN"
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
+
Administrators can create users one at a time from `/admin/users` or import a
|
|
107
|
+
CSV from the same page. The CLI can import the same file when run on the server:
|
|
108
|
+
|
|
109
|
+
```csv
|
|
110
|
+
email,name,role,password,password_reset_required
|
|
111
|
+
user@example.com,User,user,,
|
|
112
|
+
admin2@example.com,Admin Two,admin,temporary-password,true
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
artifacty users import --file users.csv
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Rows without a password receive a generated temporary password. Generated
|
|
120
|
+
passwords are shown only in the import result, and imported users must change
|
|
121
|
+
their password before they can continue to `/account` and issue MCP/API tokens.
|
|
122
|
+
|
|
106
123
|
- Claude: writes project `.mcp.json`. Claude Code's startup timeout is controlled by the parent `MCP_TIMEOUT` environment variable and defaults to 30 seconds, so Artifacty does not add a per-server `.mcp.json` `timeout` field.
|
|
107
124
|
- Codex: writes or replaces the `[mcp_servers.artifacty]` block in `~/.codex/config.toml` unless `--config` is provided. The generated block uses a 30 second startup timeout so slower Windows or cold-start environments can load the MCP server reliably.
|
|
108
125
|
- Gemini: writes project `.gemini/settings.json` with a 30 second timeout.
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
createArtifact,
|
|
9
9
|
createStore,
|
|
10
10
|
getArtifact,
|
|
11
|
+
importUsersFromCsv,
|
|
11
12
|
listAuditEvents,
|
|
12
13
|
listArtifactsPage,
|
|
13
14
|
rebuildSearchIndex,
|
|
@@ -300,6 +301,18 @@ async function main() {
|
|
|
300
301
|
return;
|
|
301
302
|
}
|
|
302
303
|
|
|
304
|
+
if (command === "users") {
|
|
305
|
+
const action = options._[0];
|
|
306
|
+
if (action === "import") {
|
|
307
|
+
const csv = await readContent(options);
|
|
308
|
+
printJson(await importUsersFromCsv(store, csv, {
|
|
309
|
+
passwordResetRequired: !options.noPasswordReset
|
|
310
|
+
}));
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
throw new Error("users requires an action: import");
|
|
314
|
+
}
|
|
315
|
+
|
|
303
316
|
if (command === "service") {
|
|
304
317
|
const action = options._[0] || "plist";
|
|
305
318
|
printJson(await serviceCommand(action, {
|
|
@@ -351,7 +364,7 @@ function parseArgs(args) {
|
|
|
351
364
|
}
|
|
352
365
|
|
|
353
366
|
const key = arg.slice(2);
|
|
354
|
-
if (key === "raw" || key === "dry-run" || key === "trust" || key === "include-archived" || key === "allow-secrets" || key === "generate-token" || key === "detach" || key === "foreground" || key === "force" || key === "skip-mcp" || key === "mcp-http") {
|
|
367
|
+
if (key === "raw" || key === "dry-run" || key === "trust" || key === "include-archived" || key === "allow-secrets" || key === "generate-token" || key === "detach" || key === "foreground" || key === "force" || key === "skip-mcp" || key === "mcp-http" || key === "no-password-reset") {
|
|
355
368
|
options[toCamelCase(key)] = true;
|
|
356
369
|
continue;
|
|
357
370
|
}
|
|
@@ -453,6 +466,7 @@ Usage:
|
|
|
453
466
|
artifacty export --file <path>
|
|
454
467
|
artifacty backup [--file <path>]
|
|
455
468
|
artifacty import-store --file <path>
|
|
469
|
+
artifacty users import --file <path.csv> [--no-password-reset]
|
|
456
470
|
artifacty service plist|unit|task|install|uninstall [--platform macos|linux|windows] [--dry-run] [--path <path>] [--mcp-http]
|
|
457
471
|
artifacty list [--query text] [--tag tag] [--source agent] [--limit 50] [--offset 0] [--include-archived]
|
|
458
472
|
artifacty show <id> [--version n] [--raw]
|
package/src/lib/render.js
CHANGED
|
@@ -321,6 +321,7 @@ export function renderAccountPage({ baseUrl, user, tokens = [], createdToken = "
|
|
|
321
321
|
<nav>
|
|
322
322
|
<a href="${view.href("/")}">${view.text("nav.index")}</a>
|
|
323
323
|
${user.role === "admin" ? `<a href="/admin/users">Users</a>` : ""}
|
|
324
|
+
<a href="/account/password">Password</a>
|
|
324
325
|
<form class="nav-form" method="post" action="/logout"><button type="submit">Sign out</button></form>
|
|
325
326
|
${languageSwitcher(view)}
|
|
326
327
|
</nav>
|
|
@@ -357,14 +358,60 @@ export function renderAccountPage({ baseUrl, user, tokens = [], createdToken = "
|
|
|
357
358
|
});
|
|
358
359
|
}
|
|
359
360
|
|
|
360
|
-
export function
|
|
361
|
+
export function renderPasswordPage({ baseUrl, user, required = false, error = "", success = "", locale = DEFAULT_LOCALE, currentPath = "/account/password" }) {
|
|
362
|
+
const view = viewContext(locale, currentPath);
|
|
363
|
+
return pageShell({
|
|
364
|
+
title: "Change Password",
|
|
365
|
+
body: `
|
|
366
|
+
<header class="topbar">
|
|
367
|
+
<div>
|
|
368
|
+
<h1>Change Password</h1>
|
|
369
|
+
<p>${escapeHtml(user.email)} · ${escapeHtml(baseUrl)}</p>
|
|
370
|
+
</div>
|
|
371
|
+
<nav>
|
|
372
|
+
<a href="${view.href("/")}">${view.text("nav.index")}</a>
|
|
373
|
+
${required ? "" : `<a href="/account">Account</a>`}
|
|
374
|
+
<form class="nav-form" method="post" action="/logout"><button type="submit">Sign out</button></form>
|
|
375
|
+
${languageSwitcher(view)}
|
|
376
|
+
</nav>
|
|
377
|
+
</header>
|
|
378
|
+
<main class="artifact-editor auth-panel">
|
|
379
|
+
${required ? `<p class="auth-warning">Password change is required before continuing.</p>` : ""}
|
|
380
|
+
${error ? `<p class="auth-error">${escapeHtml(error)}</p>` : ""}
|
|
381
|
+
${success ? `<p class="auth-success">${escapeHtml(success)}</p>` : ""}
|
|
382
|
+
<form class="editor-form auth-form" method="post" action="/account/password">
|
|
383
|
+
<section class="editor-fields">
|
|
384
|
+
<label class="field">
|
|
385
|
+
<span>Current password</span>
|
|
386
|
+
<input type="password" name="currentPassword" autocomplete="current-password" required>
|
|
387
|
+
</label>
|
|
388
|
+
<label class="field">
|
|
389
|
+
<span>New password</span>
|
|
390
|
+
<input type="password" name="newPassword" autocomplete="new-password" minlength="8" required>
|
|
391
|
+
</label>
|
|
392
|
+
<label class="field">
|
|
393
|
+
<span>Confirm password</span>
|
|
394
|
+
<input type="password" name="confirmPassword" autocomplete="new-password" minlength="8" required>
|
|
395
|
+
</label>
|
|
396
|
+
</section>
|
|
397
|
+
<footer class="editor-actions">
|
|
398
|
+
<button type="submit">Change password</button>
|
|
399
|
+
</footer>
|
|
400
|
+
</form>
|
|
401
|
+
</main>
|
|
402
|
+
`,
|
|
403
|
+
locale: view.locale
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function renderAdminUsersPage({ baseUrl, user, users = [], importResult = null, importError = "", locale = DEFAULT_LOCALE, currentPath = "/admin/users" }) {
|
|
361
408
|
const view = viewContext(locale, currentPath);
|
|
362
409
|
const rows = users.map((item) => `
|
|
363
410
|
<tr>
|
|
364
411
|
<td>${escapeHtml(item.email)}</td>
|
|
365
412
|
<td>${escapeHtml(item.name)}</td>
|
|
366
413
|
<td>${escapeHtml(item.role)}</td>
|
|
367
|
-
<td>${item.active ? "Active" : "Disabled"}</td>
|
|
414
|
+
<td>${item.active ? "Active" : "Disabled"}${item.passwordResetRequired ? " · Reset required" : ""}</td>
|
|
368
415
|
<td>${escapeHtml(item.createdAt)}</td>
|
|
369
416
|
<td>
|
|
370
417
|
${item.id === user.id ? "" : `<form method="post" action="/admin/users/${encodeURIComponent(item.id)}/${item.active ? "disable" : "enable"}">
|
|
@@ -373,6 +420,38 @@ export function renderAdminUsersPage({ baseUrl, user, users = [], locale = DEFAU
|
|
|
373
420
|
</td>
|
|
374
421
|
</tr>
|
|
375
422
|
`).join("");
|
|
423
|
+
const createdRows = (importResult?.created || []).map((item) => `
|
|
424
|
+
<tr>
|
|
425
|
+
<td>${escapeHtml(item.user.email)}</td>
|
|
426
|
+
<td>${escapeHtml(item.user.role)}</td>
|
|
427
|
+
<td>${item.user.passwordResetRequired ? "Yes" : "No"}</td>
|
|
428
|
+
<td>${item.passwordGenerated ? `<code>${escapeHtml(item.temporaryPassword)}</code>` : "Provided in CSV"}</td>
|
|
429
|
+
</tr>
|
|
430
|
+
`).join("");
|
|
431
|
+
const skippedRows = (importResult?.skipped || []).map((item) => `
|
|
432
|
+
<tr><td>${escapeHtml(item.row)}</td><td>${escapeHtml(item.email)}</td><td>${escapeHtml(item.reason)}</td></tr>
|
|
433
|
+
`).join("");
|
|
434
|
+
const failedRows = (importResult?.failed || []).map((item) => `
|
|
435
|
+
<tr><td>${escapeHtml(item.row)}</td><td>${escapeHtml(item.email)}</td><td>${escapeHtml(item.error)}</td></tr>
|
|
436
|
+
`).join("");
|
|
437
|
+
const importSummary = importResult ? `
|
|
438
|
+
<section class="meta-card">
|
|
439
|
+
<h2>Import results</h2>
|
|
440
|
+
<p class="muted">${importResult.created.length} created, ${importResult.skipped.length} skipped, ${importResult.failed.length} failed.</p>
|
|
441
|
+
${createdRows ? `<table class="data-table">
|
|
442
|
+
<thead><tr><th>Email</th><th>Role</th><th>Reset required</th><th>Temporary password</th></tr></thead>
|
|
443
|
+
<tbody>${createdRows}</tbody>
|
|
444
|
+
</table>` : ""}
|
|
445
|
+
${skippedRows ? `<h3>Skipped</h3><table class="data-table">
|
|
446
|
+
<thead><tr><th>Row</th><th>Email</th><th>Reason</th></tr></thead>
|
|
447
|
+
<tbody>${skippedRows}</tbody>
|
|
448
|
+
</table>` : ""}
|
|
449
|
+
${failedRows ? `<h3>Failed</h3><table class="data-table">
|
|
450
|
+
<thead><tr><th>Row</th><th>Email</th><th>Error</th></tr></thead>
|
|
451
|
+
<tbody>${failedRows}</tbody>
|
|
452
|
+
</table>` : ""}
|
|
453
|
+
</section>
|
|
454
|
+
` : "";
|
|
376
455
|
|
|
377
456
|
return pageShell({
|
|
378
457
|
title: "Users",
|
|
@@ -397,10 +476,24 @@ export function renderAdminUsersPage({ baseUrl, user, users = [], locale = DEFAU
|
|
|
397
476
|
<label class="field"><span>Name</span><input name="name"></label>
|
|
398
477
|
<label class="field"><span>Role</span><select name="role"><option value="user">user</option><option value="admin">admin</option></select></label>
|
|
399
478
|
<label class="field"><span>Password</span><input type="password" name="password" minlength="8" required></label>
|
|
479
|
+
<label class="check-field"><input type="checkbox" name="passwordResetRequired"> Require password change</label>
|
|
400
480
|
</section>
|
|
401
481
|
<footer class="editor-actions"><button type="submit">Create user</button></footer>
|
|
402
482
|
</form>
|
|
403
483
|
</section>
|
|
484
|
+
<section class="meta-card">
|
|
485
|
+
<h2>Import users from CSV</h2>
|
|
486
|
+
<p class="muted">Use headers: email, name, role, password, password_reset_required. Missing passwords are generated and must be changed at first sign-in.</p>
|
|
487
|
+
${importError ? `<p class="auth-error">${escapeHtml(importError)}</p>` : ""}
|
|
488
|
+
<form class="editor-form" method="post" action="/admin/users/import">
|
|
489
|
+
<label class="field content-field">
|
|
490
|
+
<span>CSV</span>
|
|
491
|
+
<textarea class="compact-textarea" name="csv" spellcheck="false" placeholder="email,name,role user@example.com,User,user" required></textarea>
|
|
492
|
+
</label>
|
|
493
|
+
<footer class="editor-actions"><button type="submit">Import users</button></footer>
|
|
494
|
+
</form>
|
|
495
|
+
</section>
|
|
496
|
+
${importSummary}
|
|
404
497
|
<section class="meta-card">
|
|
405
498
|
<h2>Existing users</h2>
|
|
406
499
|
<table class="data-table">
|
|
@@ -980,6 +1073,8 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
|
|
|
980
1073
|
grid-template-columns: minmax(220px, 1fr);
|
|
981
1074
|
}
|
|
982
1075
|
.auth-error,
|
|
1076
|
+
.auth-success,
|
|
1077
|
+
.auth-warning,
|
|
983
1078
|
.token-once {
|
|
984
1079
|
border: 1px solid var(--line);
|
|
985
1080
|
border-radius: 8px;
|
|
@@ -991,6 +1086,16 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
|
|
|
991
1086
|
background: #fef2f2;
|
|
992
1087
|
border-color: #fecaca;
|
|
993
1088
|
}
|
|
1089
|
+
.auth-success {
|
|
1090
|
+
color: #166534;
|
|
1091
|
+
background: #f0fdf4;
|
|
1092
|
+
border-color: #bbf7d0;
|
|
1093
|
+
}
|
|
1094
|
+
.auth-warning {
|
|
1095
|
+
color: #92400e;
|
|
1096
|
+
background: #fffbeb;
|
|
1097
|
+
border-color: #fde68a;
|
|
1098
|
+
}
|
|
994
1099
|
.muted {
|
|
995
1100
|
color: var(--muted);
|
|
996
1101
|
}
|
|
@@ -1049,6 +1154,19 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
|
|
|
1049
1154
|
text-transform: uppercase;
|
|
1050
1155
|
color: var(--faint);
|
|
1051
1156
|
}
|
|
1157
|
+
.check-field {
|
|
1158
|
+
display: flex;
|
|
1159
|
+
gap: 8px;
|
|
1160
|
+
align-items: center;
|
|
1161
|
+
min-height: 38px;
|
|
1162
|
+
color: var(--muted);
|
|
1163
|
+
font-size: 13px;
|
|
1164
|
+
}
|
|
1165
|
+
.check-field input {
|
|
1166
|
+
width: auto;
|
|
1167
|
+
min-height: 0;
|
|
1168
|
+
padding: 0;
|
|
1169
|
+
}
|
|
1052
1170
|
input,
|
|
1053
1171
|
select,
|
|
1054
1172
|
textarea {
|
|
@@ -1077,6 +1195,9 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
|
|
|
1077
1195
|
white-space: pre;
|
|
1078
1196
|
overflow: auto;
|
|
1079
1197
|
}
|
|
1198
|
+
.compact-textarea {
|
|
1199
|
+
min-height: 160px;
|
|
1200
|
+
}
|
|
1080
1201
|
.textarea-enhanced {
|
|
1081
1202
|
display: none;
|
|
1082
1203
|
}
|
package/src/lib/storage.js
CHANGED
|
@@ -664,36 +664,12 @@ export async function countUsers(store = createStore()) {
|
|
|
664
664
|
}
|
|
665
665
|
|
|
666
666
|
export async function createUser(store = createStore(), input = {}) {
|
|
667
|
-
const email = normalizeEmail(input.email);
|
|
668
|
-
const password = String(input.password || "");
|
|
669
|
-
if (!email) {
|
|
670
|
-
throw Object.assign(new Error("User email is required"), { statusCode: 400, code: "USER_EMAIL_REQUIRED" });
|
|
671
|
-
}
|
|
672
|
-
if (password.length < 8) {
|
|
673
|
-
throw Object.assign(new Error("User password must be at least 8 characters"), { statusCode: 400, code: "USER_PASSWORD_WEAK" });
|
|
674
|
-
}
|
|
675
|
-
const role = normalizeUserRole(input.role || "user");
|
|
676
|
-
const name = normalizeOptionalString(input.name) || email;
|
|
677
|
-
const now = new Date().toISOString();
|
|
678
|
-
const user = {
|
|
679
|
-
id: randomUUID(),
|
|
680
|
-
email,
|
|
681
|
-
name,
|
|
682
|
-
role,
|
|
683
|
-
active: true,
|
|
684
|
-
createdAt: now,
|
|
685
|
-
updatedAt: now
|
|
686
|
-
};
|
|
687
667
|
const db = openDatabase(store);
|
|
688
668
|
try {
|
|
689
|
-
db
|
|
690
|
-
INSERT INTO users (id, email, name, role, password_hash, active, created_at, updated_at)
|
|
691
|
-
VALUES (?, ?, ?, ?, ?, 1, ?, ?)
|
|
692
|
-
`).run(user.id, user.email, user.name, user.role, hashPassword(password), now, now);
|
|
693
|
-
return user;
|
|
669
|
+
return insertUser(db, input);
|
|
694
670
|
} catch (error) {
|
|
695
671
|
if (/UNIQUE/i.test(error.message)) {
|
|
696
|
-
throw Object.assign(new Error(`User already exists: ${email}`), { statusCode: 409, code: "USER_EXISTS" });
|
|
672
|
+
throw Object.assign(new Error(`User already exists: ${normalizeEmail(input.email)}`), { statusCode: 409, code: "USER_EXISTS" });
|
|
697
673
|
}
|
|
698
674
|
throw error;
|
|
699
675
|
} finally {
|
|
@@ -701,11 +677,81 @@ export async function createUser(store = createStore(), input = {}) {
|
|
|
701
677
|
}
|
|
702
678
|
}
|
|
703
679
|
|
|
680
|
+
export async function importUsersFromCsv(store = createStore(), csv, options = {}) {
|
|
681
|
+
const records = parseUserCsv(csv);
|
|
682
|
+
const result = {
|
|
683
|
+
created: [],
|
|
684
|
+
skipped: [],
|
|
685
|
+
failed: [],
|
|
686
|
+
totalRows: records.length
|
|
687
|
+
};
|
|
688
|
+
const db = openDatabase(store);
|
|
689
|
+
try {
|
|
690
|
+
for (const record of records) {
|
|
691
|
+
const email = normalizeEmail(record.email);
|
|
692
|
+
if (!email) {
|
|
693
|
+
result.failed.push({
|
|
694
|
+
row: record.row,
|
|
695
|
+
email: "",
|
|
696
|
+
error: "User email is required",
|
|
697
|
+
code: "USER_EMAIL_REQUIRED"
|
|
698
|
+
});
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const providedPassword = normalizeOptionalString(record.password || record.temporary_password);
|
|
703
|
+
const password = providedPassword || generateTemporaryPassword();
|
|
704
|
+
const resetFromCsv = firstDefined(
|
|
705
|
+
record.password_reset_required,
|
|
706
|
+
record.require_password_reset,
|
|
707
|
+
record.force_password_reset,
|
|
708
|
+
record.reset_required
|
|
709
|
+
);
|
|
710
|
+
const passwordResetRequired = !providedPassword || (resetFromCsv === undefined
|
|
711
|
+
? options.passwordResetRequired !== false
|
|
712
|
+
: parseBooleanOption(resetFromCsv));
|
|
713
|
+
|
|
714
|
+
try {
|
|
715
|
+
const user = insertUser(db, {
|
|
716
|
+
email,
|
|
717
|
+
name: record.name || email,
|
|
718
|
+
role: record.role || "user",
|
|
719
|
+
password,
|
|
720
|
+
passwordResetRequired
|
|
721
|
+
});
|
|
722
|
+
result.created.push({
|
|
723
|
+
user,
|
|
724
|
+
passwordGenerated: !providedPassword,
|
|
725
|
+
temporaryPassword: !providedPassword ? password : undefined
|
|
726
|
+
});
|
|
727
|
+
} catch (error) {
|
|
728
|
+
if (error.code === "USER_EXISTS" || /UNIQUE/i.test(error.message)) {
|
|
729
|
+
result.skipped.push({
|
|
730
|
+
row: record.row,
|
|
731
|
+
email,
|
|
732
|
+
reason: "User already exists"
|
|
733
|
+
});
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
result.failed.push({
|
|
737
|
+
row: record.row,
|
|
738
|
+
email,
|
|
739
|
+
error: error.message,
|
|
740
|
+
code: error.code || "USER_IMPORT_FAILED"
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return result;
|
|
745
|
+
} finally {
|
|
746
|
+
db.close();
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
704
750
|
export async function listUsers(store = createStore()) {
|
|
705
751
|
const db = openDatabase(store);
|
|
706
752
|
try {
|
|
707
753
|
return db.prepare(`
|
|
708
|
-
SELECT id, email, name, role, active, created_at, updated_at
|
|
754
|
+
SELECT id, email, name, role, active, password_reset_required, created_at, updated_at
|
|
709
755
|
FROM users
|
|
710
756
|
ORDER BY created_at ASC
|
|
711
757
|
`).all().map(userFromRow);
|
|
@@ -723,18 +769,48 @@ export async function setUserActive(store = createStore(), id, active) {
|
|
|
723
769
|
throw Object.assign(new Error(`User not found: ${id}`), { statusCode: 404, code: "USER_NOT_FOUND" });
|
|
724
770
|
}
|
|
725
771
|
return userFromRow(db.prepare(`
|
|
726
|
-
SELECT id, email, name, role, active, created_at, updated_at FROM users WHERE id = ?
|
|
772
|
+
SELECT id, email, name, role, active, password_reset_required, created_at, updated_at FROM users WHERE id = ?
|
|
727
773
|
`).get(id));
|
|
728
774
|
} finally {
|
|
729
775
|
db.close();
|
|
730
776
|
}
|
|
731
777
|
}
|
|
732
778
|
|
|
779
|
+
export async function changeUserPassword(store = createStore(), userId, input = {}) {
|
|
780
|
+
const password = String(input.password || input.newPassword || "");
|
|
781
|
+
if (password.length < 8) {
|
|
782
|
+
throw Object.assign(new Error("User password must be at least 8 characters"), { statusCode: 400, code: "USER_PASSWORD_WEAK" });
|
|
783
|
+
}
|
|
784
|
+
const db = openDatabase(store);
|
|
785
|
+
try {
|
|
786
|
+
const existing = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId);
|
|
787
|
+
if (!existing) {
|
|
788
|
+
throw Object.assign(new Error(`User not found: ${userId}`), { statusCode: 404, code: "USER_NOT_FOUND" });
|
|
789
|
+
}
|
|
790
|
+
if (input.currentPassword !== undefined && !verifyPassword(input.currentPassword, existing.password_hash)) {
|
|
791
|
+
throw Object.assign(new Error("Current password is incorrect"), { statusCode: 400, code: "CURRENT_PASSWORD_INVALID" });
|
|
792
|
+
}
|
|
793
|
+
const now = new Date().toISOString();
|
|
794
|
+
db.prepare(`
|
|
795
|
+
UPDATE users
|
|
796
|
+
SET password_hash = ?, password_reset_required = ?, updated_at = ?
|
|
797
|
+
WHERE id = ?
|
|
798
|
+
`).run(hashPassword(password), input.passwordResetRequired ? 1 : 0, now, userId);
|
|
799
|
+
return userFromRow(db.prepare(`
|
|
800
|
+
SELECT id, email, name, role, active, password_reset_required, created_at, updated_at
|
|
801
|
+
FROM users
|
|
802
|
+
WHERE id = ?
|
|
803
|
+
`).get(userId));
|
|
804
|
+
} finally {
|
|
805
|
+
db.close();
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
733
809
|
export async function verifyUserPassword(store = createStore(), email, password) {
|
|
734
810
|
const db = openDatabase(store);
|
|
735
811
|
try {
|
|
736
812
|
const row = db.prepare(`
|
|
737
|
-
SELECT id, email, name, role, password_hash, active, created_at, updated_at
|
|
813
|
+
SELECT id, email, name, role, password_hash, active, password_reset_required, created_at, updated_at
|
|
738
814
|
FROM users
|
|
739
815
|
WHERE email = ?
|
|
740
816
|
`).get(normalizeEmail(email));
|
|
@@ -776,7 +852,7 @@ export async function getSessionUser(store = createStore(), token) {
|
|
|
776
852
|
const db = openDatabase(store);
|
|
777
853
|
try {
|
|
778
854
|
const row = db.prepare(`
|
|
779
|
-
SELECT s.id AS session_id, s.expires_at, u.id, u.email, u.name, u.role, u.active, u.created_at, u.updated_at
|
|
855
|
+
SELECT s.id AS session_id, s.expires_at, u.id, u.email, u.name, u.role, u.active, u.password_reset_required, u.created_at, u.updated_at
|
|
780
856
|
FROM sessions s
|
|
781
857
|
JOIN users u ON u.id = s.user_id
|
|
782
858
|
WHERE s.token_hash = ? AND s.revoked_at IS NULL
|
|
@@ -866,7 +942,7 @@ export async function authenticateApiToken(store = createStore(), token) {
|
|
|
866
942
|
try {
|
|
867
943
|
const tokenHash = hashToken(token);
|
|
868
944
|
const row = db.prepare(`
|
|
869
|
-
SELECT t.id AS token_id, t.name AS token_name, u.id, u.email, u.name, u.role, u.active, u.created_at, u.updated_at
|
|
945
|
+
SELECT t.id AS token_id, t.name AS token_name, u.id, u.email, u.name, u.role, u.active, u.password_reset_required, u.created_at, u.updated_at
|
|
870
946
|
FROM api_tokens t
|
|
871
947
|
JOIN users u ON u.id = t.user_id
|
|
872
948
|
WHERE t.token_hash = ? AND t.revoked_at IS NULL
|
|
@@ -1039,6 +1115,7 @@ function initializeSchema(db) {
|
|
|
1039
1115
|
role TEXT NOT NULL DEFAULT 'user',
|
|
1040
1116
|
password_hash TEXT NOT NULL,
|
|
1041
1117
|
active INTEGER NOT NULL DEFAULT 1,
|
|
1118
|
+
password_reset_required INTEGER NOT NULL DEFAULT 0,
|
|
1042
1119
|
created_at TEXT NOT NULL,
|
|
1043
1120
|
updated_at TEXT NOT NULL
|
|
1044
1121
|
);
|
|
@@ -1075,6 +1152,7 @@ function initializeSchema(db) {
|
|
|
1075
1152
|
ensureColumn(db, "artifacts", "artifact_type", "TEXT NOT NULL DEFAULT 'document'");
|
|
1076
1153
|
ensureColumn(db, "artifacts", "schema_version", "INTEGER NOT NULL DEFAULT 1");
|
|
1077
1154
|
ensureColumn(db, "artifacts", "archived_at", "TEXT");
|
|
1155
|
+
ensureColumn(db, "users", "password_reset_required", "INTEGER NOT NULL DEFAULT 0");
|
|
1078
1156
|
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('store_version', ?)").run(String(STORE_VERSION));
|
|
1079
1157
|
ensureSearchTable(db);
|
|
1080
1158
|
}
|
|
@@ -1380,6 +1458,7 @@ function userFromRow(row) {
|
|
|
1380
1458
|
name: row.name,
|
|
1381
1459
|
role: row.role,
|
|
1382
1460
|
active: Boolean(row.active),
|
|
1461
|
+
passwordResetRequired: Boolean(row.password_reset_required),
|
|
1383
1462
|
createdAt: row.created_at,
|
|
1384
1463
|
updatedAt: row.updated_at
|
|
1385
1464
|
};
|
|
@@ -1587,6 +1666,156 @@ function normalizeUserRole(value) {
|
|
|
1587
1666
|
return role;
|
|
1588
1667
|
}
|
|
1589
1668
|
|
|
1669
|
+
function insertUser(db, input = {}) {
|
|
1670
|
+
const email = normalizeEmail(input.email);
|
|
1671
|
+
const password = String(input.password || "");
|
|
1672
|
+
if (!email) {
|
|
1673
|
+
throw Object.assign(new Error("User email is required"), { statusCode: 400, code: "USER_EMAIL_REQUIRED" });
|
|
1674
|
+
}
|
|
1675
|
+
if (password.length < 8) {
|
|
1676
|
+
throw Object.assign(new Error("User password must be at least 8 characters"), { statusCode: 400, code: "USER_PASSWORD_WEAK" });
|
|
1677
|
+
}
|
|
1678
|
+
const role = normalizeUserRole(input.role || "user");
|
|
1679
|
+
const name = normalizeOptionalString(input.name) || email;
|
|
1680
|
+
const now = new Date().toISOString();
|
|
1681
|
+
const user = {
|
|
1682
|
+
id: randomUUID(),
|
|
1683
|
+
email,
|
|
1684
|
+
name,
|
|
1685
|
+
role,
|
|
1686
|
+
active: true,
|
|
1687
|
+
passwordResetRequired: Boolean(input.passwordResetRequired),
|
|
1688
|
+
createdAt: now,
|
|
1689
|
+
updatedAt: now
|
|
1690
|
+
};
|
|
1691
|
+
try {
|
|
1692
|
+
db.prepare(`
|
|
1693
|
+
INSERT INTO users (id, email, name, role, password_hash, active, password_reset_required, created_at, updated_at)
|
|
1694
|
+
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)
|
|
1695
|
+
`).run(
|
|
1696
|
+
user.id,
|
|
1697
|
+
user.email,
|
|
1698
|
+
user.name,
|
|
1699
|
+
user.role,
|
|
1700
|
+
hashPassword(password),
|
|
1701
|
+
user.passwordResetRequired ? 1 : 0,
|
|
1702
|
+
now,
|
|
1703
|
+
now
|
|
1704
|
+
);
|
|
1705
|
+
return user;
|
|
1706
|
+
} catch (error) {
|
|
1707
|
+
if (/UNIQUE/i.test(error.message)) {
|
|
1708
|
+
throw Object.assign(new Error(`User already exists: ${email}`), { statusCode: 409, code: "USER_EXISTS" });
|
|
1709
|
+
}
|
|
1710
|
+
throw error;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
function parseUserCsv(csv) {
|
|
1715
|
+
const rows = parseCsvRows(csv).filter((row) => row.some((cell) => normalizeOptionalString(cell)));
|
|
1716
|
+
if (rows.length === 0) {
|
|
1717
|
+
return [];
|
|
1718
|
+
}
|
|
1719
|
+
const headers = rows[0].map(normalizeCsvHeader);
|
|
1720
|
+
if (!headers.includes("email")) {
|
|
1721
|
+
throw Object.assign(new Error("User CSV requires an email header"), {
|
|
1722
|
+
statusCode: 400,
|
|
1723
|
+
code: "USER_CSV_EMAIL_REQUIRED"
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
return rows.slice(1).map((row, index) => {
|
|
1727
|
+
const record = { row: index + 2 };
|
|
1728
|
+
headers.forEach((header, columnIndex) => {
|
|
1729
|
+
if (header) {
|
|
1730
|
+
record[header] = normalizeOptionalString(row[columnIndex]);
|
|
1731
|
+
}
|
|
1732
|
+
});
|
|
1733
|
+
return record;
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
function parseCsvRows(csv) {
|
|
1738
|
+
const text = String(csv || "").replace(/^\uFEFF/, "");
|
|
1739
|
+
const rows = [];
|
|
1740
|
+
let row = [];
|
|
1741
|
+
let cell = "";
|
|
1742
|
+
let quoted = false;
|
|
1743
|
+
|
|
1744
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
1745
|
+
const char = text[index];
|
|
1746
|
+
if (quoted) {
|
|
1747
|
+
if (char === "\"" && text[index + 1] === "\"") {
|
|
1748
|
+
cell += "\"";
|
|
1749
|
+
index += 1;
|
|
1750
|
+
} else if (char === "\"") {
|
|
1751
|
+
quoted = false;
|
|
1752
|
+
} else {
|
|
1753
|
+
cell += char;
|
|
1754
|
+
}
|
|
1755
|
+
continue;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
if (char === "\"") {
|
|
1759
|
+
quoted = true;
|
|
1760
|
+
} else if (char === ",") {
|
|
1761
|
+
row.push(cell);
|
|
1762
|
+
cell = "";
|
|
1763
|
+
} else if (char === "\n") {
|
|
1764
|
+
row.push(cell.replace(/\r$/, ""));
|
|
1765
|
+
rows.push(row);
|
|
1766
|
+
row = [];
|
|
1767
|
+
cell = "";
|
|
1768
|
+
} else {
|
|
1769
|
+
cell += char;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
if (quoted) {
|
|
1774
|
+
throw Object.assign(new Error("User CSV has an unterminated quoted field"), {
|
|
1775
|
+
statusCode: 400,
|
|
1776
|
+
code: "USER_CSV_INVALID"
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
row.push(cell.replace(/\r$/, ""));
|
|
1780
|
+
rows.push(row);
|
|
1781
|
+
return rows;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
function normalizeCsvHeader(value) {
|
|
1785
|
+
const header = normalizeOptionalString(value).toLowerCase().replace(/[\s-]+/g, "_");
|
|
1786
|
+
if (header === "mail" || header === "email_address") {
|
|
1787
|
+
return "email";
|
|
1788
|
+
}
|
|
1789
|
+
if (header === "display_name" || header === "full_name") {
|
|
1790
|
+
return "name";
|
|
1791
|
+
}
|
|
1792
|
+
if (header === "temporary_password" || header === "temp_password") {
|
|
1793
|
+
return "temporary_password";
|
|
1794
|
+
}
|
|
1795
|
+
if (header === "force_reset" || header === "must_change_password") {
|
|
1796
|
+
return "password_reset_required";
|
|
1797
|
+
}
|
|
1798
|
+
return header;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
function firstDefined(...values) {
|
|
1802
|
+
return values.find((value) => value !== undefined && value !== "");
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
function parseBooleanOption(value) {
|
|
1806
|
+
const normalized = normalizeOptionalString(value).toLowerCase();
|
|
1807
|
+
if (["1", "true", "yes", "y", "on"].includes(normalized)) {
|
|
1808
|
+
return true;
|
|
1809
|
+
}
|
|
1810
|
+
if (["0", "false", "no", "n", "off"].includes(normalized)) {
|
|
1811
|
+
return false;
|
|
1812
|
+
}
|
|
1813
|
+
throw Object.assign(new Error(`Invalid boolean value: ${value}`), {
|
|
1814
|
+
statusCode: 400,
|
|
1815
|
+
code: "INVALID_BOOLEAN"
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1590
1819
|
function normalizeOptionalString(value) {
|
|
1591
1820
|
if (value === undefined || value === null) {
|
|
1592
1821
|
return "";
|
|
@@ -1594,6 +1823,10 @@ function normalizeOptionalString(value) {
|
|
|
1594
1823
|
return String(value).trim();
|
|
1595
1824
|
}
|
|
1596
1825
|
|
|
1826
|
+
function generateTemporaryPassword() {
|
|
1827
|
+
return `tmp_${randomBytes(18).toString("base64url")}`;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1597
1830
|
function generateOpaqueToken(prefix) {
|
|
1598
1831
|
return `${prefix}_${randomBytes(32).toString("base64url")}`;
|
|
1599
1832
|
}
|
package/src/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import {
|
|
8
8
|
archiveArtifact,
|
|
9
9
|
authenticateApiToken,
|
|
10
|
+
changeUserPassword,
|
|
10
11
|
countUsers,
|
|
11
12
|
createApiToken,
|
|
12
13
|
createArtifact,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
createUser,
|
|
16
17
|
getArtifact,
|
|
17
18
|
getSessionUser,
|
|
19
|
+
importUsersFromCsv,
|
|
18
20
|
listApiTokens,
|
|
19
21
|
listArtifactsPage,
|
|
20
22
|
listAuditEvents,
|
|
@@ -40,6 +42,7 @@ import {
|
|
|
40
42
|
renderArtifactPage,
|
|
41
43
|
renderAccountPage,
|
|
42
44
|
renderAdminUsersPage,
|
|
45
|
+
renderPasswordPage,
|
|
43
46
|
renderReactFramePage,
|
|
44
47
|
renderDashboard,
|
|
45
48
|
renderDiffPage,
|
|
@@ -184,6 +187,9 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
184
187
|
|
|
185
188
|
const userCount = await countUsers(store);
|
|
186
189
|
const currentUser = userCount > 0 ? await sessionUserFromRequest(store, request) : null;
|
|
190
|
+
if (currentUser?.passwordResetRequired && !["/account/password", "/logout"].includes(pathname)) {
|
|
191
|
+
return sendRedirect(response, "/account/password?required=1");
|
|
192
|
+
}
|
|
187
193
|
|
|
188
194
|
if (method === "GET" && pathname === "/login") {
|
|
189
195
|
return sendHtml(response, renderLoginPage({
|
|
@@ -216,7 +222,7 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
216
222
|
}), 401);
|
|
217
223
|
}
|
|
218
224
|
const session = await createSession(store, user.id);
|
|
219
|
-
return sendRedirect(response, "/account", {
|
|
225
|
+
return sendRedirect(response, user.passwordResetRequired ? "/account/password?required=1" : "/account", {
|
|
220
226
|
"set-cookie": sessionCookie(session.token)
|
|
221
227
|
});
|
|
222
228
|
}
|
|
@@ -228,6 +234,61 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
228
234
|
});
|
|
229
235
|
}
|
|
230
236
|
|
|
237
|
+
if (method === "GET" && pathname === "/account/password") {
|
|
238
|
+
if (!currentUser) {
|
|
239
|
+
return sendRedirect(response, "/login");
|
|
240
|
+
}
|
|
241
|
+
return sendHtml(response, renderPasswordPage({
|
|
242
|
+
baseUrl,
|
|
243
|
+
user: currentUser,
|
|
244
|
+
required: currentUser.passwordResetRequired || url.searchParams.get("required") === "1",
|
|
245
|
+
locale,
|
|
246
|
+
currentPath
|
|
247
|
+
}), 200, headOnly);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (method === "POST" && pathname === "/account/password") {
|
|
251
|
+
if (!currentUser) {
|
|
252
|
+
return sendRedirect(response, "/login");
|
|
253
|
+
}
|
|
254
|
+
const body = await readFormBody(request);
|
|
255
|
+
if (body.newPassword !== body.confirmPassword) {
|
|
256
|
+
return sendHtml(response, renderPasswordPage({
|
|
257
|
+
baseUrl,
|
|
258
|
+
user: currentUser,
|
|
259
|
+
required: currentUser.passwordResetRequired,
|
|
260
|
+
error: "New password and confirmation do not match.",
|
|
261
|
+
locale,
|
|
262
|
+
currentPath
|
|
263
|
+
}), 400);
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
const updatedUser = await changeUserPassword(store, currentUser.id, {
|
|
267
|
+
currentPassword: body.currentPassword,
|
|
268
|
+
newPassword: body.newPassword
|
|
269
|
+
});
|
|
270
|
+
return sendHtml(response, renderPasswordPage({
|
|
271
|
+
baseUrl,
|
|
272
|
+
user: updatedUser,
|
|
273
|
+
success: "Password changed.",
|
|
274
|
+
locale,
|
|
275
|
+
currentPath
|
|
276
|
+
}));
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if ((error.statusCode || 500) >= 500) {
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
return sendHtml(response, renderPasswordPage({
|
|
282
|
+
baseUrl,
|
|
283
|
+
user: currentUser,
|
|
284
|
+
required: currentUser.passwordResetRequired,
|
|
285
|
+
error: error.message,
|
|
286
|
+
locale,
|
|
287
|
+
currentPath
|
|
288
|
+
}), error.statusCode || 400);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
231
292
|
if (method === "GET" && pathname === "/account") {
|
|
232
293
|
if (!currentUser) {
|
|
233
294
|
return sendRedirect(response, "/login");
|
|
@@ -294,11 +355,45 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
294
355
|
email: body.email,
|
|
295
356
|
name: body.name || body.email,
|
|
296
357
|
role: body.role || "user",
|
|
297
|
-
password: body.password
|
|
358
|
+
password: body.password,
|
|
359
|
+
passwordResetRequired: body.passwordResetRequired === "on"
|
|
298
360
|
});
|
|
299
361
|
return sendRedirect(response, "/admin/users");
|
|
300
362
|
}
|
|
301
363
|
|
|
364
|
+
if (method === "POST" && pathname === "/admin/users/import") {
|
|
365
|
+
if (!currentUser) {
|
|
366
|
+
return sendRedirect(response, "/login");
|
|
367
|
+
}
|
|
368
|
+
requireAdmin(currentUser);
|
|
369
|
+
const body = await readFormBody(request);
|
|
370
|
+
try {
|
|
371
|
+
const importResult = await importUsersFromCsv(store, body.csv || "", {
|
|
372
|
+
passwordResetRequired: true
|
|
373
|
+
});
|
|
374
|
+
return sendHtml(response, renderAdminUsersPage({
|
|
375
|
+
baseUrl,
|
|
376
|
+
user: currentUser,
|
|
377
|
+
users: await listUsers(store),
|
|
378
|
+
importResult,
|
|
379
|
+
locale,
|
|
380
|
+
currentPath: "/admin/users"
|
|
381
|
+
}));
|
|
382
|
+
} catch (error) {
|
|
383
|
+
if ((error.statusCode || 500) >= 500) {
|
|
384
|
+
throw error;
|
|
385
|
+
}
|
|
386
|
+
return sendHtml(response, renderAdminUsersPage({
|
|
387
|
+
baseUrl,
|
|
388
|
+
user: currentUser,
|
|
389
|
+
users: await listUsers(store),
|
|
390
|
+
importError: error.message,
|
|
391
|
+
locale,
|
|
392
|
+
currentPath: "/admin/users"
|
|
393
|
+
}), error.statusCode || 400);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
302
397
|
const userActiveMatch = /^\/admin\/users\/([^/]+)\/(enable|disable)$/.exec(pathname);
|
|
303
398
|
if (userActiveMatch && method === "POST") {
|
|
304
399
|
if (!currentUser) {
|