artifacty 0.8.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/src/lib/render.js CHANGED
@@ -2,7 +2,7 @@ import { EDITOR_CLIENT_PATH, VIEWER_CLIENT_PATH, editorImportMapJson } from "./e
2
2
  import { createI18n, DEFAULT_LOCALE, editorMessages, localizedHref, switchLocaleHref } from "./i18n.js";
3
3
  import { ARTIFACT_FORMATS, ARTIFACT_TYPES } from "./storage.js";
4
4
 
5
- export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination, locale = DEFAULT_LOCALE, currentPath = "/" }) {
5
+ export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination, locale = DEFAULT_LOCALE, currentPath = "/", user = null }) {
6
6
  const view = viewContext(locale, currentPath);
7
7
  const total = pagination?.total ?? artifacts.length;
8
8
  const start = artifacts.length ? (pagination?.offset ?? 0) + 1 : 0;
@@ -48,6 +48,7 @@ export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination,
48
48
  <a href="${view.href("/new")}">${view.text("nav.new")}</a>
49
49
  <a href="${view.href("/import")}">${view.text("nav.import")}</a>
50
50
  <a href="/api/artifacts">${view.text("nav.api")}</a>
51
+ ${authNav(user)}
51
52
  ${languageSwitcher(view)}
52
53
  </nav>
53
54
  </header>
@@ -249,6 +250,263 @@ export function renderImportArtifactPage({ baseUrl, authToken = "", locale = DEF
249
250
  });
250
251
  }
251
252
 
