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