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 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 revoke personal API tokens from
125
- `/account`. Use those personal tokens for `artifacty install ... --api-token`
126
- so MCP and API audit logs record the user's email as `actor`.
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, and personal token issue/revoke flows.
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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "artifacty",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Local artifact exchange for heterogeneous LLM agents via HTTP and MCP.",
5
5
  "type": "module",
6
6
  "keywords": [
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 renderAdminUsersPage({ baseUrl, user, users = [], locale = DEFAULT_LOCALE, currentPath = "/admin/users" }) {
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&#10;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
  }
@@ -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.prepare(`
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) {