@dyrected/core 2.19.0 → 2.20.1

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/dist/server.cjs CHANGED
@@ -3063,7 +3063,7 @@ function assertValidTrashInConfig(config, source = "config") {
3063
3063
  if (col.slug === "trash") {
3064
3064
  throw new Error(`Collection slug "trash" is reserved by Dyrected for the trash system.`);
3065
3065
  }
3066
- const isSystem = col.slug.startsWith("__");
3066
+ const isSystem = INTERNAL_SYSTEM_COLLECTIONS.has(col.slug);
3067
3067
  if (isSystem && col.trash) {
3068
3068
  throw new Error(`System collection "${col.slug}" cannot enable trash.`);
3069
3069
  }
@@ -3170,12 +3170,20 @@ async function restoreDocument(options) {
3170
3170
  }
3171
3171
  return { restoredDoc: docToRestore };
3172
3172
  }
3173
- var TRASH_COLLECTION, TRASH_COLLECTION_CONFIG;
3173
+ var TRASH_COLLECTION, INTERNAL_SYSTEM_COLLECTIONS, TRASH_COLLECTION_CONFIG;
3174
3174
  var init_trash = __esm({
3175
3175
  "src/trash.ts"() {
3176
3176
  "use strict";
3177
3177
  init_hooks();
3178
3178
  TRASH_COLLECTION = "__trash";
3179
+ INTERNAL_SYSTEM_COLLECTIONS = /* @__PURE__ */ new Set([
3180
+ "__audit",
3181
+ "__sessions",
3182
+ "__task_locks",
3183
+ "__trash",
3184
+ "__workflow_history",
3185
+ "__lifecycle_events"
3186
+ ]);
3179
3187
  TRASH_COLLECTION_CONFIG = {
3180
3188
  slug: TRASH_COLLECTION,
3181
3189
  labels: { singular: "Trash entry", plural: "Trash entries" },
@@ -4299,12 +4307,17 @@ function sanitizeWhereClause(where, fields) {
4299
4307
  const result = {};
4300
4308
  for (const [key, value] of Object.entries(node)) {
4301
4309
  const upperKey = key.toUpperCase();
4302
- if (upperKey === "AND" || upperKey === "OR") {
4310
+ if (upperKey === "AND" || upperKey === "OR" || upperKey === "NOT") {
4303
4311
  if (Array.isArray(value)) {
4304
4312
  const processed = value.map((v) => walk(v)).filter((v) => Object.keys(v).length > 0);
4305
4313
  if (processed.length > 0) {
4306
4314
  result[upperKey] = processed;
4307
4315
  }
4316
+ } else if (typeof value === "object" && value !== null) {
4317
+ const processed = walk(value);
4318
+ if (Object.keys(processed).length > 0) {
4319
+ result[upperKey] = processed;
4320
+ }
4308
4321
  }
4309
4322
  continue;
4310
4323
  }
@@ -5415,8 +5428,10 @@ __export(server_exports, {
5415
5428
  AUTH_SESSIONS_COLLECTION: () => AUTH_SESSIONS_COLLECTION,
5416
5429
  AuthController: () => AuthController,
5417
5430
  CollectionController: () => CollectionController,
5431
+ DEFAULT_INVITE_EXPIRY: () => DEFAULT_INVITE_EXPIRY,
5418
5432
  DEFAULT_LOCK_TIME_MS: () => DEFAULT_LOCK_TIME_MS,
5419
5433
  DEFAULT_MAX_LOGIN_ATTEMPTS: () => DEFAULT_MAX_LOGIN_ATTEMPTS,
5434
+ DEFAULT_RESET_PASSWORD_EXPIRY: () => DEFAULT_RESET_PASSWORD_EXPIRY,
5420
5435
  DEFAULT_SESSION_EXPIRY: () => DEFAULT_SESSION_EXPIRY,
5421
5436
  EmbeddingService: () => EmbeddingService,
5422
5437
  GlobalController: () => GlobalController,
@@ -5448,13 +5463,16 @@ __export(server_exports, {
5448
5463
  getAIModel: () => getAIModel,
5449
5464
  getAuthSession: () => getAuthSession,
5450
5465
  getConfigLogger: () => getConfigLogger,
5466
+ getDbEmailTemplateOverride: () => getDbEmailTemplateOverride,
5451
5467
  getEmbeddingModel: () => getEmbeddingModel,
5452
5468
  getLockedUntilMs: () => getLockedUntilMs,
5453
5469
  getObservabilityRuntime: () => getObservabilityRuntime,
5454
5470
  getRequestLogger: () => getRequestLogger,
5455
5471
  hasEmbeddingApiKey: () => hasEmbeddingApiKey,
5472
+ hasUsablePassword: () => hasUsablePassword,
5456
5473
  hashContent: () => hashContent,
5457
5474
  hashPassword: () => hashPassword,
5475
+ interpolateVariables: () => interpolateVariables,
5458
5476
  isAuthSessionActive: () => isAuthSessionActive,
5459
5477
  issueAuthSessionToken: () => issueAuthSessionToken,
5460
5478
  normalizeFieldValue: () => normalizeFieldValue,
@@ -5463,7 +5481,10 @@ __export(server_exports, {
5463
5481
  registerRoutes: () => registerRoutes,
5464
5482
  renderPrometheusMetrics: () => renderPrometheusMetrics,
5465
5483
  resolveAuthLockoutConfig: () => resolveAuthLockoutConfig,
5484
+ resolveEmailTemplate: () => resolveEmailTemplate,
5485
+ resolveInviteTokenExpiry: () => resolveInviteTokenExpiry,
5466
5486
  resolveObservabilityConfig: () => resolveObservabilityConfig,
5487
+ resolveResetPasswordTokenExpiry: () => resolveResetPasswordTokenExpiry,
5467
5488
  resolveSessionTokenExpiry: () => resolveSessionTokenExpiry,
5468
5489
  revokeAllAuthSessions: () => revokeAllAuthSessions,
5469
5490
  revokeAuthSession: () => revokeAuthSession,
@@ -5473,6 +5494,7 @@ __export(server_exports, {
5473
5494
  serializeViewForApi: () => serializeViewForApi,
5474
5495
  shouldSampleRequest: () => shouldSampleRequest,
5475
5496
  signCollectionToken: () => signCollectionToken,
5497
+ toOutboundEmail: () => toOutboundEmail,
5476
5498
  touchAuthSession: () => touchAuthSession,
5477
5499
  validatePayloadAgainstFields: () => validatePayloadAgainstFields,
5478
5500
  verifyCollectionToken: () => verifyCollectionToken,
@@ -6297,13 +6319,27 @@ async function hashPassword(plain) {
6297
6319
  const derivedKey = await scryptAsync(plain, salt, KEY_LEN);
6298
6320
  return `${salt}:${derivedKey.toString("hex")}`;
6299
6321
  }
6322
+ function hasUsablePassword(stored) {
6323
+ if (typeof stored !== "string" || !stored) return false;
6324
+ const parts = stored.split(":");
6325
+ return parts.length === 2 && Boolean(parts[0]) && Boolean(parts[1]);
6326
+ }
6300
6327
  async function verifyPassword(plain, stored) {
6301
- const [salt, storedHash] = stored.split(":");
6328
+ if (typeof plain !== "string" || !plain || typeof stored !== "string" || !stored) {
6329
+ return false;
6330
+ }
6331
+ const parts = stored.split(":");
6332
+ if (parts.length !== 2) return false;
6333
+ const [salt, storedHash] = parts;
6302
6334
  if (!salt || !storedHash) return false;
6303
- const derivedKey = await scryptAsync(plain, salt, KEY_LEN);
6304
- const storedBuffer = Buffer.from(storedHash, "hex");
6305
- if (derivedKey.length !== storedBuffer.length) return false;
6306
- return (0, import_node_crypto.timingSafeEqual)(derivedKey, storedBuffer);
6335
+ try {
6336
+ const derivedKey = await scryptAsync(plain, salt, KEY_LEN);
6337
+ const storedBuffer = Buffer.from(storedHash, "hex");
6338
+ if (derivedKey.length !== storedBuffer.length) return false;
6339
+ return (0, import_node_crypto.timingSafeEqual)(derivedKey, storedBuffer);
6340
+ } catch {
6341
+ return false;
6342
+ }
6307
6343
  }
6308
6344
 
6309
6345
  // src/auth/token.ts
@@ -6319,6 +6355,8 @@ function getSecret() {
6319
6355
  }
6320
6356
  var DEFAULT_EXPIRY = "7d";
6321
6357
  var DEFAULT_SESSION_EXPIRY = DEFAULT_EXPIRY;
6358
+ var DEFAULT_INVITE_EXPIRY = "48h";
6359
+ var DEFAULT_RESET_PASSWORD_EXPIRY = "2h";
6322
6360
  function resolveSessionTokenExpiry(collection) {
6323
6361
  const auth = collection.auth;
6324
6362
  if (auth && typeof auth === "object" && typeof auth.tokenExpiration === "string" && auth.tokenExpiration.length > 0) {
@@ -6326,6 +6364,20 @@ function resolveSessionTokenExpiry(collection) {
6326
6364
  }
6327
6365
  return DEFAULT_SESSION_EXPIRY;
6328
6366
  }
6367
+ function resolveInviteTokenExpiry(collection) {
6368
+ const auth = collection.auth;
6369
+ if (auth && typeof auth === "object" && typeof auth.inviteExpiration === "string" && auth.inviteExpiration.length > 0) {
6370
+ return auth.inviteExpiration;
6371
+ }
6372
+ return DEFAULT_INVITE_EXPIRY;
6373
+ }
6374
+ function resolveResetPasswordTokenExpiry(collection) {
6375
+ const auth = collection.auth;
6376
+ if (auth && typeof auth === "object" && typeof auth.resetPasswordExpiration === "string" && auth.resetPasswordExpiration.length > 0) {
6377
+ return auth.resetPasswordExpiration;
6378
+ }
6379
+ return DEFAULT_RESET_PASSWORD_EXPIRY;
6380
+ }
6329
6381
  async function signCollectionToken(payload, expiresIn = DEFAULT_EXPIRY) {
6330
6382
  return new import_jose.SignJWT({ ...payload }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(expiresIn).sign(getSecret());
6331
6383
  }
@@ -6630,13 +6682,34 @@ function getAdminRoleForCollection(collection) {
6630
6682
  }
6631
6683
  return "admin";
6632
6684
  }
6685
+ var STANDARD_ADMIN_ROLES = /* @__PURE__ */ new Set([
6686
+ "admin",
6687
+ "super_admin",
6688
+ "superadmin",
6689
+ "super admin",
6690
+ "super-admin",
6691
+ "owner"
6692
+ ]);
6633
6693
  function isUserAdmin(user, collection) {
6634
6694
  if (!user) return false;
6635
- const roles = Array.isArray(user.roles) ? user.roles : typeof user.role === "string" ? [user.role] : [];
6636
- if (roles.length === 0) return false;
6695
+ const rawRoles = Array.isArray(user.roles) ? user.roles : typeof user.roles === "string" ? [user.roles] : Array.isArray(user.role) ? user.role : typeof user.role === "string" ? [user.role] : [];
6696
+ if (rawRoles.length === 0) return false;
6697
+ const roles = rawRoles.filter((r) => typeof r === "string");
6637
6698
  const configuredRole = getAdminRoleForCollection(collection);
6638
- if (roles.includes(configuredRole)) return true;
6639
- return roles.includes("admin") || roles.includes("super_admin");
6699
+ for (const r of roles) {
6700
+ if (r === configuredRole || r.toLowerCase() === configuredRole.toLowerCase()) {
6701
+ return true;
6702
+ }
6703
+ const normalized = r.trim().toLowerCase();
6704
+ if (STANDARD_ADMIN_ROLES.has(normalized)) {
6705
+ return true;
6706
+ }
6707
+ const stripped = normalized.replace(/[\s_-]+/g, "");
6708
+ if (stripped === "admin" || stripped === "superadmin" || stripped === "owner") {
6709
+ return true;
6710
+ }
6711
+ }
6712
+ return false;
6640
6713
  }
6641
6714
 
6642
6715
  // src/utils/collection-search.ts
@@ -7285,7 +7358,7 @@ function compileNavigation(config) {
7285
7358
  const mentionedTrash = explicitNav.some((i) => i.trash || i.slug === "trash");
7286
7359
  if (!mentionedTrash) {
7287
7360
  const hasTrash = collections.some(
7288
- (col) => !col.slug.startsWith("__") && resolveTrashConfig(col, config).enabled
7361
+ (col) => !INTERNAL_SYSTEM_COLLECTIONS.has(col.slug) && resolveTrashConfig(col, config).enabled
7289
7362
  );
7290
7363
  if (hasTrash) {
7291
7364
  registerItem(void 0, {
@@ -11444,9 +11517,16 @@ function resolveAdminUrl(c, config, clientUrl) {
11444
11517
  function appendQueryParam(url, key, value) {
11445
11518
  return `${url}${url.includes("?") ? "&" : "?"}${key}=${encodeURIComponent(value)}`;
11446
11519
  }
11447
-
11448
- // src/controllers/auth.controller.ts
11449
- var import_node_crypto3 = require("crypto");
11520
+ function resolveActionUrl(c, config, customUrl) {
11521
+ if (customUrl && typeof customUrl === "string" && customUrl.trim().length > 0) {
11522
+ try {
11523
+ return new URL(customUrl.trim(), c.req.url).toString();
11524
+ } catch {
11525
+ return customUrl.trim();
11526
+ }
11527
+ }
11528
+ return resolveAdminUrl(c, config);
11529
+ }
11450
11530
 
11451
11531
  // src/auth/lockout.ts
11452
11532
  var DEFAULT_MAX_LOGIN_ATTEMPTS = 5;
@@ -11503,15 +11583,21 @@ var emailTokens = {
11503
11583
  width: "600px"
11504
11584
  };
11505
11585
  function escapeHtml(value) {
11506
- return value.replace(/[&<>'"]/g, (character) => ({
11507
- "&": "&amp;",
11508
- "<": "&lt;",
11509
- ">": "&gt;",
11510
- "'": "&#39;",
11511
- '"': "&quot;"
11512
- })[character]);
11586
+ return value.replace(
11587
+ /[&<>'"]/g,
11588
+ (character) => ({
11589
+ "&": "&amp;",
11590
+ "<": "&lt;",
11591
+ ">": "&gt;",
11592
+ "'": "&#39;",
11593
+ '"': "&quot;"
11594
+ })[character]
11595
+ );
11513
11596
  }
11514
11597
  function safeHttpUrl(value) {
11598
+ if (value.startsWith("{{") && value.endsWith("}}")) {
11599
+ return value;
11600
+ }
11515
11601
  try {
11516
11602
  const url = new URL(value);
11517
11603
  return url.protocol === "https:" || url.protocol === "http:" ? escapeHtml(url.toString()) : void 0;
@@ -11522,8 +11608,8 @@ function safeHttpUrl(value) {
11522
11608
  function heading(content) {
11523
11609
  return `<h1 style="margin:0;font-family:${emailTokens.font};font-size:24px;line-height:1.25;font-weight:700;color:${emailTokens.colors.text}">${escapeHtml(content)}</h1>`;
11524
11610
  }
11525
- function paragraph(content, margin = "0 0 16px") {
11526
- return `<p style="margin:${margin};font-family:${emailTokens.font};font-size:15px;line-height:1.6;color:${emailTokens.colors.muted}">${escapeHtml(content)}</p>`;
11611
+ function paragraph(content, margin = "0 0 16px", size = "15px") {
11612
+ return `<p style="margin:${margin};font-family:${emailTokens.font};font-size:${size};line-height:1.6;color:${emailTokens.colors.muted}">${escapeHtml(content)}</p>`;
11527
11613
  }
11528
11614
  function sectionLabel(content) {
11529
11615
  return `<p style="margin:0 0 8px;font-family:${emailTokens.font};font-size:11px;line-height:1.4;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:${emailTokens.colors.subtle}">${escapeHtml(content)}</p>`;
@@ -11553,10 +11639,13 @@ function detailBox(content, monospace = false) {
11553
11639
  function ctaButton(label, href) {
11554
11640
  const safeHref = safeHttpUrl(href);
11555
11641
  if (!safeHref) return "";
11556
- return table(row(
11557
- `<a href="${safeHref}" style="display:inline-block;padding:13px 22px;border-radius:${emailTokens.radius.control};background:${emailTokens.colors.accent};font-family:${emailTokens.font};font-size:14px;line-height:1.2;font-weight:700;color:${emailTokens.colors.text};text-decoration:none">${escapeHtml(label)}</a>`,
11558
- "padding:8px 0 24px"
11559
- ), "width:auto");
11642
+ return table(
11643
+ row(
11644
+ `<a href="${safeHref}" style="display:inline-block;padding:13px 22px;border-radius:${emailTokens.radius.control};background:${emailTokens.colors.accent};font-family:${emailTokens.font};font-size:14px;line-height:1.2;font-weight:700;color:${emailTokens.colors.text};text-decoration:none">${escapeHtml(label)}</a>`,
11645
+ "padding:8px 0 24px"
11646
+ ),
11647
+ "width:auto"
11648
+ );
11560
11649
  }
11561
11650
  function safeLinkDetailBox(href) {
11562
11651
  const safeHref = safeHttpUrl(href);
@@ -11564,9 +11653,33 @@ function safeLinkDetailBox(href) {
11564
11653
  return detailBox(safeHref, true);
11565
11654
  }
11566
11655
  function alertBox(content) {
11567
- return table(row(escapeHtml(content), `padding:13px 16px;font-family:${emailTokens.font};font-size:13px;line-height:1.5;color:${emailTokens.colors.dangerText}`), `width:100%;background:${emailTokens.colors.dangerSurface};border:1px solid ${emailTokens.colors.dangerBorder};border-radius:${emailTokens.radius.control}`);
11656
+ return table(
11657
+ row(
11658
+ escapeHtml(content),
11659
+ `padding:13px 16px;font-family:${emailTokens.font};font-size:13px;line-height:1.5;color:${emailTokens.colors.dangerText}`
11660
+ ),
11661
+ `width:100%;background:${emailTokens.colors.dangerSurface};border:1px solid ${emailTokens.colors.dangerBorder};border-radius:${emailTokens.radius.control}`
11662
+ );
11568
11663
  }
11569
- function layout({ preheader, title, content, footer }) {
11664
+ function resolveBlocks(input) {
11665
+ if (Array.isArray(input)) {
11666
+ const parts = [];
11667
+ for (const item of input) {
11668
+ if (Array.isArray(item)) {
11669
+ for (const sub of item) {
11670
+ if (typeof sub === "string" && sub.length > 0) parts.push(sub);
11671
+ }
11672
+ } else if (typeof item === "string" && item.length > 0) {
11673
+ parts.push(item);
11674
+ }
11675
+ }
11676
+ return parts.join("\n");
11677
+ }
11678
+ return typeof input === "string" ? input : "";
11679
+ }
11680
+ function layout({ preheader, title, content, footer, siteName }) {
11681
+ const resolvedContent = resolveBlocks(content);
11682
+ const resolvedFooter = resolveBlocks(footer);
11570
11683
  return `<!doctype html>
11571
11684
  <html lang="en" dir="ltr" xmlns="http://www.w3.org/1999/xhtml">
11572
11685
  <head>
@@ -11587,8 +11700,8 @@ function layout({ preheader, title, content, footer }) {
11587
11700
  ${table(
11588
11701
  row(
11589
11702
  table(
11590
- row("&nbsp;", `height:5px;background:${emailTokens.colors.accent};font-size:0;line-height:0`) + row(`${sectionLabel("Dyrected")}${heading(title)}`, "padding:30px 32px 24px") + row(content, "padding:0 32px 32px") + row(
11591
- `${divider()}${paragraph(footer, "20px 0 6px")}${paragraph("Privacy: this message contains account-related information; please avoid forwarding it.", "0")}`,
11703
+ row("&nbsp;", `height:5px;background:${emailTokens.colors.accent};font-size:0;line-height:0`) + row(`${sectionLabel(siteName ?? "Dyrected")}${heading(title)}`, "padding:30px 32px 24px") + row(resolvedContent, "padding:0 32px 32px") + row(
11704
+ `${divider()}${paragraph(resolvedFooter, "20px 0 6px")}${paragraph("Privacy: this message contains account-related information; please avoid forwarding it.", "10px 0px 0px 0px", "12px")}`,
11592
11705
  "padding:0 32px 28px"
11593
11706
  ),
11594
11707
  `width:100%;max-width:${emailTokens.width};background:${emailTokens.colors.surface};border:1px solid ${emailTokens.colors.border};border-radius:${emailTokens.radius.card};overflow:hidden`
@@ -11601,6 +11714,81 @@ function layout({ preheader, title, content, footer }) {
11601
11714
  </body>
11602
11715
  </html>`;
11603
11716
  }
11717
+ function getDefaultEmailTemplate(purpose, options) {
11718
+ const siteName = options?.siteName ?? "Dyrected";
11719
+ switch (purpose) {
11720
+ case "invite":
11721
+ return {
11722
+ subject: `You've been invited to ${siteName}`,
11723
+ rawHtml: layout({
11724
+ siteName,
11725
+ preheader: `You've been invited to join ${siteName}.`,
11726
+ title: "You've been invited",
11727
+ content: [
11728
+ paragraph("Use the invitation link below to create your account. The link expires in 7 days."),
11729
+ ctaButton("Accept invitation", "{{url}}"),
11730
+ sectionLabel("Invitation link"),
11731
+ safeLinkDetailBox("{{url}}"),
11732
+ paragraph("If the button does not work, copy and paste the link above into your browser.", "12px 0 0")
11733
+ ],
11734
+ footer: "If you weren't expecting this invitation, you can safely ignore this email."
11735
+ })
11736
+ };
11737
+ case "resetPassword":
11738
+ return {
11739
+ subject: "Reset your password",
11740
+ rawHtml: layout({
11741
+ siteName,
11742
+ preheader: `Reset your ${siteName} password.`,
11743
+ title: "Reset your password",
11744
+ content: [
11745
+ paragraph("We received a request to reset your password. The reset link expires in 1 hour."),
11746
+ ctaButton("Reset password", "{{url}}"),
11747
+ sectionLabel("Reset link"),
11748
+ safeLinkDetailBox("{{url}}"),
11749
+ paragraph("If the button does not work, copy and paste the link above into your browser.", "12px 0 0")
11750
+ ],
11751
+ footer: "If you didn't request a password reset, you can safely ignore this email."
11752
+ })
11753
+ };
11754
+ case "welcome":
11755
+ return {
11756
+ subject: `Welcome to ${siteName} \u2014 your account is ready`,
11757
+ rawHtml: layout({
11758
+ siteName,
11759
+ preheader: `Your ${siteName} account is ready.`,
11760
+ title: "Welcome \u2014 your account is ready",
11761
+ content: [paragraph("Your account has been created. You can now log in with:"), detailBox("{{email}}")],
11762
+ footer: "If you didn't create this account, you can safely ignore this email."
11763
+ })
11764
+ };
11765
+ case "passwordChanged":
11766
+ return {
11767
+ subject: "Your password has been changed",
11768
+ rawHtml: layout({
11769
+ siteName,
11770
+ preheader: `Your ${siteName} password was changed.`,
11771
+ title: "Password changed",
11772
+ content: [
11773
+ paragraph("The password for your account has been changed."),
11774
+ alertBox("If you did not make this change, please contact support or reset your password immediately.")
11775
+ ],
11776
+ footer: "Security notice: this confirms a recent credential change."
11777
+ })
11778
+ };
11779
+ default:
11780
+ return {
11781
+ subject: `Notification from ${siteName}`,
11782
+ rawHtml: layout({
11783
+ siteName,
11784
+ preheader: `Account notification from ${siteName}.`,
11785
+ title: "Account Notification",
11786
+ content: [paragraph(`This is an account notification from ${siteName}.`)],
11787
+ footer: "If you have any questions, please reach out to our team."
11788
+ })
11789
+ };
11790
+ }
11791
+ }
11604
11792
 
11605
11793
  // src/services/email.service.ts
11606
11794
  var _devSend = null;
@@ -11628,11 +11816,18 @@ async function getDevSend() {
11628
11816
  password: account.pass
11629
11817
  });
11630
11818
  _devSend = async ({ to, subject, html }) => {
11631
- const info = await transport.sendMail({ from: '"Dyrected Dev" <dev@dyrected.local>', to, subject, html });
11819
+ const info = await transport.sendMail({
11820
+ from: '"Dyrected Dev" <dev@dyrected.local>',
11821
+ to,
11822
+ subject,
11823
+ html
11824
+ });
11825
+ const previewUrl = nodemailer.default.getTestMessageUrl(info) || void 0;
11632
11826
  logger.info({
11633
11827
  msg: "Email preview URL",
11634
- previewUrl: nodemailer.default.getTestMessageUrl(info)
11828
+ previewUrl
11635
11829
  });
11830
+ return { previewUrl };
11636
11831
  };
11637
11832
  return _devSend;
11638
11833
  } catch {
@@ -11658,69 +11853,286 @@ async function sendEmail(config, payload) {
11658
11853
  err,
11659
11854
  msg: "Email provider send failed",
11660
11855
  to: payload.to,
11661
- subject: payload.subject
11856
+ subject: "subject" in payload ? payload.subject : void 0,
11857
+ template: "template" in payload ? payload.template : void 0
11662
11858
  });
11663
11859
  throw err;
11664
11860
  }
11665
- return;
11861
+ return { success: true };
11666
11862
  }
11667
11863
  if (process.env.NODE_ENV !== "production") {
11668
11864
  const devSend = await getDevSend();
11669
- await devSend?.(payload);
11865
+ if (devSend) {
11866
+ if ("template" in payload && payload.template) {
11867
+ const devHtml = `
11868
+ <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
11869
+ <h3 style="margin-top:0; color: #1e293b;">External Template Email (Dev Preview)</h3>
11870
+ <p><strong>To:</strong> ${payload.to}</p>
11871
+ <p><strong>Template ID:</strong> <code>${payload.template}</code></p>
11872
+ ${payload.subject ? `<p><strong>Subject:</strong> ${payload.subject}</p>` : ""}
11873
+ <p><strong>Variables:</strong></p>
11874
+ <pre style="background: #f8fafc; padding: 12px; border-radius: 6px; overflow-x: auto;">${JSON.stringify(payload.variables, null, 2)}</pre>
11875
+ </div>
11876
+ `;
11877
+ const res = await devSend({
11878
+ to: payload.to,
11879
+ subject: payload.subject ?? `[Template: ${payload.template}] External Email`,
11880
+ html: devHtml
11881
+ });
11882
+ return { success: true, previewUrl: res?.previewUrl };
11883
+ } else if ("html" in payload) {
11884
+ const res = await devSend({
11885
+ to: payload.to,
11886
+ subject: payload.subject,
11887
+ html: payload.html
11888
+ });
11889
+ return { success: true, previewUrl: res?.previewUrl };
11890
+ }
11891
+ }
11892
+ }
11893
+ return { success: true };
11894
+ }
11895
+ function interpolateVariables(str, args, escapeMode = "html") {
11896
+ if (!str || typeof str !== "string") return str;
11897
+ return str.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, path) => {
11898
+ const parts = path.split(".");
11899
+ let val = args;
11900
+ for (const part of parts) {
11901
+ if (val == null) break;
11902
+ val = val[part];
11903
+ }
11904
+ if (val === void 0 || val === null) return "";
11905
+ const raw = String(val);
11906
+ return escapeMode === "html" ? escapeHtml(raw) : raw;
11907
+ });
11908
+ }
11909
+ function formatDbTemplateDoc(doc, args) {
11910
+ if (doc.format === "external_template" || doc.externalTemplateId) {
11911
+ const rawVars = doc.variables && typeof doc.variables === "object" ? doc.variables : {};
11912
+ const interpolatedVars = { ...args };
11913
+ for (const [key, val] of Object.entries(rawVars)) {
11914
+ if (typeof val === "string") {
11915
+ interpolatedVars[key] = interpolateVariables(val, args);
11916
+ } else {
11917
+ interpolatedVars[key] = val;
11918
+ }
11919
+ }
11920
+ return {
11921
+ template: doc.externalTemplateId ?? "",
11922
+ variables: interpolatedVars,
11923
+ ...doc.subject ? { subject: interpolateVariables(doc.subject, args) } : {}
11924
+ };
11925
+ }
11926
+ return {
11927
+ subject: interpolateVariables(doc.subject || "", args),
11928
+ html: interpolateVariables(doc.rawHtml || "", args)
11929
+ };
11930
+ }
11931
+ async function getDbEmailTemplateOverride(db, collectionSlug, purpose, args) {
11932
+ if (!db || typeof db.find !== "function") return null;
11933
+ try {
11934
+ if (collectionSlug) {
11935
+ const specific = await db.find({
11936
+ collection: "__email_templates",
11937
+ where: {
11938
+ collectionSlug,
11939
+ purpose,
11940
+ active: true
11941
+ },
11942
+ limit: 1
11943
+ });
11944
+ const doc2 = specific?.docs?.[0];
11945
+ if (doc2) {
11946
+ return formatDbTemplateDoc(doc2, args);
11947
+ }
11948
+ }
11949
+ const global = await db.find({
11950
+ collection: "__email_templates",
11951
+ where: {
11952
+ collectionSlug: "*",
11953
+ purpose,
11954
+ active: true
11955
+ },
11956
+ limit: 1
11957
+ });
11958
+ const doc = global?.docs?.[0];
11959
+ if (doc) {
11960
+ return formatDbTemplateDoc(doc, args);
11961
+ }
11962
+ } catch {
11963
+ return null;
11964
+ }
11965
+ return null;
11966
+ }
11967
+ function enrichArgs(config, collection, args) {
11968
+ const slug = collection?.slug;
11969
+ const readableSlug = slug?.replace(/^__/, "");
11970
+ const fallbackLabel = readableSlug ? readableSlug.charAt(0).toUpperCase() + readableSlug.slice(1) : "Account";
11971
+ return {
11972
+ ...args,
11973
+ collection: slug,
11974
+ collectionLabel: collection?.labels?.singular || collection?.labels?.plural || fallbackLabel,
11975
+ collectionLabels: collection?.labels,
11976
+ siteName: config.admin?.branding?.logoText ?? "Dyrected"
11977
+ };
11978
+ }
11979
+ function toOutboundEmail(to, result, metadata) {
11980
+ if ("template" in result) {
11981
+ return {
11982
+ to,
11983
+ type: "template",
11984
+ template: result.template,
11985
+ variables: result.variables,
11986
+ ...result.subject ? { subject: result.subject } : {},
11987
+ ...metadata
11988
+ };
11670
11989
  }
11990
+ return {
11991
+ to,
11992
+ type: "html",
11993
+ subject: result.subject ?? "Notification",
11994
+ html: result.html,
11995
+ ...result.text ? { text: result.text } : {},
11996
+ ...metadata
11997
+ };
11671
11998
  }
11672
- function buildWelcomeEmail(config, args) {
11673
- const custom = config.email?.templates?.welcome?.(args);
11999
+ function buildWelcomeEmail(config, args, collection, dbOverride) {
12000
+ if (dbOverride) return dbOverride;
12001
+ const enriched = enrichArgs(config, collection, args);
12002
+ const collectionAuth = collection?.auth;
12003
+ if (collectionAuth && typeof collectionAuth === "object" && collectionAuth.email?.templates?.welcome) {
12004
+ return collectionAuth.email.templates.welcome(enriched);
12005
+ }
12006
+ const custom = config.email?.templates?.welcome?.(enriched);
12007
+ if (custom) return custom;
11674
12008
  return {
11675
- subject: custom?.subject ?? "Welcome \u2014 your account is ready",
11676
- html: custom?.html ?? layout({
11677
- preheader: "Your Dyrected account is ready.",
12009
+ subject: "Welcome \u2014 your account is ready",
12010
+ html: layout({
12011
+ siteName: enriched.siteName,
12012
+ preheader: `Your ${enriched.siteName} account is ready.`,
11678
12013
  title: "Welcome \u2014 your account is ready",
11679
- content: `${paragraph("Your account has been created. You can now log in with:")}${detailBox(args.email)}`,
12014
+ content: [
12015
+ paragraph("Your account has been created. You can now log in with:"),
12016
+ detailBox(args.email)
12017
+ ],
11680
12018
  footer: "If you didn't create this account, you can safely ignore this email."
11681
12019
  })
11682
12020
  };
11683
12021
  }
11684
- function buildInviteEmail(config, args) {
11685
- const custom = config.email?.templates?.invite?.(args);
12022
+ function buildInviteEmail(config, args, collection, dbOverride) {
12023
+ if (dbOverride) return dbOverride;
12024
+ const enriched = enrichArgs(config, collection, args);
12025
+ const collectionAuth = collection?.auth;
12026
+ if (collectionAuth && typeof collectionAuth === "object" && collectionAuth.email?.templates?.invite) {
12027
+ return collectionAuth.email.templates.invite(enriched);
12028
+ }
12029
+ const custom = config.email?.templates?.invite?.(enriched);
12030
+ if (custom) return custom;
11686
12031
  const inviteLink = args.url;
11687
12032
  const inviteLinkBlock = inviteLink ? safeLinkDetailBox(inviteLink) : "";
11688
12033
  return {
11689
- subject: custom?.subject ?? "You've been invited",
11690
- html: custom?.html ?? layout({
11691
- preheader: "You've been invited to join a Dyrected admin area.",
12034
+ subject: "You've been invited",
12035
+ html: layout({
12036
+ siteName: enriched.siteName,
12037
+ preheader: `You've been invited to join ${enriched.siteName}.`,
11692
12038
  title: "You've been invited",
11693
- content: inviteLink ? `${args.invitedByEmail ? paragraph(`You were invited by ${args.invitedByEmail}.`) : ""}${paragraph("Use the invitation link below to create your account. The link expires in 7 days.")}${ctaButton("Accept invitation", inviteLink)}${sectionLabel("Invitation link")}${inviteLinkBlock}${paragraph("If the button does not work, copy and paste the link above into your browser.", "12px 0 0")}` : `${args.invitedByEmail ? paragraph(`You were invited by ${args.invitedByEmail}.`) : ""}${paragraph("Use the invitation token below to accept your invitation. It expires in 7 days.")}${sectionLabel("Invitation token")}${detailBox(args.token, true)}`,
12039
+ content: [
12040
+ args.invitedByEmail && paragraph(`You were invited by ${args.invitedByEmail}.`),
12041
+ inviteLink ? [
12042
+ paragraph("Use the invitation link below to create your account. The link expires in 7 days."),
12043
+ ctaButton("Accept invitation", inviteLink),
12044
+ sectionLabel("Invitation link"),
12045
+ inviteLinkBlock,
12046
+ paragraph("If the button does not work, copy and paste the link above into your browser.", "12px 0 0")
12047
+ ] : [
12048
+ paragraph("Use the invitation token below to accept your invitation. It expires in 7 days."),
12049
+ sectionLabel("Invitation token"),
12050
+ detailBox(args.token, true)
12051
+ ]
12052
+ ],
11694
12053
  footer: "If you weren't expecting this invitation, you can safely ignore this email."
11695
12054
  })
11696
12055
  };
11697
12056
  }
11698
- function buildResetPasswordEmail(config, args) {
11699
- const custom = config.email?.templates?.resetPassword?.(args);
12057
+ function buildResetPasswordEmail(config, args, collection, dbOverride) {
12058
+ if (dbOverride) return dbOverride;
12059
+ const enriched = enrichArgs(config, collection, args);
12060
+ const collectionAuth = collection?.auth;
12061
+ if (collectionAuth && typeof collectionAuth === "object" && collectionAuth.email?.templates?.resetPassword) {
12062
+ return collectionAuth.email.templates.resetPassword(enriched);
12063
+ }
12064
+ const custom = config.email?.templates?.resetPassword?.(enriched);
12065
+ if (custom) return custom;
11700
12066
  const resetLink = args.url;
11701
12067
  const resetLinkBlock = resetLink ? safeLinkDetailBox(resetLink) : "";
11702
12068
  return {
11703
- subject: custom?.subject ?? "Reset your password",
11704
- html: custom?.html ?? layout({
11705
- preheader: "Reset your Dyrected password.",
12069
+ subject: "Reset your password",
12070
+ html: layout({
12071
+ siteName: enriched.siteName,
12072
+ preheader: `Reset your ${enriched.siteName} password.`,
11706
12073
  title: "Reset your password",
11707
- content: resetLink ? `${paragraph("We received a request to reset your password. The reset link expires in 1 hour.")}${ctaButton("Reset password", resetLink)}${sectionLabel("Reset link")}${resetLinkBlock}${paragraph("If the button does not work, copy and paste the link above into your browser.", "12px 0 0")}` : `${paragraph("We received a request to reset your password. The reset token expires in 1 hour.")}${sectionLabel("Reset token")}${detailBox(args.token, true)}`,
12074
+ content: [
12075
+ resetLink ? [
12076
+ paragraph("We received a request to reset your password. The reset link expires in 1 hour."),
12077
+ ctaButton("Reset password", resetLink),
12078
+ sectionLabel("Reset link"),
12079
+ resetLinkBlock,
12080
+ paragraph("If the button does not work, copy and paste the link above into your browser.", "12px 0 0")
12081
+ ] : [
12082
+ paragraph("We received a request to reset your password. The reset token expires in 1 hour."),
12083
+ sectionLabel("Reset token"),
12084
+ detailBox(args.token, true)
12085
+ ]
12086
+ ],
11708
12087
  footer: "If you didn't request a password reset, you can safely ignore this email."
11709
12088
  })
11710
12089
  };
11711
12090
  }
11712
- function buildPasswordChangedEmail(config, args) {
11713
- const custom = config.email?.templates?.passwordChanged?.(args);
12091
+ function buildPasswordChangedEmail(config, args, collection, dbOverride) {
12092
+ if (dbOverride) return dbOverride;
12093
+ const enriched = enrichArgs(config, collection, args);
12094
+ const collectionAuth = collection?.auth;
12095
+ if (collectionAuth && typeof collectionAuth === "object" && collectionAuth.email?.templates?.passwordChanged) {
12096
+ return collectionAuth.email.templates.passwordChanged(enriched);
12097
+ }
12098
+ const custom = config.email?.templates?.passwordChanged?.(enriched);
12099
+ if (custom) return custom;
11714
12100
  return {
11715
- subject: custom?.subject ?? "Your password has been changed",
11716
- html: custom?.html ?? layout({
11717
- preheader: "Your Dyrected password was changed.",
12101
+ subject: "Your password has been changed",
12102
+ html: layout({
12103
+ siteName: enriched.siteName,
12104
+ preheader: `Your ${enriched.siteName} password was changed.`,
11718
12105
  title: "Password changed",
11719
- content: `${paragraph("The password for this account was just changed:")}${detailBox(args.email)}${spacer()}${alertBox("If you did not make this change, please contact support immediately.")}`,
12106
+ content: [
12107
+ paragraph("The password for this account was just changed:"),
12108
+ detailBox(args.email),
12109
+ spacer(),
12110
+ alertBox("If you did not make this change, please contact support immediately.")
12111
+ ],
11720
12112
  footer: "This is an automated security notification."
11721
12113
  })
11722
12114
  };
11723
12115
  }
12116
+ async function resolveEmailTemplate(params) {
12117
+ const { config, collection, purpose, args, db } = params;
12118
+ const enriched = enrichArgs(config, collection, args);
12119
+ let dbOverride = null;
12120
+ if (config.email?.adminEditable !== false && db) {
12121
+ dbOverride = await getDbEmailTemplateOverride(db, collection?.slug, purpose, enriched);
12122
+ }
12123
+ switch (purpose) {
12124
+ case "welcome":
12125
+ return buildWelcomeEmail(config, enriched, collection, dbOverride);
12126
+ case "invite":
12127
+ return buildInviteEmail(config, enriched, collection, dbOverride);
12128
+ case "resetPassword":
12129
+ return buildResetPasswordEmail(config, enriched, collection, dbOverride);
12130
+ case "passwordChanged":
12131
+ return buildPasswordChangedEmail(config, enriched, collection, dbOverride);
12132
+ default:
12133
+ throw new Error(`Unknown email template purpose: ${purpose}`);
12134
+ }
12135
+ }
11724
12136
 
11725
12137
  // src/controllers/auth.controller.ts
11726
12138
  var AuthController = class {
@@ -11757,9 +12169,10 @@ var AuthController = class {
11757
12169
  const data = {
11758
12170
  ...safeExtraFields,
11759
12171
  email,
11760
- password: await hashPassword((0, import_node_crypto3.randomBytes)(32).toString("hex"))
12172
+ password: null,
12173
+ invitedAt: Date.now()
11761
12174
  };
11762
- if (this.hasField("status")) {
12175
+ if (this.hasField("status") && !data.status) {
11763
12176
  data.status = "pending";
11764
12177
  }
11765
12178
  return data;
@@ -11882,6 +12295,7 @@ var AuthController = class {
11882
12295
  ...rolePayload
11883
12296
  }
11884
12297
  });
12298
+ const safeUser = this.sanitizeUser(user);
11885
12299
  const token = await issueAuthSessionToken({
11886
12300
  config,
11887
12301
  userId: user.id,
@@ -11891,15 +12305,28 @@ var AuthController = class {
11891
12305
  ip: c.get("clientIp"),
11892
12306
  authSource: "local"
11893
12307
  });
11894
- const { subject, html } = buildWelcomeEmail(config, { email: body.email });
11895
- sendEmail(config, { to: body.email, subject, html }).catch(
12308
+ resolveEmailTemplate({
12309
+ config,
12310
+ collection: this.collection,
12311
+ purpose: "welcome",
12312
+ args: { email: body.email, user: safeUser },
12313
+ db
12314
+ }).then(
12315
+ (emailResult) => sendEmail(
12316
+ config,
12317
+ toOutboundEmail(body.email, emailResult, {
12318
+ collection: this.collection.slug,
12319
+ purpose: "welcome",
12320
+ user: safeUser
12321
+ })
12322
+ )
12323
+ ).catch(
11896
12324
  (err) => getRequestLogger(c, "auth").error({
11897
12325
  err,
11898
12326
  msg: "Failed to send welcome email",
11899
12327
  email: body.email
11900
12328
  })
11901
12329
  );
11902
- const safeUser = this.sanitizeUser(user);
11903
12330
  return c.json({ token, user: safeUser });
11904
12331
  }
11905
12332
  // ---------------------------------------------------------------------------
@@ -11933,6 +12360,16 @@ var AuthController = class {
11933
12360
  403
11934
12361
  );
11935
12362
  }
12363
+ if (!hasUsablePassword(user.password)) {
12364
+ return c.json(
12365
+ {
12366
+ error: true,
12367
+ code: "PASSWORD_NOT_SET",
12368
+ message: "This account does not have a password set. Please use the invite link or reset password."
12369
+ },
12370
+ 401
12371
+ );
12372
+ }
11936
12373
  const lockedUntilMs = getLockedUntilMs(user.lockedUntil);
11937
12374
  if (lockedUntilMs !== null && lockedUntilMs > Date.now()) {
11938
12375
  const retryAfterSeconds = Math.max(
@@ -12110,34 +12547,67 @@ var AuthController = class {
12110
12547
  limit: 1
12111
12548
  });
12112
12549
  const user = result.docs[0];
12550
+ let resetToken;
12551
+ let url;
12552
+ let emailSent = false;
12553
+ let emailError;
12113
12554
  if (user) {
12114
- const resetToken = await signCollectionToken(
12555
+ const siteId = c.get("siteId");
12556
+ resetToken = await signCollectionToken(
12115
12557
  {
12116
- sub: user.id,
12558
+ sub: String(user.id),
12117
12559
  email: user.email,
12118
12560
  collection: this.collection.slug,
12119
- purpose: "reset"
12561
+ purpose: "reset",
12562
+ ...siteId ? { siteId } : {}
12120
12563
  },
12121
- "1h"
12564
+ resolveResetPasswordTokenExpiry(this.collection)
12122
12565
  );
12123
- const url = appendQueryParam(resolveAdminUrl(c, config, body?.resetUrl), "token", resetToken);
12124
- try {
12125
- const { subject, html } = buildResetPasswordEmail(config, {
12126
- token: resetToken,
12127
- url
12128
- });
12129
- await sendEmail(config, { to: user.email, subject, html });
12130
- } catch (err) {
12131
- getRequestLogger(c, "auth").error({
12132
- err,
12133
- msg: "Failed to send password reset email",
12134
- email: user.email
12135
- });
12566
+ const customResetUrl = body?.resetUrl ?? (typeof this.collection.auth === "object" ? this.collection.auth.urls?.resetPassword : void 0);
12567
+ url = appendQueryParam(
12568
+ resolveActionUrl(c, config, customResetUrl),
12569
+ "token",
12570
+ resetToken
12571
+ );
12572
+ if (body?.sendEmail !== false) {
12573
+ try {
12574
+ const emailResult = await resolveEmailTemplate({
12575
+ config,
12576
+ collection: this.collection,
12577
+ purpose: "resetPassword",
12578
+ args: {
12579
+ token: resetToken,
12580
+ url,
12581
+ user
12582
+ },
12583
+ db
12584
+ });
12585
+ await sendEmail(
12586
+ config,
12587
+ toOutboundEmail(user.email, emailResult, {
12588
+ collection: this.collection.slug,
12589
+ purpose: "resetPassword",
12590
+ user
12591
+ })
12592
+ );
12593
+ emailSent = true;
12594
+ } catch (err) {
12595
+ emailSent = false;
12596
+ emailError = err?.message ?? "Email delivery failed";
12597
+ getRequestLogger(c, "auth").error({
12598
+ err,
12599
+ msg: "Failed to send password reset email",
12600
+ email: user.email
12601
+ });
12602
+ }
12136
12603
  }
12137
12604
  }
12138
12605
  return c.json({
12139
12606
  success: true,
12140
- message: "If an account with that email exists, a reset link has been sent."
12607
+ message: "If an account with that email exists, a reset link has been sent.",
12608
+ ...body?.sendEmail === false && resetToken ? { token: resetToken, resetUrl: url } : {},
12609
+ emailSent,
12610
+ ...emailError ? { emailError } : {}
12141
12611
  });
12142
12612
  }
12143
12613
  // ---------------------------------------------------------------------------
@@ -12171,6 +12641,13 @@ var AuthController = class {
12171
12641
  400
12172
12642
  );
12173
12643
  }
12644
+ const currentSiteId = c.get("siteId");
12645
+ if (payload.siteId && currentSiteId && payload.siteId !== currentSiteId) {
12646
+ return c.json(
12647
+ { error: true, code: "SITE_MISMATCH", message: "Token is not valid for this site." },
12648
+ 403
12649
+ );
12650
+ }
12174
12651
  const hashedPassword = await hashPassword(body.password);
12175
12652
  await db.update({
12176
12653
  collection: this.collection.slug,
@@ -12185,10 +12662,24 @@ var AuthController = class {
12185
12662
  userId: payload.sub,
12186
12663
  collection: this.collection.slug
12187
12664
  });
12188
- const { subject, html } = buildPasswordChangedEmail(config, {
12189
- email: payload.email
12190
- });
12191
- sendEmail(config, { to: payload.email, subject, html }).catch(
12665
+ resolveEmailTemplate({
12666
+ config,
12667
+ collection: this.collection,
12668
+ purpose: "passwordChanged",
12669
+ args: {
12670
+ email: payload.email,
12671
+ user: { id: payload.sub, email: payload.email }
12672
+ },
12673
+ db
12674
+ }).then(
12675
+ (emailResult) => sendEmail(
12676
+ config,
12677
+ toOutboundEmail(payload.email, emailResult, {
12678
+ collection: this.collection.slug,
12679
+ purpose: "passwordChanged"
12680
+ })
12681
+ )
12682
+ ).catch(
12192
12683
  (err) => getRequestLogger(c, "auth").error({
12193
12684
  err,
12194
12685
  msg: "Failed to send password-changed email",
@@ -12217,61 +12708,117 @@ var AuthController = class {
12217
12708
  if (!body?.email) {
12218
12709
  return c.json({ error: true, message: "email is required." }, 400);
12219
12710
  }
12220
- const inviteData = body?.data && typeof body.data === "object" && !Array.isArray(body.data) ? body.data : {};
12711
+ const {
12712
+ email,
12713
+ inviteUrl,
12714
+ sendEmail: shouldSendEmail,
12715
+ data: nestedData,
12716
+ ...topLevelFields
12717
+ } = body;
12718
+ const inviteData = {
12719
+ ...topLevelFields,
12720
+ ...nestedData && typeof nestedData === "object" && !Array.isArray(nestedData) ? nestedData : {}
12721
+ };
12221
12722
  const existing = await db.find({
12222
12723
  collection: this.collection.slug,
12223
- where: { email: body.email },
12724
+ where: { email },
12224
12725
  limit: 1
12225
12726
  });
12226
12727
  const existingUser = existing.docs[0];
12227
12728
  const existingIsPending = existingUser?.status === "pending";
12228
- if (existingUser && !existingIsPending) {
12729
+ if (existingUser && !existingIsPending && hasUsablePassword(existingUser.password)) {
12229
12730
  return c.json(
12230
- { error: true, message: "An account with that email already exists." },
12731
+ {
12732
+ error: true,
12733
+ code: "USER_ALREADY_EXISTS",
12734
+ message: "An account with that email already exists and has a password set. Use forgot password if needed."
12735
+ },
12231
12736
  409
12232
12737
  );
12233
12738
  }
12739
+ const now2 = Date.now();
12740
+ let targetUser;
12234
12741
  if (!existingUser) {
12235
- await db.create({
12742
+ const pendingData = await this.buildPendingInviteData(email, inviteData);
12743
+ pendingData.invitedAt = now2;
12744
+ targetUser = await db.create({
12236
12745
  collection: this.collection.slug,
12237
- data: await this.buildPendingInviteData(body.email, inviteData)
12746
+ data: pendingData
12238
12747
  });
12239
- } else if (Object.keys(inviteData).length > 0) {
12240
- await db.update({
12748
+ } else {
12749
+ const updateData = {
12750
+ ...inviteData,
12751
+ invitedAt: now2
12752
+ };
12753
+ if (this.hasField("status") && !existingUser.status) {
12754
+ updateData.status = "pending";
12755
+ }
12756
+ targetUser = await db.update({
12241
12757
  collection: this.collection.slug,
12242
12758
  id: String(existingUser.id),
12243
- data: await this.buildPendingInviteData(body.email, inviteData)
12759
+ data: updateData
12244
12760
  });
12245
12761
  }
12762
+ const siteId = c.get("siteId");
12246
12763
  const inviteToken = await signCollectionToken(
12247
12764
  {
12248
- sub: body.email,
12249
- email: body.email,
12765
+ sub: email,
12766
+ email,
12250
12767
  collection: this.collection.slug,
12251
- purpose: "invite"
12768
+ purpose: "invite",
12769
+ ...siteId ? { siteId } : {}
12252
12770
  },
12253
- "7d"
12771
+ resolveInviteTokenExpiry(this.collection)
12254
12772
  );
12255
- const url = appendQueryParam(resolveAdminUrl(c, config, body?.inviteUrl), "inviteToken", inviteToken);
12256
- try {
12257
- const { subject, html } = buildInviteEmail(config, {
12258
- token: inviteToken,
12259
- invitedByEmail: requestUser.email,
12260
- url
12261
- });
12262
- await sendEmail(config, { to: body.email, subject, html });
12263
- } catch (err) {
12264
- getRequestLogger(c, "auth").error({
12265
- err,
12266
- msg: "Failed to send invite email",
12267
- email: body.email
12268
- });
12773
+ const customInviteUrl = inviteUrl ?? (typeof this.collection.auth === "object" ? this.collection.auth.urls?.invite : void 0);
12774
+ const url = appendQueryParam(
12775
+ resolveActionUrl(c, config, customInviteUrl),
12776
+ "inviteToken",
12777
+ inviteToken
12778
+ );
12779
+ let emailSent = false;
12780
+ let emailError;
12781
+ if (shouldSendEmail !== false) {
12782
+ try {
12783
+ const emailResult = await resolveEmailTemplate({
12784
+ config,
12785
+ collection: this.collection,
12786
+ purpose: "invite",
12787
+ args: {
12788
+ token: inviteToken,
12789
+ invitedByEmail: requestUser.email,
12790
+ url,
12791
+ data: inviteData,
12792
+ user: targetUser
12793
+ },
12794
+ db
12795
+ });
12796
+ await sendEmail(
12797
+ config,
12798
+ toOutboundEmail(email, emailResult, {
12799
+ collection: this.collection.slug,
12800
+ purpose: "invite",
12801
+ user: targetUser
12802
+ })
12803
+ );
12804
+ emailSent = true;
12805
+ } catch (err) {
12806
+ emailSent = false;
12807
+ emailError = err?.message ?? "Email delivery failed";
12808
+ getRequestLogger(c, "auth").error({
12809
+ err,
12810
+ msg: "Failed to send invite email",
12811
+ email
12812
+ });
12813
+ }
12269
12814
  }
12270
12815
  return c.json({
12271
12816
  success: true,
12272
- message: `Invite sent to ${body.email}.`,
12817
+ message: emailSent ? `Invite sent to ${email}.` : shouldSendEmail === false ? `Invite created for ${email}.` : `Invite created for ${email}, but email delivery failed.`,
12273
12818
  token: inviteToken,
12274
- inviteUrl: url
12819
+ inviteUrl: url,
12820
+ emailSent,
12821
+ ...emailError ? { emailError } : {}
12275
12822
  });
12276
12823
  }
12277
12824
  // ---------------------------------------------------------------------------
@@ -12305,6 +12852,13 @@ var AuthController = class {
12305
12852
  400
12306
12853
  );
12307
12854
  }
12855
+ const currentSiteId = c.get("siteId");
12856
+ if (payload.siteId && currentSiteId && payload.siteId !== currentSiteId) {
12857
+ return c.json(
12858
+ { error: true, code: "SITE_MISMATCH", message: "Token is not valid for this site." },
12859
+ 403
12860
+ );
12861
+ }
12308
12862
  const inviteeEmail = payload.sub;
12309
12863
  const existing = await db.find({
12310
12864
  collection: this.collection.slug,
@@ -12313,12 +12867,26 @@ var AuthController = class {
12313
12867
  });
12314
12868
  const existingUser = existing.docs[0];
12315
12869
  const existingIsPending = existingUser?.status === "pending";
12316
- if (existingUser && !existingIsPending) {
12870
+ if (existingUser && !existingIsPending && hasUsablePassword(existingUser.password)) {
12317
12871
  return c.json(
12318
- { error: true, message: "An account with that email already exists." },
12872
+ {
12873
+ error: true,
12874
+ code: "INVITE_ALREADY_ACCEPTED",
12875
+ message: "This invitation has already been accepted. Please log in or use forgot password."
12876
+ },
12319
12877
  409
12320
12878
  );
12321
12879
  }
12880
+ if (existingUser && payload.iat && typeof existingUser.invitedAt === "number" && payload.iat * 1e3 < existingUser.invitedAt - 2e3) {
12881
+ return c.json(
12882
+ {
12883
+ error: true,
12884
+ code: "INVITE_SUPERSEDED",
12885
+ message: "This invitation link has been superseded by a newer invitation. Please use the latest link."
12886
+ },
12887
+ 400
12888
+ );
12889
+ }
12322
12890
  const { token: _t, password: _p, ...extraFields } = body;
12323
12891
  const hashedPassword = await hashPassword(body.password);
12324
12892
  const user = existingUser ? await db.update({
@@ -12342,23 +12910,179 @@ var AuthController = class {
12342
12910
  ip: c.get("clientIp"),
12343
12911
  authSource: "local"
12344
12912
  });
12345
- const { subject, html } = buildWelcomeEmail(config, {
12346
- email: inviteeEmail
12347
- });
12348
- sendEmail(config, { to: inviteeEmail, subject, html }).catch(
12913
+ const safeUser = this.sanitizeUser(user);
12914
+ resolveEmailTemplate({
12915
+ config,
12916
+ collection: this.collection,
12917
+ purpose: "welcome",
12918
+ args: {
12919
+ email: inviteeEmail,
12920
+ user: safeUser
12921
+ },
12922
+ db
12923
+ }).then(
12924
+ (emailResult) => sendEmail(
12925
+ config,
12926
+ toOutboundEmail(inviteeEmail, emailResult, {
12927
+ collection: this.collection.slug,
12928
+ purpose: "welcome",
12929
+ user: safeUser
12930
+ })
12931
+ )
12932
+ ).catch(
12349
12933
  (err) => getRequestLogger(c, "auth").error({
12350
12934
  err,
12351
12935
  msg: "Failed to send welcome email",
12352
12936
  email: inviteeEmail
12353
12937
  })
12354
12938
  );
12355
- const safeUser = this.sanitizeUser(user);
12356
12939
  return c.json({ token: sessionToken, user: safeUser }, 201);
12357
12940
  }
12941
+ // ---------------------------------------------------------------------------
12942
+ // GET /tokens/verify?token=...&purpose=invite|reset
12943
+ // Public. Validates token signature, expiration, database existence, and state.
12944
+ // ---------------------------------------------------------------------------
12945
+ async verifyToken(c) {
12946
+ const db = c.get("config").db;
12947
+ if (!db) return c.json({ message: "Database not configured" }, 500);
12948
+ const token = c.req.query("token");
12949
+ const purpose = c.req.query("purpose");
12950
+ if (!token) {
12951
+ return c.json(
12952
+ { valid: false, code: "TOKEN_REQUIRED", message: "Token is required." },
12953
+ 400
12954
+ );
12955
+ }
12956
+ let payload;
12957
+ try {
12958
+ payload = await verifyCollectionToken(token);
12959
+ } catch {
12960
+ return c.json(
12961
+ {
12962
+ valid: false,
12963
+ code: "TOKEN_EXPIRED_OR_INVALID",
12964
+ message: "Token is invalid or has expired."
12965
+ },
12966
+ 200
12967
+ );
12968
+ }
12969
+ if (payload.collection !== this.collection.slug) {
12970
+ return c.json(
12971
+ {
12972
+ valid: false,
12973
+ code: "COLLECTION_MISMATCH",
12974
+ message: "Token does not belong to this collection."
12975
+ },
12976
+ 200
12977
+ );
12978
+ }
12979
+ if (purpose && payload.purpose !== purpose) {
12980
+ return c.json(
12981
+ {
12982
+ valid: false,
12983
+ code: "PURPOSE_MISMATCH",
12984
+ message: `Token was issued for '${payload.purpose}', not '${purpose}'.`
12985
+ },
12986
+ 200
12987
+ );
12988
+ }
12989
+ const currentSiteId = c.get("siteId");
12990
+ if (payload.siteId && currentSiteId && payload.siteId !== currentSiteId) {
12991
+ return c.json(
12992
+ {
12993
+ valid: false,
12994
+ code: "SITE_MISMATCH",
12995
+ message: "Token is not valid for this site."
12996
+ },
12997
+ 200
12998
+ );
12999
+ }
13000
+ if (payload.purpose === "invite") {
13001
+ const result = await db.find({
13002
+ collection: this.collection.slug,
13003
+ where: { email: payload.email ?? payload.sub },
13004
+ limit: 1
13005
+ });
13006
+ const user = result.docs[0];
13007
+ if (!user) {
13008
+ return c.json(
13009
+ {
13010
+ valid: false,
13011
+ code: "USER_NOT_FOUND",
13012
+ message: "Invited account was not found."
13013
+ },
13014
+ 200
13015
+ );
13016
+ }
13017
+ if (user._deleted || user.deletedAt) {
13018
+ return c.json(
13019
+ {
13020
+ valid: false,
13021
+ code: "USER_DELETED",
13022
+ message: "Account has been deleted."
13023
+ },
13024
+ 200
13025
+ );
13026
+ }
13027
+ if (user.status !== "pending" && hasUsablePassword(user.password)) {
13028
+ return c.json(
13029
+ {
13030
+ valid: false,
13031
+ code: "INVITE_ALREADY_ACCEPTED",
13032
+ message: "This invitation has already been accepted."
13033
+ },
13034
+ 200
13035
+ );
13036
+ }
13037
+ if (payload.iat && typeof user.invitedAt === "number" && payload.iat * 1e3 < user.invitedAt - 2e3) {
13038
+ return c.json(
13039
+ {
13040
+ valid: false,
13041
+ code: "INVITE_SUPERSEDED",
13042
+ message: "This invitation link has been superseded by a newer one."
13043
+ },
13044
+ 200
13045
+ );
13046
+ }
13047
+ return c.json({
13048
+ valid: true,
13049
+ email: user.email,
13050
+ collection: this.collection.slug
13051
+ });
13052
+ }
13053
+ if (payload.purpose === "reset") {
13054
+ const result = await db.find({
13055
+ collection: this.collection.slug,
13056
+ where: { id: payload.sub },
13057
+ limit: 1
13058
+ });
13059
+ const user = result.docs[0];
13060
+ if (!user || user._deleted || user.deletedAt) {
13061
+ return c.json(
13062
+ {
13063
+ valid: false,
13064
+ code: "USER_NOT_FOUND",
13065
+ message: "Account was not found."
13066
+ },
13067
+ 200
13068
+ );
13069
+ }
13070
+ return c.json({
13071
+ valid: true,
13072
+ email: user.email,
13073
+ collection: this.collection.slug
13074
+ });
13075
+ }
13076
+ return c.json({
13077
+ valid: true,
13078
+ email: payload.email,
13079
+ collection: this.collection.slug
13080
+ });
13081
+ }
12358
13082
  };
12359
13083
 
12360
13084
  // src/controllers/admin-auth.controller.ts
12361
- var import_node_crypto4 = require("crypto");
13085
+ var import_node_crypto3 = require("crypto");
12362
13086
  var import_node_util2 = require("util");
12363
13087
  var import_jose2 = require("jose");
12364
13088
  var AdminAuthController = class {
@@ -12568,7 +13292,7 @@ var AdminAuthController = class {
12568
13292
  access.roles,
12569
13293
  access.data
12570
13294
  ),
12571
- password: await hashPassword((0, import_node_crypto4.randomBytes)(32).toString("hex"))
13295
+ password: await hashPassword((0, import_node_crypto3.randomBytes)(32).toString("hex"))
12572
13296
  }
12573
13297
  });
12574
13298
  } else if (user) {
@@ -12655,7 +13379,7 @@ var AdminAuthController = class {
12655
13379
  }
12656
13380
  async buildOidcStartUrl(provider, returnTo, siteId, origin) {
12657
13381
  const discovery = await this.getOidcDiscovery(provider);
12658
- const nonce = (0, import_node_crypto4.randomBytes)(16).toString("hex");
13382
+ const nonce = (0, import_node_crypto3.randomBytes)(16).toString("hex");
12659
13383
  const state = await this.signState({
12660
13384
  providerId: provider.id,
12661
13385
  returnTo,
@@ -15478,6 +16202,31 @@ function generateOpenApi(config) {
15478
16202
  spec.paths[`${path}/accept-invite`] = {
15479
16203
  post: publicAuthPost("Accept an invitation")
15480
16204
  };
16205
+ spec.paths[`${path}/tokens/verify`] = {
16206
+ get: {
16207
+ tags: [collectionTag],
16208
+ summary: `Verify an invite or password reset token`,
16209
+ parameters: [
16210
+ {
16211
+ name: "token",
16212
+ in: "query",
16213
+ required: true,
16214
+ schema: { type: "string" }
16215
+ },
16216
+ {
16217
+ name: "purpose",
16218
+ in: "query",
16219
+ required: false,
16220
+ schema: { type: "string", enum: ["invite", "reset"] }
16221
+ }
16222
+ ],
16223
+ responses: {
16224
+ 200: {
16225
+ description: "Token verification result"
16226
+ }
16227
+ }
16228
+ }
16229
+ };
15481
16230
  spec.paths[`${path}/{id}/change-password`] = {
15482
16231
  post: {
15483
16232
  tags: [collectionTag],
@@ -16088,7 +16837,7 @@ function registerRoutes(app, config) {
16088
16837
  return resolveBooleanAccess(requestConfig, access, accessArgs);
16089
16838
  };
16090
16839
  const filteredCollections = await Promise.all(
16091
- collections.filter((col) => !col.slug.startsWith("__") && (!siteId || col.shared || !col.siteId || col.siteId === siteId)).map(async (col) => ({
16840
+ collections.filter((col) => !INTERNAL_SYSTEM_COLLECTIONS.has(col.slug) && (!siteId || col.shared || !col.siteId || col.siteId === siteId)).map(async (col) => ({
16092
16841
  slug: col.slug,
16093
16842
  labels: col.labels,
16094
16843
  shared: !!col.shared,
@@ -16230,7 +16979,7 @@ function registerRoutes(app, config) {
16230
16979
  model: effectiveAi?.model
16231
16980
  },
16232
16981
  trash: {
16233
- enabled: collections.some((col) => !col.slug.startsWith("__") && resolveTrashConfig(col, requestConfig).enabled),
16982
+ enabled: collections.some((col) => !INTERNAL_SYSTEM_COLLECTIONS.has(col.slug) && resolveTrashConfig(col, requestConfig).enabled),
16234
16983
  retentionDays: requestConfig.trash?.retentionDays !== void 0 ? requestConfig.trash.retentionDays : 30,
16235
16984
  allowPermanentDelete: requestConfig.trash?.allowPermanentDelete !== false
16236
16985
  },
@@ -16241,7 +16990,7 @@ function registerRoutes(app, config) {
16241
16990
  uploadCollectionConfigured: requestConfig.collections.some((collection) => !!collection.upload),
16242
16991
  trashPurgeOverdue: await (async () => {
16243
16992
  const hasRetention = collections.some(
16244
- (col) => !col.slug.startsWith("__") && resolveTrashConfig(col, requestConfig).retentionDays !== null
16993
+ (col) => !INTERNAL_SYSTEM_COLLECTIONS.has(col.slug) && resolveTrashConfig(col, requestConfig).retentionDays !== null
16245
16994
  );
16246
16995
  if (!hasRetention || !requestConfig.db) return false;
16247
16996
  try {
@@ -16879,6 +17628,7 @@ function registerRoutes(app, config) {
16879
17628
  app.post(`${path}/reset-password`, (c) => authController.resetPassword(c));
16880
17629
  app.post(`${path}/invite`, requireAuth(config), (c) => authController.invite(c));
16881
17630
  app.post(`${path}/accept-invite`, (c) => authController.acceptInvite(c));
17631
+ app.get(`${path}/tokens/verify`, (c) => authController.verifyToken(c));
16882
17632
  }
16883
17633
  const auditController = new AuditController();
16884
17634
  for (const collection of config.collections) {
@@ -16923,6 +17673,126 @@ function registerRoutes(app, config) {
16923
17673
  app.get(path, (c) => controller.get(c));
16924
17674
  app.patch(path, (c) => controller.update(c));
16925
17675
  }
17676
+ const testEmailRateLimitMap = /* @__PURE__ */ new Map();
17677
+ const TEST_EMAIL_MAX = 5;
17678
+ const TEST_EMAIL_WINDOW_MS = 6e4;
17679
+ const handleTestEmailDispatch = async (c) => {
17680
+ const user = c.get("user");
17681
+ const adminCol = config.collections.find((col) => col.slug === "__admins") || config.collections.find((col) => col.auth);
17682
+ if (!isUserAdmin(user, adminCol)) {
17683
+ return c.json({ error: true, message: "Forbidden: Admin access required to dispatch test emails" }, 403);
17684
+ }
17685
+ const userId = String(user?.id ?? user?.sub ?? "unknown");
17686
+ const now2 = Date.now();
17687
+ const window2 = (testEmailRateLimitMap.get(userId) ?? []).filter(
17688
+ (ts) => now2 - ts < TEST_EMAIL_WINDOW_MS
17689
+ );
17690
+ if (window2.length >= TEST_EMAIL_MAX) {
17691
+ return c.json(
17692
+ { error: true, message: `Rate limit exceeded: maximum ${TEST_EMAIL_MAX} test emails per minute.` },
17693
+ 429
17694
+ );
17695
+ }
17696
+ window2.push(now2);
17697
+ testEmailRateLimitMap.set(userId, window2);
17698
+ const body = await c.req.json().catch(() => ({}));
17699
+ const {
17700
+ to,
17701
+ subject,
17702
+ html,
17703
+ format = "html",
17704
+ externalTemplateId,
17705
+ purpose = "invite",
17706
+ collectionSlug = "users"
17707
+ } = body;
17708
+ if (!to || typeof to !== "string" || !to.includes("@")) {
17709
+ return c.json({ error: true, message: "A valid recipient email address is required" }, 400);
17710
+ }
17711
+ const siteName = config.admin?.branding?.logoText || "Dyrected";
17712
+ let appUrl = config.admin?.siteUrl;
17713
+ if (!appUrl) {
17714
+ try {
17715
+ appUrl = new URL(c.req.url).origin;
17716
+ } catch {
17717
+ appUrl = "http://localhost:3000";
17718
+ }
17719
+ }
17720
+ const targetCol = config.collections.find((x) => x.slug === collectionSlug);
17721
+ const resolvedCollectionLabel = collectionSlug === "*" ? "Account" : targetCol?.labels?.singular || targetCol?.labels?.plural || (collectionSlug.startsWith("__") ? collectionSlug.slice(2).charAt(0).toUpperCase() + collectionSlug.slice(3) : collectionSlug.charAt(0).toUpperCase() + collectionSlug.slice(1));
17722
+ const mockData = {
17723
+ "{{url}}": `${appUrl}/auth/setup-password?token=mock_test_token_${Date.now()}`,
17724
+ "{{token}}": `mock_test_token_${Date.now()}`,
17725
+ "{{email}}": to,
17726
+ "{{collectionLabel}}": resolvedCollectionLabel,
17727
+ "{{siteName}}": siteName,
17728
+ "{{user.name}}": typeof user?.name === "string" ? user.name : "Test Recipient",
17729
+ "{{user.first_name}}": typeof user?.first_name === "string" ? user.first_name : "Test",
17730
+ "{{user.email}}": to
17731
+ };
17732
+ try {
17733
+ let result;
17734
+ if (format === "external_template") {
17735
+ if (!externalTemplateId) {
17736
+ return c.json({ error: true, message: "External template ID is required when format is external_template" }, 400);
17737
+ }
17738
+ const variables = {
17739
+ email: to,
17740
+ url: `${appUrl}/auth/setup-password?token=mock_test_token_${Date.now()}`,
17741
+ token: `mock_test_token_${Date.now()}`,
17742
+ collection: collectionSlug,
17743
+ collectionLabel: resolvedCollectionLabel,
17744
+ siteName,
17745
+ user: {
17746
+ name: typeof user?.name === "string" ? user.name : "Test Recipient",
17747
+ first_name: typeof user?.first_name === "string" ? user.first_name : "Test",
17748
+ email: to
17749
+ }
17750
+ };
17751
+ let testSubject = subject || `[TEST] ${purpose} email`;
17752
+ for (const [tag, sample] of Object.entries(mockData)) {
17753
+ testSubject = testSubject.split(tag).join(sample);
17754
+ }
17755
+ result = await sendEmail(config, {
17756
+ to,
17757
+ type: "template",
17758
+ template: externalTemplateId,
17759
+ variables,
17760
+ subject: testSubject,
17761
+ purpose,
17762
+ collection: collectionSlug
17763
+ });
17764
+ } else {
17765
+ let finalSubject = subject || `[TEST] ${purpose} email`;
17766
+ let finalHtml = html || "";
17767
+ for (const [tag, sample] of Object.entries(mockData)) {
17768
+ finalSubject = finalSubject.split(tag).join(sample);
17769
+ finalHtml = finalHtml.split(tag).join(sample);
17770
+ }
17771
+ result = await sendEmail(config, {
17772
+ to,
17773
+ type: "html",
17774
+ subject: finalSubject,
17775
+ html: finalHtml,
17776
+ purpose,
17777
+ collection: collectionSlug
17778
+ });
17779
+ }
17780
+ return c.json({
17781
+ success: true,
17782
+ message: `Test email sent to ${to}`,
17783
+ previewUrl: result?.previewUrl
17784
+ });
17785
+ } catch (err) {
17786
+ getConfigLogger(config, "email").error({
17787
+ err,
17788
+ msg: "Failed to dispatch test email",
17789
+ to
17790
+ });
17791
+ return c.json({ error: true, message: err.message || "Failed to send test email" }, 500);
17792
+ }
17793
+ };
17794
+ app.post("/api/__email_templates/test", requireAuth(config), handleTestEmailDispatch);
17795
+ app.post("/api/email-templates/test", requireAuth(config), handleTestEmailDispatch);
16926
17796
  if (!process.env.DYRECTED_JWT_SECRET) {
16927
17797
  getConfigLogger(config, "router").warn({
16928
17798
  msg: "DYRECTED_JWT_SECRET is not set; token-mode live preview is signing with an insecure default"
@@ -17377,6 +18247,114 @@ var AUTH_SESSIONS_COLLECTION_CONFIG = {
17377
18247
  },
17378
18248
  admin: { hidden: true }
17379
18249
  };
18250
+ var EMAIL_TEMPLATES_COLLECTION_SLUG = "__email_templates";
18251
+ var EMAIL_TEMPLATES_COLLECTION_CONFIG = {
18252
+ slug: EMAIL_TEMPLATES_COLLECTION_SLUG,
18253
+ labels: { singular: "Email Template", plural: "Email Templates" },
18254
+ detail: false,
18255
+ fields: [
18256
+ {
18257
+ name: "collectionSlug",
18258
+ type: "select",
18259
+ label: "Applies To",
18260
+ required: true,
18261
+ defaultValue: "*",
18262
+ options: [
18263
+ { label: "Global (All Users & Collections)", value: "*" }
18264
+ ],
18265
+ admin: {
18266
+ description: "Choose which user collection this template applies to, or 'Global' for all users."
18267
+ }
18268
+ },
18269
+ {
18270
+ name: "purpose",
18271
+ type: "select",
18272
+ label: "Purpose",
18273
+ required: true,
18274
+ options: [
18275
+ { label: "Invite", value: "invite" },
18276
+ { label: "Password Reset", value: "resetPassword" },
18277
+ { label: "Welcome", value: "welcome" },
18278
+ { label: "Password Changed", value: "passwordChanged" }
18279
+ ],
18280
+ admin: { width: "50%" }
18281
+ },
18282
+ {
18283
+ name: "format",
18284
+ type: "select",
18285
+ label: "Format",
18286
+ required: true,
18287
+ defaultValue: "html",
18288
+ options: [
18289
+ { label: "HTML", value: "html" },
18290
+ { label: "External Template", value: "external_template" }
18291
+ ],
18292
+ admin: { width: "50%" }
18293
+ },
18294
+ {
18295
+ name: "subject",
18296
+ type: "text",
18297
+ label: "Subject",
18298
+ admin: {
18299
+ description: "Subject line (optional when using external provider template)"
18300
+ }
18301
+ },
18302
+ {
18303
+ name: "rawHtml",
18304
+ type: "textarea",
18305
+ label: "HTML Content",
18306
+ admin: {
18307
+ description: "Raw HTML email content with {{variable}} tags"
18308
+ }
18309
+ },
18310
+ {
18311
+ name: "externalTemplateId",
18312
+ type: "text",
18313
+ label: "External Template ID",
18314
+ admin: {
18315
+ description: "Template ID or alias from Seamailer, Postmark, SendGrid, etc."
18316
+ }
18317
+ },
18318
+ {
18319
+ name: "active",
18320
+ type: "boolean",
18321
+ label: "Active",
18322
+ defaultValue: true,
18323
+ admin: {
18324
+ description: "When active, this template overrides code config. When disabled, falls back to code."
18325
+ }
18326
+ }
18327
+ ],
18328
+ admin: {
18329
+ group: "Settings",
18330
+ icon: "Mail",
18331
+ groupBy: "collectionSlug",
18332
+ defaultColumns: ["purpose", "collectionSlug", "format", "active"]
18333
+ },
18334
+ hooks: {
18335
+ beforeChange: [
18336
+ async ({ data, req }) => {
18337
+ if (data && data.format === "html") {
18338
+ const siteName = req?.config?.admin?.branding?.logoText || "Dyrected";
18339
+ const defaults = getDefaultEmailTemplate(data.purpose || "invite", { siteName });
18340
+ if (!data.subject) {
18341
+ data.subject = defaults.subject;
18342
+ }
18343
+ if (!data.rawHtml) {
18344
+ data.rawHtml = defaults.rawHtml;
18345
+ }
18346
+ }
18347
+ return data;
18348
+ }
18349
+ ]
18350
+ },
18351
+ access: {
18352
+ read: ({ user }) => isUserAdmin(user),
18353
+ create: ({ user }) => isUserAdmin(user),
18354
+ update: ({ user }) => isUserAdmin(user),
18355
+ delete: ({ user }) => isUserAdmin(user)
18356
+ }
18357
+ };
17380
18358
  function normalizeConfig(config) {
17381
18359
  const schemaAwareConfig = normalizeSchemaFragment(config);
17382
18360
  assertValidDeclarativeHooksInConfig(schemaAwareConfig, "config");
@@ -17391,10 +18369,13 @@ function normalizeConfig(config) {
17391
18369
  const needsTasks = (config.tasks?.length ?? 0) > 0;
17392
18370
  const needsAuthSessions = collections.some((col) => !!col.auth);
17393
18371
  const needsTrash = schemaAwareConfig.trash?.enabled === true || collections.some((col) => resolveTrashConfig(col, schemaAwareConfig).enabled);
17394
- const adminAuthCollectionSlug = getAdminAuthCollection({
18372
+ const adminAuthCollection = getAdminAuthCollection({
17395
18373
  collections,
17396
18374
  adminAuth: schemaAwareConfig.adminAuth
17397
- })?.slug;
18375
+ });
18376
+ const adminAuthCollectionSlug = adminAuthCollection?.slug;
18377
+ const adminOnly = ({ user }) => isUserAdmin(user, adminAuthCollection);
18378
+ const adminOtherThanSelf = ({ user, id }) => isUserAdmin(user, adminAuthCollection) && user?.id !== id;
17398
18379
  const normalizedCollections = collections.map((col) => {
17399
18380
  let fields = col.fields || [];
17400
18381
  const existingFieldNames = new Set(fields.map((f) => f.name));
@@ -17443,7 +18424,7 @@ function normalizeConfig(config) {
17443
18424
  { value: "viewer", label: "Viewer" }
17444
18425
  ],
17445
18426
  access: {
17446
- update: "user.role == 'admin' || (user.roles != null && 'admin' in user.roles)"
18427
+ update: adminOnly
17447
18428
  }
17448
18429
  }
17449
18430
  ];
@@ -17461,7 +18442,7 @@ function normalizeConfig(config) {
17461
18442
  { value: "pending", label: "Pending" }
17462
18443
  ],
17463
18444
  access: {
17464
- update: "(user.role == 'admin' || (user.roles != null && 'admin' in user.roles)) && user.id != id"
18445
+ update: adminOtherThanSelf
17465
18446
  },
17466
18447
  admin: {
17467
18448
  condition: `!(data.roles && "admin" in data.roles) && data.role != "admin"`
@@ -17530,7 +18511,7 @@ function normalizeConfig(config) {
17530
18511
  access: {
17531
18512
  ...field.access || {},
17532
18513
  // Must be an admin; cannot edit own roles (no self-elevation).
17533
- update: "(user.role == 'admin' || (user.roles != null && 'admin' in user.roles)) && user.id != id"
18514
+ update: adminOtherThanSelf
17534
18515
  }
17535
18516
  };
17536
18517
  }
@@ -17539,7 +18520,7 @@ function normalizeConfig(config) {
17539
18520
  ...field,
17540
18521
  access: {
17541
18522
  ...field.access || {},
17542
- update: "(user.role == 'admin' || (user.roles != null && 'admin' in user.roles)) && user.id != id"
18523
+ update: adminOtherThanSelf
17543
18524
  }
17544
18525
  };
17545
18526
  }
@@ -17631,6 +18612,52 @@ function normalizeConfig(config) {
17631
18612
  if (needsTrash && !normalizedCollections.some((col) => col.slug === TRASH_COLLECTION)) {
17632
18613
  systemCollections.push(TRASH_COLLECTION_CONFIG);
17633
18614
  }
18615
+ const needsEmailTemplates = schemaAwareConfig.email?.adminEditable === true;
18616
+ if (needsEmailTemplates && !normalizedCollections.some((col) => col.slug === EMAIL_TEMPLATES_COLLECTION_SLUG)) {
18617
+ const adminCol = getAdminAuthCollection(schemaAwareConfig);
18618
+ const customAccess = schemaAwareConfig.email?.access;
18619
+ const resolveOpAccess = (op) => {
18620
+ if (typeof customAccess === "function") return customAccess;
18621
+ if (customAccess && typeof customAccess === "object" && customAccess[op]) return customAccess[op];
18622
+ return ({ user }) => isUserAdmin(user, adminCol);
18623
+ };
18624
+ const customPurposes = schemaAwareConfig.email?.purposes ?? [];
18625
+ const purposeOptions = [
18626
+ { label: "Invite", value: "invite" },
18627
+ { label: "Password Reset", value: "resetPassword" },
18628
+ { label: "Welcome", value: "welcome" },
18629
+ { label: "Password Changed", value: "passwordChanged" },
18630
+ ...customPurposes
18631
+ ];
18632
+ const targetableCollections = normalizedCollections.filter(
18633
+ (c) => !c.slug.startsWith("__")
18634
+ );
18635
+ const audienceOptions = [
18636
+ { label: "Global (All Collections)", value: "*" },
18637
+ ...targetableCollections.map((c) => ({
18638
+ label: `${c.labels?.singular || c.labels?.plural || c.slug} (${c.slug})`,
18639
+ value: c.slug
18640
+ }))
18641
+ ];
18642
+ systemCollections.push({
18643
+ ...EMAIL_TEMPLATES_COLLECTION_CONFIG,
18644
+ fields: EMAIL_TEMPLATES_COLLECTION_CONFIG.fields.map((f) => {
18645
+ if (f.name === "collectionSlug") {
18646
+ return { ...f, options: audienceOptions };
18647
+ }
18648
+ if (f.name === "purpose") {
18649
+ return { ...f, options: purposeOptions };
18650
+ }
18651
+ return f;
18652
+ }),
18653
+ access: {
18654
+ read: resolveOpAccess("read"),
18655
+ create: resolveOpAccess("create"),
18656
+ update: resolveOpAccess("update"),
18657
+ delete: resolveOpAccess("delete")
18658
+ }
18659
+ });
18660
+ }
17634
18661
  return {
17635
18662
  ...schemaAwareConfig,
17636
18663
  collections: [...normalizedCollections, ...systemCollections],
@@ -17945,8 +18972,10 @@ init_embedding_service();
17945
18972
  AUTH_SESSIONS_COLLECTION,
17946
18973
  AuthController,
17947
18974
  CollectionController,
18975
+ DEFAULT_INVITE_EXPIRY,
17948
18976
  DEFAULT_LOCK_TIME_MS,
17949
18977
  DEFAULT_MAX_LOGIN_ATTEMPTS,
18978
+ DEFAULT_RESET_PASSWORD_EXPIRY,
17950
18979
  DEFAULT_SESSION_EXPIRY,
17951
18980
  EmbeddingService,
17952
18981
  GlobalController,
@@ -17978,13 +19007,16 @@ init_embedding_service();
17978
19007
  getAIModel,
17979
19008
  getAuthSession,
17980
19009
  getConfigLogger,
19010
+ getDbEmailTemplateOverride,
17981
19011
  getEmbeddingModel,
17982
19012
  getLockedUntilMs,
17983
19013
  getObservabilityRuntime,
17984
19014
  getRequestLogger,
17985
19015
  hasEmbeddingApiKey,
19016
+ hasUsablePassword,
17986
19017
  hashContent,
17987
19018
  hashPassword,
19019
+ interpolateVariables,
17988
19020
  isAuthSessionActive,
17989
19021
  issueAuthSessionToken,
17990
19022
  normalizeFieldValue,
@@ -17993,7 +19025,10 @@ init_embedding_service();
17993
19025
  registerRoutes,
17994
19026
  renderPrometheusMetrics,
17995
19027
  resolveAuthLockoutConfig,
19028
+ resolveEmailTemplate,
19029
+ resolveInviteTokenExpiry,
17996
19030
  resolveObservabilityConfig,
19031
+ resolveResetPasswordTokenExpiry,
17997
19032
  resolveSessionTokenExpiry,
17998
19033
  revokeAllAuthSessions,
17999
19034
  revokeAuthSession,
@@ -18003,6 +19038,7 @@ init_embedding_service();
18003
19038
  serializeViewForApi,
18004
19039
  shouldSampleRequest,
18005
19040
  signCollectionToken,
19041
+ toOutboundEmail,
18006
19042
  touchAuthSession,
18007
19043
  validatePayloadAgainstFields,
18008
19044
  verifyCollectionToken,