253
+ export function renderLoginPage({ baseUrl, setup = false, error = "", locale = DEFAULT_LOCALE, currentPath = "/login" }) {
254
+ const view = viewContext(locale, currentPath);
255
+ const title = setup ? "Create admin account" : "Sign in";
256
+ return pageShell({
257
+ title,
258
+ body: `
259
+ <header class="topbar">
260
+ <div>
261
+ <h1>${escapeHtml(title)}</h1>
262
+ <p>${escapeHtml(baseUrl)}</p>
263
+ </div>
264
+ <nav>
265
+ <a href="${view.href("/")}">${view.text("nav.index")}</a>
266
+ ${languageSwitcher(view)}
267
+ </nav>
268
+ </header>
269
+ <main class="artifact-editor auth-panel">
270
+ ${error ? `<p class="auth-error">${escapeHtml(error)}</p>` : ""}
271
+ ${setup ? `<p class="muted">No users exist yet. The first account becomes an administrator.</p>` : ""}
272
+ <form class="editor-form auth-form" method="post" action="/login">
273
+ <section class="editor-fields">
274
+ <label class="field">
275
+ <span>Email</span>
276
+ <input type="email" name="email" autocomplete="username" required>
277
+ </label>
278
+ ${setup ? `<label class="field">
279
+ <span>Name</span>
280
+ <input name="name" autocomplete="name">
281
+ </label>` : ""}
282
+ <label class="field">
283
+ <span>Password</span>
284
+ <input type="password" name="password" autocomplete="${setup ? "new-password" : "current-password"}" minlength="8" required>
285
+ </label>
286
+ </section>
287
+ <footer class="editor-actions">
288
+ <button type="submit">${setup ? "Create admin" : "Sign in"}</button>
289
+ </footer>
290
+ </form>
291
+ </main>
292
+ `,
293
+ locale: view.locale
294
+ });
295
+ }
296
+
297
+ export function renderAccountPage({ baseUrl, user, tokens = [], createdToken = "", locale = DEFAULT_LOCALE, currentPath = "/account" }) {
298
+ const view = viewContext(locale, currentPath);
299
+ const rows = tokens.map((token) => `
300
+ <tr>
301
+ <td>${escapeHtml(token.name)}</td>
302
+ <td>${escapeHtml(token.createdAt)}</td>
303
+ <td>${token.lastUsedAt ? escapeHtml(token.lastUsedAt) : "Never"}</td>
304
+ <td>${token.revokedAt ? escapeHtml(token.revokedAt) : "Active"}</td>
305
+ <td>
306
+ ${token.revokedAt ? "" : `<form method="post" action="/account/tokens/${encodeURIComponent(token.id)}/revoke">
307
+ <button type="submit">Revoke</button>
308
+ </form>`}
309
+ </td>
310
+ </tr>
311
+ `).join("");
312
+
313
+ return pageShell({
314
+ title: "Account",
315
+ body: `
316
+ <header class="topbar">
317
+ <div>
318
+ <h1>Account</h1>
319
+ <p>${escapeHtml(user.email)} · ${escapeHtml(user.role)} · ${escapeHtml(baseUrl)}</p>
320
+ </div>
321
+ <nav>
322
+ <a href="${view.href("/")}">${view.text("nav.index")}</a>
323
+ ${user.role === "admin" ? `<a href="/admin/users">Users</a>` : ""}
324
+ <a href="/account/password">Password</a>
325
+ <form class="nav-form" method="post" action="/logout"><button type="submit">Sign out</button></form>
326
+ ${languageSwitcher(view)}
327
+ </nav>
328
+ </header>
329
+ <main class="artifact-view">
330
+ ${createdToken ? `<section class="token-once">
331
+ <h2>New API token</h2>
332
+ <p>Copy this token now. Artifacty stores only its hash and cannot show it again.</p>
333
+ <pre class="artifact-code"><code>${escapeHtml(createdToken)}</code></pre>
334
+ </section>` : ""}
335
+ <section class="meta-card">
336
+ <h2>Profile</h2>
337
+ <p><strong>${escapeHtml(user.name)}</strong></p>
338
+ <p>${escapeHtml(user.email)}</p>
339
+ <p>Role: ${escapeHtml(user.role)}</p>
340
+ </section>
341
+ <section class="meta-card">
342
+ <h2>Create API token</h2>
343
+ <form class="inline-action" method="post" action="/account/tokens">
344
+ <input name="name" placeholder="Token name" autocomplete="off" required>
345
+ <button type="submit">Create token</button>
346
+ </form>
347
+ </section>
348
+ <section class="meta-card">
349
+ <h2>API tokens</h2>
350
+ <table class="data-table">
351
+ <thead><tr><th>Name</th><th>Created</th><th>Last used</th><th>Status</th><th></th></tr></thead>
352
+ <tbody>${rows || `<tr><td colspan="5">No API tokens.</td></tr>`}</tbody>
353
+ </table>
354
+ </section>
355
+ </main>
356
+ `,
357
+ locale: view.locale
358
+ });
359
+ }
360
+
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" }) {
408
+ const view = viewContext(locale, currentPath);
409
+ const rows = users.map((item) => `
410
+ <tr>
411
+ <td>${escapeHtml(item.email)}</td>
412
+ <td>${escapeHtml(item.name)}</td>
413
+ <td>${escapeHtml(item.role)}</td>
414
+ <td>${item.active ? "Active" : "Disabled"}${item.passwordResetRequired ? " · Reset required" : ""}</td>
415
+ <td>${escapeHtml(item.createdAt)}</td>
416
+ <td>
417
+ ${item.id === user.id ? "" : `<form method="post" action="/admin/users/${encodeURIComponent(item.id)}/${item.active ? "disable" : "enable"}">
418
+ <button type="submit">${item.active ? "Disable" : "Enable"}</button>
419
+ </form>`}
420
+ </td>
421
+ </tr>
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
+ ` : "";
455
+
456
+ return pageShell({
457
+ title: "Users",
458
+ body: `
459
+ <header class="topbar">
460
+ <div>
461
+ <h1>Users</h1>
462
+ <p>${escapeHtml(baseUrl)}</p>
463
+ </div>
464
+ <nav>
465
+ <a href="${view.href("/")}">${view.text("nav.index")}</a>
466
+ <a href="/account">Account</a>
467
+ ${languageSwitcher(view)}
468
+ </nav>
469
+ </header>
470
+ <main class="artifact-view">
471
+ <section class="meta-card">
472
+ <h2>Create user</h2>
473
+ <form class="editor-form auth-form" method="post" action="/admin/users">
474
+ <section class="editor-fields">
475
+ <label class="field"><span>Email</span><input type="email" name="email" required></label>
476
+ <label class="field"><span>Name</span><input name="name"></label>
477
+ <label class="field"><span>Role</span><select name="role"><option value="user">user</option><option value="admin">admin</option></select></label>
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>
480
+ </section>
481
+ <footer class="editor-actions"><button type="submit">Create user</button></footer>
482
+ </form>
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}
497
+ <section class="meta-card">
498
+ <h2>Existing users</h2>
499
+ <table class="data-table">
500
+ <thead><tr><th>Email</th><th>Name</th><th>Role</th><th>Status</th><th>Created</th><th></th></tr></thead>
501
+ <tbody>${rows}</tbody>
502
+ </table>
503
+ </section>
504
+ </main>
505
+ `,
506
+ locale: view.locale
507
+ });
508
+ }
509
+
252
510
  export function renderArtifactPage({ artifact, version, content, baseUrl, authToken = "", locale = DEFAULT_LOCALE, currentPath = "/" }) {
253
511
  const view = viewContext(locale, currentPath);
254
512
  const versionLinks = artifact.versions
@@ -726,6 +984,25 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
726
984
  .inline-action {
727
985
  margin-bottom: 12px;
728
986
  }
987
+ .nav-form {
988
+ display: inline-flex;
989
+ margin: 0;
990
+ }
991
+ .nav-form button {
992
+ min-height: 0;
993
+ padding: 0;
994
+ border: 0;
995
+ background: transparent;
996
+ color: inherit;
997
+ font: inherit;
998
+ font-weight: inherit;
999
+ }
1000
+ .nav-form button:hover {
1001
+ background: transparent;
1002
+ border: 0;
1003
+ color: var(--text);
1004
+ text-decoration: underline;
1005
+ }
729
1006
  .diff-form {
730
1007
  grid-template-columns: 160px 160px auto;
731
1008
  width: fit-content;
@@ -788,6 +1065,76 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
788
1065
  display: grid;
789
1066
  gap: 16px;
790
1067
  }
1068
+ .auth-panel {
1069
+ max-width: 720px;
1070
+ margin: 0 auto;
1071
+ }
1072
+ .auth-form .editor-fields {
1073
+ grid-template-columns: minmax(220px, 1fr);
1074
+ }
1075
+ .auth-error,
1076
+ .auth-success,
1077
+ .auth-warning,
1078
+ .token-once {
1079
+ border: 1px solid var(--line);
1080
+ border-radius: 8px;
1081
+ padding: 12px 14px;
1082
+ background: var(--panel);
1083
+ }
1084
+ .auth-error {
1085
+ color: #991b1b;
1086
+ background: #fef2f2;
1087
+ border-color: #fecaca;
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
+ }
1099
+ .muted {
1100
+ color: var(--muted);
1101
+ }
1102
+ .meta-card {
1103
+ display: grid;
1104
+ gap: 10px;
1105
+ margin-bottom: 16px;
1106
+ border: 1px solid var(--line);
1107
+ border-radius: 8px;
1108
+ padding: 16px;
1109
+ background: var(--panel);
1110
+ }
1111
+ .meta-card h2,
1112
+ .token-once h2 {
1113
+ margin: 0;
1114
+ font-size: 18px;
1115
+ }
1116
+ .data-table {
1117
+ width: 100%;
1118
+ border-collapse: collapse;
1119
+ font-size: 13px;
1120
+ }
1121
+ .data-table th,
1122
+ .data-table td {
1123
+ padding: 9px 10px;
1124
+ border-bottom: 1px solid var(--line);
1125
+ text-align: left;
1126
+ vertical-align: middle;
1127
+ }
1128
+ .data-table th {
1129
+ color: var(--faint);
1130
+ font-family: var(--mono);
1131
+ font-size: 11.5px;
1132
+ letter-spacing: 0.06em;
1133
+ text-transform: uppercase;
1134
+ }
1135
+ .data-table form {
1136
+ margin: 0;
1137
+ }
791
1138
  .editor-fields {
792
1139
  display: grid;
793
1140
  grid-template-columns: minmax(220px, 1fr) 180px 180px minmax(160px, 240px);
@@ -807,6 +1154,19 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
807
1154
  text-transform: uppercase;
808
1155
  color: var(--faint);
809
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
+ }
810
1170
  input,
811
1171
  select,
812
1172
  textarea {
@@ -835,6 +1195,9 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
835
1195
  white-space: pre;
836
1196
  overflow: auto;
837
1197
  }
1198
+ .compact-textarea {
1199
+ min-height: 160px;
1200
+ }
838
1201
  .textarea-enhanced {
839
1202
  display: none;
840
1203
  }
@@ -1573,6 +1936,13 @@ function languageSwitcher(view) {
1573
1936
  return `<span class="language-switcher">${english}${korean}</span>`;
1574
1937
  }
1575
1938
 
1939
+ function authNav(user) {
1940
+ if (!user) {
1941
+ return `<a href="/login">Sign in</a>`;
1942
+ }
1943
+ return `${user.role === "admin" ? `<a href="/admin/users">Users</a>` : ""}<a href="/account">Account</a>`;
1944
+ }
1945
+
1576
1946
  export function escapeHtml(value) {
1577
1947
  return String(value)
1578
1948
  .replaceAll("&", "&amp;")
@@ -57,6 +57,10 @@ export function requireToken({ request, url, body = {}, config = securityConfig(
57
57
  }
58
58
  }
59
59
 
60
+ export function requestToken({ request, url, body = {} }) {
61
+ return extractToken({ request, url, body });
62
+ }
63
+
60
64
  export function scanForSecrets(content) {
61
65
  const text = String(content ?? "");
62
66
  const findings = [];
@@ -230,6 +230,7 @@ function serviceConfig(options = {}) {
230
230
  apiToken: options.apiToken || "",
231
231
  shareMode: options.shareMode || "",
232
232
  allowSecrets: Boolean(options.allowSecrets),
233
+ mcpHttp: Boolean(options.mcpHttp),
233
234
  taskName: options.taskName || DEFAULT_TASK_NAME
234
235
  };
235
236
  }
@@ -251,6 +252,9 @@ function serverArgs(config, options = {}) {
251
252
  if (config.allowSecrets) {
252
253
  args.push("--allow-secrets");
253
254
  }
255
+ if (config.mcpHttp) {
256
+ args.push("--mcp-http");
257
+ }
254
258
  if (options.includeApiTokenArg && config.apiToken) {
255
259
  args.push("--api-token", config.apiToken);
256
260
  }