artifacty 0.15.0 → 0.16.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.ko.md +50 -8
- package/README.md +51 -9
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/docs/README.md +6 -0
- package/docs/ko/README.md +6 -0
- package/docs/ko/language-packs.md +40 -0
- package/docs/ko/smtp.md +27 -0
- package/docs/language-packs.md +38 -0
- package/docs/roadmap-design.md +31 -2
- package/docs/smtp.md +25 -0
- package/package.json +10 -1
- package/scripts/build-ui.mjs +16 -0
- package/src/cli.js +4 -4
- package/src/client/api-docs.js +32 -0
- package/src/client/auto-apply.js +20 -0
- package/src/client/sidebar.js +31 -0
- package/src/client/theme.js +42 -0
- package/src/client/ui-controls.css +2 -0
- package/src/client/viewer.js +17 -6
- package/src/lib/events.js +4 -4
- package/src/lib/i18n.js +497 -11
- package/src/lib/language-settings.js +114 -0
- package/src/lib/openapi.js +93 -74
- package/src/lib/render.js +551 -397
- package/src/lib/smtp-client.js +206 -0
- package/src/lib/smtp-settings.js +109 -0
- package/src/lib/storage.js +71 -17
- package/src/lib/ui-icons.js +32 -0
- package/src/mcp-server.js +3 -3
- package/src/server.js +213 -16
- package/src/styles/ui-controls.scss +63 -0
package/src/server.js
CHANGED
|
@@ -70,6 +70,8 @@ import {
|
|
|
70
70
|
setArtifactVisibility,
|
|
71
71
|
setReviewStatus,
|
|
72
72
|
setUserActive,
|
|
73
|
+
setUserRole,
|
|
74
|
+
resetUserPassword,
|
|
73
75
|
updateArtifact,
|
|
74
76
|
VersionConflictError,
|
|
75
77
|
verifyUserPassword
|
|
@@ -78,13 +80,16 @@ import { assertNetworkAccess, getNetworkPolicy, stageNetworkPolicy, getPendingNe
|
|
|
78
80
|
import { getShareLinkPolicy, setShareLinkPolicy, createShareLink, listShareLinks, revokeShareLink, resolveShareLink } from "./lib/share-links.js";
|
|
79
81
|
import { installSamples, removeSamples } from "./lib/samples.js";
|
|
80
82
|
import { createWebUpdater } from "./lib/web-update.js";
|
|
83
|
+
import { getLanguageSettings, saveLanguageSettings, importLanguagePack } from "./lib/language-settings.js";
|
|
84
|
+
import { getSmtpSettings, getSmtpDeliveryConfig, saveSmtpSettings } from "./lib/smtp-settings.js";
|
|
85
|
+
import { sendSmtpMail } from "./lib/smtp-client.js";
|
|
81
86
|
import { MAX_BACKUP_BYTES, buildStoreBackup, exportStoreToString, importStoreBundle, importStoreFromString } from "./lib/backup.js";
|
|
82
87
|
import { convertAgentArtifact, decodeMediaContent, mediaContentType } from "./lib/converters.js";
|
|
83
88
|
import { createLineDiff, createStructuredDiff, diffFormatFor, resolveDiffView } from "./lib/diff.js";
|
|
84
89
|
import { EDITOR_CLIENT_PATH, VIEWER_CLIENT_PATH, editorClientFilePath, editorVendorPath, viewerClientFilePath } from "./lib/editor-assets.js";
|
|
85
90
|
import { createEmbeddingProvider } from "./lib/embeddings.js";
|
|
86
91
|
import { EVENT_TYPES, publish, sanitizeEventForDelivery, subscribe } from "./lib/events.js";
|
|
87
|
-
import { localeFromBodyOrUrl, localeFromUrl, localizedHref } from "./lib/i18n.js";
|
|
92
|
+
import { createI18n, localeFromBodyOrUrl, localeFromUrl, localizedHref, enabledLocales, defaultLocale, runWithLanguageSettings, TRANSLATIONS } from "./lib/i18n.js";
|
|
88
93
|
import { paginationJson } from "./lib/listing.js";
|
|
89
94
|
import { buildOpenApiDocument, renderApiDocsHtml } from "./lib/openapi.js";
|
|
90
95
|
import { getRetentionPolicy, runRetention, setRetentionPolicy, startRetentionSweep } from "./lib/retention.js";
|
|
@@ -112,7 +117,10 @@ import {
|
|
|
112
117
|
renderAccountPage,
|
|
113
118
|
renderAdminBackupPage,
|
|
114
119
|
renderAdminAccessPage,
|
|
120
|
+
renderAccessModePreviewPage,
|
|
115
121
|
renderAdminUpdatePage,
|
|
122
|
+
renderAdminLanguagesPage,
|
|
123
|
+
renderAdminEmailPage,
|
|
116
124
|
renderArtifactSharingPage,
|
|
117
125
|
renderSharedArtifactPage,
|
|
118
126
|
renderInboxPage,
|
|
@@ -205,7 +213,7 @@ export async function startServer(options = {}) {
|
|
|
205
213
|
allowPortFallback,
|
|
206
214
|
createServer(port) {
|
|
207
215
|
const candidateServer = http.createServer((request, response) => {
|
|
208
|
-
handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security, mcpHttp, webUpdater }).catch((error) => {
|
|
216
|
+
handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security, mcpHttp, webUpdater, smtpSender: options.smtpSender }).catch((error) => {
|
|
209
217
|
sendError(response, error);
|
|
210
218
|
});
|
|
211
219
|
});
|
|
@@ -310,20 +318,66 @@ function closeServer(server) {
|
|
|
310
318
|
});
|
|
311
319
|
}
|
|
312
320
|
|
|
313
|
-
|
|
321
|
+
function isBrowserPath(pathname) {
|
|
322
|
+
return !/^\/(?:api(?:\/|$)|mcp(?:\/|$)|assets\/|vendor\/|health$|events$|openapi\.json$)/.test(pathname)
|
|
323
|
+
&& !/\/(?:raw|react-frame|export)$/.test(pathname);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export async function handleRequest(args) {
|
|
327
|
+
const { request, store } = args;
|
|
314
328
|
await assertNetworkAccess(store, request.socket.remoteAddress);
|
|
329
|
+
const pathname = decodeURIComponent(new URL(request.url, "http://artifacty.local").pathname);
|
|
330
|
+
if (!isBrowserPath(pathname)) return handleScopedRequest(args);
|
|
331
|
+
const settings = await getLanguageSettings(store);
|
|
332
|
+
return runWithLanguageSettings(settings, () => handleScopedRequest(args));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function handleScopedRequest({ request, response, store, host, port, security = securityConfig(), mcpHttp = false, webUpdater, smtpSender = sendSmtpMail }) {
|
|
315
336
|
const url = new URL(request.url, `http://${request.headers.host || `${host}:${port}`}`);
|
|
316
337
|
const pathname = decodeURIComponent(url.pathname);
|
|
317
338
|
const baseUrl = `http://${host}:${port}`;
|
|
318
339
|
const authToken = url.searchParams.get("token") || "";
|
|
319
|
-
const locale = localeFromUrl(url);
|
|
320
340
|
const currentPath = `${url.pathname}${url.search}`;
|
|
341
|
+
const browserPage = isBrowserPath(pathname);
|
|
342
|
+
let locale = localeFromUrl(url);
|
|
343
|
+
if (browserPage) {
|
|
344
|
+
const explicit = url.searchParams.get("lang");
|
|
345
|
+
const cookie = parseCookies(request.headers.cookie).artifacty_locale;
|
|
346
|
+
const enabled = enabledLocales();
|
|
347
|
+
locale = enabled.includes(explicit) ? explicit
|
|
348
|
+
: enabled.includes(cookie) ? cookie : defaultLocale();
|
|
349
|
+
// Only this request-local URL changes. Keep the original path for links,
|
|
350
|
+
// and leave API and artifact bytes independent of browser preferences.
|
|
351
|
+
url.searchParams.set("lang", locale);
|
|
352
|
+
response.setHeader("cache-control", "no-store");
|
|
353
|
+
response.setHeader("vary", "Cookie");
|
|
354
|
+
if (enabled.includes(explicit)) {
|
|
355
|
+
response.setHeader("set-cookie", `artifacty_locale=${explicit}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${request.socket.encrypted ? "; Secure" : ""}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
321
358
|
const headOnly = request.method === "HEAD";
|
|
322
359
|
const method = headOnly ? "GET" : request.method;
|
|
323
360
|
|
|
324
361
|
if (method === "GET" && pathname === "/assets/update.js") {
|
|
325
362
|
return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "update.js"), headOnly, request);
|
|
326
363
|
}
|
|
364
|
+
if (method === "GET" && pathname === "/assets/theme.js") {
|
|
365
|
+
return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "theme.js"), headOnly, request);
|
|
366
|
+
}
|
|
367
|
+
if (method === "GET" && pathname === "/assets/auto-apply.js") {
|
|
368
|
+
return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "auto-apply.js"), headOnly, request);
|
|
369
|
+
}
|
|
370
|
+
if (method === "GET" && pathname === "/assets/sidebar.js") {
|
|
371
|
+
return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "sidebar.js"), headOnly, request);
|
|
372
|
+
}
|
|
373
|
+
if (method === "GET" && pathname === "/assets/ui-controls.css") {
|
|
374
|
+
const content = await readFile(path.join(PACKAGE_ROOT, "src", "client", "ui-controls.css"), "utf8");
|
|
375
|
+
response.writeHead(200, { "content-type": "text/css; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
376
|
+
return response.end(headOnly ? undefined : content);
|
|
377
|
+
}
|
|
378
|
+
if (method === "GET" && pathname === "/assets/api-docs.js") {
|
|
379
|
+
return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "api-docs.js"), headOnly, request);
|
|
380
|
+
}
|
|
327
381
|
|
|
328
382
|
const publicShareMatch = /^\/s\/([^/]+)(\/raw)?$/.exec(pathname);
|
|
329
383
|
if (publicShareMatch && method === "GET") {
|
|
@@ -382,7 +436,7 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
382
436
|
}
|
|
383
437
|
|
|
384
438
|
if (method === "GET" && pathname === "/docs/api") {
|
|
385
|
-
return sendHtml(response, renderApiDocsHtml({ baseUrl }), 200, headOnly);
|
|
439
|
+
return sendHtml(response, renderApiDocsHtml({ baseUrl, locale, currentPath: `${url.pathname}${url.search}${url.hash}` }), 200, headOnly);
|
|
386
440
|
}
|
|
387
441
|
|
|
388
442
|
const userCount = await countUsers(store);
|
|
@@ -403,6 +457,7 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
403
457
|
if (method === "POST" && pathname === "/login") {
|
|
404
458
|
await enforceRateLimit({ store, host, request, bucket: "auth" });
|
|
405
459
|
const body = await readFormBody(request);
|
|
460
|
+
const bodyLocale = localeFromBodyOrUrl(body, url);
|
|
406
461
|
const email = body.email;
|
|
407
462
|
const password = body.password;
|
|
408
463
|
const user = userCount === 0
|
|
@@ -417,13 +472,13 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
417
472
|
return sendHtml(response, renderLoginPage({
|
|
418
473
|
baseUrl,
|
|
419
474
|
setup: false,
|
|
420
|
-
error: "
|
|
421
|
-
locale,
|
|
475
|
+
error: createI18n(bodyLocale).t("auth.invalidCredentials"),
|
|
476
|
+
locale: bodyLocale,
|
|
422
477
|
currentPath
|
|
423
478
|
}), 401);
|
|
424
479
|
}
|
|
425
480
|
const session = await createSession(store, user.id);
|
|
426
|
-
return sendRedirect(response, user.passwordResetRequired ? "/account/password?required=1" : "/account", {
|
|
481
|
+
return sendRedirect(response, localizedHref(user.passwordResetRequired ? "/account/password?required=1" : "/account", bodyLocale), {
|
|
427
482
|
"set-cookie": sessionCookie(session.token)
|
|
428
483
|
});
|
|
429
484
|
}
|
|
@@ -453,13 +508,14 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
453
508
|
return sendRedirect(response, "/login");
|
|
454
509
|
}
|
|
455
510
|
const body = await readFormBody(request);
|
|
511
|
+
const bodyLocale = localeFromBodyOrUrl(body, url);
|
|
456
512
|
if (body.newPassword !== body.confirmPassword) {
|
|
457
513
|
return sendHtml(response, renderPasswordPage({
|
|
458
514
|
baseUrl,
|
|
459
515
|
user: currentUser,
|
|
460
516
|
required: currentUser.passwordResetRequired,
|
|
461
|
-
error: "
|
|
462
|
-
locale,
|
|
517
|
+
error: createI18n(bodyLocale).t("password.mismatch"),
|
|
518
|
+
locale: bodyLocale,
|
|
463
519
|
currentPath
|
|
464
520
|
}), 400);
|
|
465
521
|
}
|
|
@@ -471,8 +527,8 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
471
527
|
return sendHtml(response, renderPasswordPage({
|
|
472
528
|
baseUrl,
|
|
473
529
|
user: updatedUser,
|
|
474
|
-
success: "
|
|
475
|
-
locale,
|
|
530
|
+
success: createI18n(bodyLocale).t("password.changed"),
|
|
531
|
+
locale: bodyLocale,
|
|
476
532
|
currentPath
|
|
477
533
|
}));
|
|
478
534
|
} catch (error) {
|
|
@@ -553,6 +609,68 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
553
609
|
}
|
|
554
610
|
|
|
555
611
|
const browserAccess = accessContext(request, currentUser);
|
|
612
|
+
if (pathname === "/admin/email") {
|
|
613
|
+
if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
|
|
614
|
+
requireAdmin(currentUser);
|
|
615
|
+
const emailPage = async (extra = {}) => renderAdminEmailPage({
|
|
616
|
+
baseUrl, user: currentUser, locale, currentPath: "/admin/email", settings: await getSmtpSettings(store), ...extra
|
|
617
|
+
});
|
|
618
|
+
if (method === "GET") return sendHtml(response, await emailPage(), 200, headOnly);
|
|
619
|
+
if (method === "POST") {
|
|
620
|
+
assertSameOrigin(request);
|
|
621
|
+
await enforceRateLimit({ store, host, request, bucket: "write" });
|
|
622
|
+
const body = await readFormBody(request, 16 * 1024);
|
|
623
|
+
try {
|
|
624
|
+
await saveSmtpSettings(store, {
|
|
625
|
+
enabled: body.enabled === "on" || body.enabled === "true", host: body.host || "", port: Number(body.port),
|
|
626
|
+
tlsMode: body.tlsMode, username: body.username || "", from: body.from || "", password: body.password || ""
|
|
627
|
+
}, { access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id } });
|
|
628
|
+
return sendRedirect(response, localizedHref("/admin/email", localeFromBodyOrUrl(body, url)));
|
|
629
|
+
} catch (error) {
|
|
630
|
+
if (error.statusCode !== 400) throw error;
|
|
631
|
+
return sendHtml(response, await emailPage({ error: createI18n(locale).t("smtp.invalidSettings") }), 400);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return sendJson(response, { error: "Method not allowed" }, 405, headOnly);
|
|
635
|
+
}
|
|
636
|
+
if (["/admin/languages", "/admin/languages/import", "/admin/languages/template"].includes(pathname)) {
|
|
637
|
+
if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
|
|
638
|
+
requireAdmin(currentUser);
|
|
639
|
+
if (method === "GET" && pathname === "/admin/languages/template") {
|
|
640
|
+
response.setHeader("content-disposition", 'attachment; filename="artifacty-language-template.json"');
|
|
641
|
+
return sendJson(response, { locale: "en", nativeName: "English", messages: TRANSLATIONS.en }, 200, headOnly);
|
|
642
|
+
}
|
|
643
|
+
const renderLanguages = async (extra = {}) => renderAdminLanguagesPage({
|
|
644
|
+
baseUrl, user: currentUser, locale, currentPath: "/admin/languages",
|
|
645
|
+
settings: await getLanguageSettings(store), ...extra
|
|
646
|
+
});
|
|
647
|
+
if (method === "GET" && pathname === "/admin/languages") return sendHtml(response, await renderLanguages(), 200, headOnly);
|
|
648
|
+
if (method === "POST" && pathname !== "/admin/languages/template") {
|
|
649
|
+
assertSameOrigin(request);
|
|
650
|
+
await enforceRateLimit({ store, host, request, bucket: "write" });
|
|
651
|
+
try {
|
|
652
|
+
// URL encoding can use three bytes for each byte of the JSON text.
|
|
653
|
+
const body = await readFormBody(request, 3 * 512 * 1024 + 1024);
|
|
654
|
+
const options = { access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id } };
|
|
655
|
+
if (pathname.endsWith("/import")) {
|
|
656
|
+
if (Buffer.byteLength(String(body.pack || ""), "utf8") > 512 * 1024) {
|
|
657
|
+
throw Object.assign(new Error(createI18n(locale).t("languageAdmin.tooLarge")), { statusCode: 413 });
|
|
658
|
+
}
|
|
659
|
+
let pack;
|
|
660
|
+
try { pack = JSON.parse(body.pack); }
|
|
661
|
+
catch { throw Object.assign(new Error(createI18n(locale).t("languageAdmin.invalidJson")), { statusCode: 400 }); }
|
|
662
|
+
await importLanguagePack(store, pack, options);
|
|
663
|
+
} else {
|
|
664
|
+
await saveLanguageSettings(store, { defaultLocale: body.defaultLocale, enabledLocales: body.enabledLocales || [] }, options);
|
|
665
|
+
}
|
|
666
|
+
return sendRedirect(response, "/admin/languages");
|
|
667
|
+
} catch (error) {
|
|
668
|
+
if (![400, 413].includes(error.statusCode)) throw error;
|
|
669
|
+
return sendHtml(response, await renderLanguages({ error: error.message }), error.statusCode);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return sendJson(response, { error: "Method not allowed" }, 405, headOnly);
|
|
673
|
+
}
|
|
556
674
|
if (["/admin/update", "/admin/update/status", "/admin/update/check", "/admin/update/start"].includes(pathname)) {
|
|
557
675
|
if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
|
|
558
676
|
requireAdmin(currentUser);
|
|
@@ -591,16 +709,23 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
591
709
|
if (!currentUser) return sendRedirect(response, "/login");
|
|
592
710
|
requireAdmin(currentUser);
|
|
593
711
|
if (method === "GET" && pathname === "/admin/access") {
|
|
594
|
-
|
|
712
|
+
const selectedMode = url.searchParams.get("mode");
|
|
713
|
+
const preview = ["public", "private"].includes(selectedMode)
|
|
714
|
+
? await previewAccessMode(store, selectedMode, { access: browserAccess }) : null;
|
|
715
|
+
return sendHtml(response, await adminAccessPage({ preview }), 200, headOnly);
|
|
595
716
|
}
|
|
596
717
|
if (method === "POST") {
|
|
597
718
|
assertLocalOrigin(request);
|
|
719
|
+
if (pathname.startsWith("/admin/access")) assertSameOrigin(request);
|
|
598
720
|
request.artifactyAuth = await requireBrowserWriteAuth({ store, request, url, config: security, currentUser });
|
|
599
721
|
await enforceRateLimit({ store, host, request, bucket: "write" });
|
|
600
722
|
const body = await readFormBody(request);
|
|
601
723
|
const options = { access: browserAccess, audit: browserAudit, remoteAddress: request.socket.remoteAddress };
|
|
602
724
|
if (pathname === "/admin/access/preview") {
|
|
603
|
-
return sendHtml(response,
|
|
725
|
+
return sendHtml(response, renderAccessModePreviewPage({
|
|
726
|
+
baseUrl, user: currentUser, locale: localeFromBodyOrUrl(body, url), currentPath: "/admin/access/preview",
|
|
727
|
+
...await previewAccessMode(store, body.mode, options)
|
|
728
|
+
}), 200, headOnly);
|
|
604
729
|
} else if (pathname === "/admin/access") {
|
|
605
730
|
await setAccessMode(store, body.mode, options);
|
|
606
731
|
} else if (pathname === "/admin/access/network") {
|
|
@@ -811,6 +936,67 @@ export async function handleRequest({ request, response, store, host, port, secu
|
|
|
811
936
|
}
|
|
812
937
|
}
|
|
813
938
|
|
|
939
|
+
const userPasswordResetMatch = /^\/admin\/users\/([^/]+)\/reset-password$/.exec(pathname);
|
|
940
|
+
if (userPasswordResetMatch && method === "POST") {
|
|
941
|
+
if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
|
|
942
|
+
requireAdmin(currentUser);
|
|
943
|
+
assertSameOrigin(request);
|
|
944
|
+
await enforceRateLimit({ store, host, request, bucket: "write" });
|
|
945
|
+
const body = await readFormBody(request);
|
|
946
|
+
const resetResult = await resetUserPassword(store, userPasswordResetMatch[1], {
|
|
947
|
+
access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id }
|
|
948
|
+
});
|
|
949
|
+
resetResult.mailStatus = "not-configured";
|
|
950
|
+
try {
|
|
951
|
+
const smtp = await getSmtpDeliveryConfig(store);
|
|
952
|
+
if (smtp.enabled) {
|
|
953
|
+
const messages = createI18n(localeFromBodyOrUrl(body, url));
|
|
954
|
+
await smtpSender(smtp, {
|
|
955
|
+
to: resetResult.user.email,
|
|
956
|
+
subject: messages.t("smtp.resetSubject"),
|
|
957
|
+
text: messages.t("smtp.resetBody", { email: resetResult.user.email, password: resetResult.temporaryPassword })
|
|
958
|
+
});
|
|
959
|
+
resetResult.mailStatus = "sent";
|
|
960
|
+
}
|
|
961
|
+
} catch {
|
|
962
|
+
// Reset already succeeded. Keep the one-time password available for
|
|
963
|
+
// manual delivery, including when SMTP acceptance is uncertain.
|
|
964
|
+
resetResult.mailStatus = "failed";
|
|
965
|
+
}
|
|
966
|
+
response.setHeader("cache-control", "no-store");
|
|
967
|
+
return sendHtml(response, renderAdminUsersPage({
|
|
968
|
+
baseUrl, user: currentUser, users: await listUsers(store), resetResult,
|
|
969
|
+
locale: localeFromBodyOrUrl(body, url), currentPath: "/admin/users"
|
|
970
|
+
}));
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const userRoleMatch = /^\/admin\/users\/([^/]+)\/role$/.exec(pathname);
|
|
974
|
+
if (userRoleMatch && method === "POST") {
|
|
975
|
+
if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
|
|
976
|
+
requireAdmin(currentUser);
|
|
977
|
+
assertLocalOrigin(request);
|
|
978
|
+
if (request.headers.origin !== `${request.socket.encrypted ? "https" : "http"}://${request.headers.host}`) {
|
|
979
|
+
throw Object.assign(new Error("An exact same-origin request is required"), { statusCode: 403, code: "ROLE_ORIGIN_REQUIRED" });
|
|
980
|
+
}
|
|
981
|
+
await enforceRateLimit({ store, host, request, bucket: "write" });
|
|
982
|
+
const body = await readFormBody(request);
|
|
983
|
+
const bodyLocale = localeFromBodyOrUrl(body, url);
|
|
984
|
+
try {
|
|
985
|
+
await setUserRole(store, userRoleMatch[1], body.role, {
|
|
986
|
+
access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id }
|
|
987
|
+
});
|
|
988
|
+
const destination = userRoleMatch[1] === currentUser.id && body.role === "user" ? "/account" : "/admin/users";
|
|
989
|
+
return sendRedirect(response, localizedHref(destination, bodyLocale));
|
|
990
|
+
} catch (error) {
|
|
991
|
+
if (![400, 409].includes(error.statusCode)) throw error;
|
|
992
|
+
return sendHtml(response, renderAdminUsersPage({
|
|
993
|
+
baseUrl, user: currentUser, users: await listUsers(store), locale: bodyLocale, currentPath: "/admin/users",
|
|
994
|
+
roleError: error.code === "LAST_ADMIN_REQUIRED" ? createI18n(bodyLocale).t("users.lastAdmin")
|
|
995
|
+
: error.code === "INVALID_USER_ROLE" ? createI18n(bodyLocale).t("users.invalidRole") : error.message
|
|
996
|
+
}), error.statusCode);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
814
1000
|
const userActiveMatch = /^\/admin\/users\/([^/]+)\/(enable|disable)$/.exec(pathname);
|
|
815
1001
|
if (userActiveMatch && method === "POST") {
|
|
816
1002
|
if (!currentUser) {
|
|
@@ -1932,7 +2118,7 @@ export async function readFormBody(request, limitBytes = MAX_ARTIFACT_BYTES + 10
|
|
|
1932
2118
|
const params = new URLSearchParams(raw);
|
|
1933
2119
|
const body = Object.fromEntries(params.entries());
|
|
1934
2120
|
// Sharing checkboxes submit repeated keys; scalar form fields keep their existing shape.
|
|
1935
|
-
for (const key of ["userIds", "teamIds"]) {
|
|
2121
|
+
for (const key of ["userIds", "teamIds", "enabledLocales"]) {
|
|
1936
2122
|
if (params.has(key)) body[key] = params.getAll(key);
|
|
1937
2123
|
}
|
|
1938
2124
|
return body;
|
|
@@ -1954,6 +2140,13 @@ export async function readBody(request, limitBytes) {
|
|
|
1954
2140
|
return Buffer.concat(chunks).toString("utf8");
|
|
1955
2141
|
}
|
|
1956
2142
|
|
|
2143
|
+
function assertSameOrigin(request) {
|
|
2144
|
+
const expected = `${request.socket.encrypted ? "https" : "http"}://${request.headers.host}`;
|
|
2145
|
+
if (request.headers.origin !== expected) {
|
|
2146
|
+
throw Object.assign(new Error("An exact same-origin request is required"), { statusCode: 403, code: "SAME_ORIGIN_REQUIRED" });
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
|
|
1957
2150
|
export function assertLocalOrigin(request) {
|
|
1958
2151
|
const origin = request.headers.origin;
|
|
1959
2152
|
if (!origin) {
|
|
@@ -2200,6 +2393,10 @@ function javascriptCorsHeaders(request) {
|
|
|
2200
2393
|
}
|
|
2201
2394
|
|
|
2202
2395
|
export function sendRedirect(response, location, headers = {}) {
|
|
2396
|
+
const existingCookies = response.getHeader("set-cookie");
|
|
2397
|
+
if (existingCookies && headers["set-cookie"]) {
|
|
2398
|
+
headers = { ...headers, "set-cookie": [...[].concat(existingCookies), ...[].concat(headers["set-cookie"])] };
|
|
2399
|
+
}
|
|
2203
2400
|
response.writeHead(303, {
|
|
2204
2401
|
location,
|
|
2205
2402
|
"cache-control": "no-store",
|
|
@@ -2776,7 +2973,7 @@ function parseCookies(header) {
|
|
|
2776
2973
|
const key = part.slice(0, index).trim();
|
|
2777
2974
|
const value = part.slice(index + 1).trim();
|
|
2778
2975
|
if (key) {
|
|
2779
|
-
cookies[key] = decodeURIComponent(value);
|
|
2976
|
+
try { cookies[key] = decodeURIComponent(value); } catch { /* Ignore malformed cookies. */ }
|
|
2780
2977
|
}
|
|
2781
2978
|
}
|
|
2782
2979
|
return cookies;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Artifacty control layer: official Bootstrap components only; no Reboot or layout reset.
|
|
2
|
+
@import "bootstrap/scss/functions";
|
|
3
|
+
$primary: #315ca8;
|
|
4
|
+
$secondary: #58616e;
|
|
5
|
+
$danger: #b42332;
|
|
6
|
+
$font-family-sans-serif: ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
|
|
7
|
+
$font-size-base: .875rem;
|
|
8
|
+
$border-radius: .375rem;
|
|
9
|
+
$input-btn-font-size: .8125rem;
|
|
10
|
+
$input-btn-padding-y: .4375rem;
|
|
11
|
+
$input-btn-padding-x: .75rem;
|
|
12
|
+
$btn-font-weight: 500;
|
|
13
|
+
$btn-white-space: nowrap;
|
|
14
|
+
$input-btn-focus-width: .1875rem;
|
|
15
|
+
$input-btn-focus-color-opacity: .2;
|
|
16
|
+
$enable-shadows: false;
|
|
17
|
+
@import "bootstrap/scss/variables";
|
|
18
|
+
@import "bootstrap/scss/variables-dark";
|
|
19
|
+
@import "bootstrap/scss/maps";
|
|
20
|
+
@import "bootstrap/scss/mixins";
|
|
21
|
+
@import "bootstrap/scss/root";
|
|
22
|
+
@import "bootstrap/scss/forms";
|
|
23
|
+
@import "bootstrap/scss/buttons";
|
|
24
|
+
@import "bootstrap/scss/button-group";
|
|
25
|
+
|
|
26
|
+
:root, [data-bs-theme="dark"] {
|
|
27
|
+
--bs-body-font-family: #{$font-family-sans-serif};
|
|
28
|
+
--bs-body-color: var(--text, #20242b);
|
|
29
|
+
--bs-body-bg: var(--panel, #fff);
|
|
30
|
+
--bs-secondary-color: var(--muted, #58616e);
|
|
31
|
+
--bs-border-color: var(--line-2, #b9c1cc);
|
|
32
|
+
--bs-secondary-bg: var(--panel-2, #eef0f3);
|
|
33
|
+
}
|
|
34
|
+
.btn { min-height: 36px; display: inline-flex; align-items: center; justify-content: center; gap: .375rem; }
|
|
35
|
+
.btn[hidden] { display: none !important; }
|
|
36
|
+
.form-control, .form-select { min-height: 38px; font-family: var(--bs-body-font-family); }
|
|
37
|
+
.form-control::placeholder { color: var(--muted, #58616e); opacity: .85; }
|
|
38
|
+
.form-control:focus, .form-select:focus { border-color: var(--accent, #315ca8); }
|
|
39
|
+
.btn-outline-secondary, .btn-ghost {
|
|
40
|
+
--bs-btn-color: var(--text, #20242b);
|
|
41
|
+
--bs-btn-border-color: var(--line, #dfe3e8);
|
|
42
|
+
--bs-btn-hover-color: var(--text, #20242b);
|
|
43
|
+
--bs-btn-hover-bg: var(--panel-2, #eef0f3);
|
|
44
|
+
--bs-btn-hover-border-color: var(--line-2, #b9c1cc);
|
|
45
|
+
--bs-btn-active-color: var(--text, #20242b);
|
|
46
|
+
--bs-btn-active-bg: var(--panel-2, #eef0f3);
|
|
47
|
+
--bs-btn-active-border-color: var(--line-2, #b9c1cc);
|
|
48
|
+
--bs-btn-disabled-color: var(--muted, #58616e);
|
|
49
|
+
--bs-btn-disabled-border-color: var(--line, #dfe3e8);
|
|
50
|
+
--bs-btn-focus-shadow-rgb: 49, 92, 168;
|
|
51
|
+
}
|
|
52
|
+
.btn-ghost { --bs-btn-border-color: transparent; }
|
|
53
|
+
[data-bs-theme="dark"] .btn-outline-danger {
|
|
54
|
+
--bs-btn-color: #ff9ba5;
|
|
55
|
+
--bs-btn-border-color: #73404a;
|
|
56
|
+
--bs-btn-hover-bg: #93313e;
|
|
57
|
+
--bs-btn-hover-border-color: #93313e;
|
|
58
|
+
}
|
|
59
|
+
.btn:focus-visible { outline: 2px solid var(--ring, #315ca8); outline-offset: 2px; }
|
|
60
|
+
@media (pointer: coarse) {
|
|
61
|
+
.btn, .form-control, .form-select { min-height: 44px; }
|
|
62
|
+
}
|
|
63
|
+
form[data-auto-apply] [hidden] { display: none !important; }
|