@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.js CHANGED
@@ -3061,7 +3061,7 @@ function assertValidTrashInConfig(config, source = "config") {
3061
3061
  if (col.slug === "trash") {
3062
3062
  throw new Error(`Collection slug "trash" is reserved by Dyrected for the trash system.`);
3063
3063
  }
3064
- const isSystem = col.slug.startsWith("__");
3064
+ const isSystem = INTERNAL_SYSTEM_COLLECTIONS.has(col.slug);
3065
3065
  if (isSystem && col.trash) {
3066
3066
  throw new Error(`System collection "${col.slug}" cannot enable trash.`);
3067
3067
  }
@@ -3168,12 +3168,20 @@ async function restoreDocument(options) {
3168
3168
  }
3169
3169
  return { restoredDoc: docToRestore };
3170
3170
  }
3171
- var TRASH_COLLECTION, TRASH_COLLECTION_CONFIG;
3171
+ var TRASH_COLLECTION, INTERNAL_SYSTEM_COLLECTIONS, TRASH_COLLECTION_CONFIG;
3172
3172
  var init_trash = __esm({
3173
3173
  "src/trash.ts"() {
3174
3174
  "use strict";
3175
3175
  init_hooks();
3176
3176
  TRASH_COLLECTION = "__trash";
3177
+ INTERNAL_SYSTEM_COLLECTIONS = /* @__PURE__ */ new Set([
3178
+ "__audit",
3179
+ "__sessions",
3180
+ "__task_locks",
3181
+ "__trash",
3182
+ "__workflow_history",
3183
+ "__lifecycle_events"
3184
+ ]);
3177
3185
  TRASH_COLLECTION_CONFIG = {
3178
3186
  slug: TRASH_COLLECTION,
3179
3187
  labels: { singular: "Trash entry", plural: "Trash entries" },
@@ -6236,13 +6244,27 @@ async function hashPassword(plain) {
6236
6244
  const derivedKey = await scryptAsync(plain, salt, KEY_LEN);
6237
6245
  return `${salt}:${derivedKey.toString("hex")}`;
6238
6246
  }
6247
+ function hasUsablePassword(stored) {
6248
+ if (typeof stored !== "string" || !stored) return false;
6249
+ const parts = stored.split(":");
6250
+ return parts.length === 2 && Boolean(parts[0]) && Boolean(parts[1]);
6251
+ }
6239
6252
  async function verifyPassword(plain, stored) {
6240
- const [salt, storedHash] = stored.split(":");
6253
+ if (typeof plain !== "string" || !plain || typeof stored !== "string" || !stored) {
6254
+ return false;
6255
+ }
6256
+ const parts = stored.split(":");
6257
+ if (parts.length !== 2) return false;
6258
+ const [salt, storedHash] = parts;
6241
6259
  if (!salt || !storedHash) return false;
6242
- const derivedKey = await scryptAsync(plain, salt, KEY_LEN);
6243
- const storedBuffer = Buffer.from(storedHash, "hex");
6244
- if (derivedKey.length !== storedBuffer.length) return false;
6245
- return timingSafeEqual(derivedKey, storedBuffer);
6260
+ try {
6261
+ const derivedKey = await scryptAsync(plain, salt, KEY_LEN);
6262
+ const storedBuffer = Buffer.from(storedHash, "hex");
6263
+ if (derivedKey.length !== storedBuffer.length) return false;
6264
+ return timingSafeEqual(derivedKey, storedBuffer);
6265
+ } catch {
6266
+ return false;
6267
+ }
6246
6268
  }
6247
6269
 
6248
6270
  // src/auth/token.ts
@@ -6258,6 +6280,8 @@ function getSecret() {
6258
6280
  }
6259
6281
  var DEFAULT_EXPIRY = "7d";
6260
6282
  var DEFAULT_SESSION_EXPIRY = DEFAULT_EXPIRY;
6283
+ var DEFAULT_INVITE_EXPIRY = "48h";
6284
+ var DEFAULT_RESET_PASSWORD_EXPIRY = "2h";
6261
6285
  function resolveSessionTokenExpiry(collection) {
6262
6286
  const auth = collection.auth;
6263
6287
  if (auth && typeof auth === "object" && typeof auth.tokenExpiration === "string" && auth.tokenExpiration.length > 0) {
@@ -6265,6 +6289,20 @@ function resolveSessionTokenExpiry(collection) {
6265
6289
  }
6266
6290
  return DEFAULT_SESSION_EXPIRY;
6267
6291
  }
6292
+ function resolveInviteTokenExpiry(collection) {
6293
+ const auth = collection.auth;
6294
+ if (auth && typeof auth === "object" && typeof auth.inviteExpiration === "string" && auth.inviteExpiration.length > 0) {
6295
+ return auth.inviteExpiration;
6296
+ }
6297
+ return DEFAULT_INVITE_EXPIRY;
6298
+ }
6299
+ function resolveResetPasswordTokenExpiry(collection) {
6300
+ const auth = collection.auth;
6301
+ if (auth && typeof auth === "object" && typeof auth.resetPasswordExpiration === "string" && auth.resetPasswordExpiration.length > 0) {
6302
+ return auth.resetPasswordExpiration;
6303
+ }
6304
+ return DEFAULT_RESET_PASSWORD_EXPIRY;
6305
+ }
6268
6306
  async function signCollectionToken(payload, expiresIn = DEFAULT_EXPIRY) {
6269
6307
  return new SignJWT({ ...payload }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(expiresIn).sign(getSecret());
6270
6308
  }
@@ -6569,13 +6607,34 @@ function getAdminRoleForCollection(collection) {
6569
6607
  }
6570
6608
  return "admin";
6571
6609
  }
6610
+ var STANDARD_ADMIN_ROLES = /* @__PURE__ */ new Set([
6611
+ "admin",
6612
+ "super_admin",
6613
+ "superadmin",
6614
+ "super admin",
6615
+ "super-admin",
6616
+ "owner"
6617
+ ]);
6572
6618
  function isUserAdmin(user, collection) {
6573
6619
  if (!user) return false;
6574
- const roles = Array.isArray(user.roles) ? user.roles : typeof user.role === "string" ? [user.role] : [];
6575
- if (roles.length === 0) return false;
6620
+ 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] : [];
6621
+ if (rawRoles.length === 0) return false;
6622
+ const roles = rawRoles.filter((r) => typeof r === "string");
6576
6623
  const configuredRole = getAdminRoleForCollection(collection);
6577
- if (roles.includes(configuredRole)) return true;
6578
- return roles.includes("admin") || roles.includes("super_admin");
6624
+ for (const r of roles) {
6625
+ if (r === configuredRole || r.toLowerCase() === configuredRole.toLowerCase()) {
6626
+ return true;
6627
+ }
6628
+ const normalized = r.trim().toLowerCase();
6629
+ if (STANDARD_ADMIN_ROLES.has(normalized)) {
6630
+ return true;
6631
+ }
6632
+ const stripped = normalized.replace(/[\s_-]+/g, "");
6633
+ if (stripped === "admin" || stripped === "superadmin" || stripped === "owner") {
6634
+ return true;
6635
+ }
6636
+ }
6637
+ return false;
6579
6638
  }
6580
6639
 
6581
6640
  // src/utils/collection-search.ts
@@ -7224,7 +7283,7 @@ function compileNavigation(config) {
7224
7283
  const mentionedTrash = explicitNav.some((i) => i.trash || i.slug === "trash");
7225
7284
  if (!mentionedTrash) {
7226
7285
  const hasTrash = collections.some(
7227
- (col) => !col.slug.startsWith("__") && resolveTrashConfig(col, config).enabled
7286
+ (col) => !INTERNAL_SYSTEM_COLLECTIONS.has(col.slug) && resolveTrashConfig(col, config).enabled
7228
7287
  );
7229
7288
  if (hasTrash) {
7230
7289
  registerItem(void 0, {
@@ -11383,9 +11442,16 @@ function resolveAdminUrl(c, config, clientUrl) {
11383
11442
  function appendQueryParam(url, key, value) {
11384
11443
  return `${url}${url.includes("?") ? "&" : "?"}${key}=${encodeURIComponent(value)}`;
11385
11444
  }
11386
-
11387
- // src/controllers/auth.controller.ts
11388
- import { randomBytes as randomBytes3 } from "crypto";
11445
+ function resolveActionUrl(c, config, customUrl) {
11446
+ if (customUrl && typeof customUrl === "string" && customUrl.trim().length > 0) {
11447
+ try {
11448
+ return new URL(customUrl.trim(), c.req.url).toString();
11449
+ } catch {
11450
+ return customUrl.trim();
11451
+ }
11452
+ }
11453
+ return resolveAdminUrl(c, config);
11454
+ }
11389
11455
 
11390
11456
  // src/auth/lockout.ts
11391
11457
  var DEFAULT_MAX_LOGIN_ATTEMPTS = 5;
@@ -11442,15 +11508,21 @@ var emailTokens = {
11442
11508
  width: "600px"
11443
11509
  };
11444
11510
  function escapeHtml(value) {
11445
- return value.replace(/[&<>'"]/g, (character) => ({
11446
- "&": "&amp;",
11447
- "<": "&lt;",
11448
- ">": "&gt;",
11449
- "'": "&#39;",
11450
- '"': "&quot;"
11451
- })[character]);
11511
+ return value.replace(
11512
+ /[&<>'"]/g,
11513
+ (character) => ({
11514
+ "&": "&amp;",
11515
+ "<": "&lt;",
11516
+ ">": "&gt;",
11517
+ "'": "&#39;",
11518
+ '"': "&quot;"
11519
+ })[character]
11520
+ );
11452
11521
  }
11453
11522
  function safeHttpUrl(value) {
11523
+ if (value.startsWith("{{") && value.endsWith("}}")) {
11524
+ return value;
11525
+ }
11454
11526
  try {
11455
11527
  const url = new URL(value);
11456
11528
  return url.protocol === "https:" || url.protocol === "http:" ? escapeHtml(url.toString()) : void 0;
@@ -11461,8 +11533,8 @@ function safeHttpUrl(value) {
11461
11533
  function heading(content) {
11462
11534
  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>`;
11463
11535
  }
11464
- function paragraph(content, margin = "0 0 16px") {
11465
- return `<p style="margin:${margin};font-family:${emailTokens.font};font-size:15px;line-height:1.6;color:${emailTokens.colors.muted}">${escapeHtml(content)}</p>`;
11536
+ function paragraph(content, margin = "0 0 16px", size = "15px") {
11537
+ return `<p style="margin:${margin};font-family:${emailTokens.font};font-size:${size};line-height:1.6;color:${emailTokens.colors.muted}">${escapeHtml(content)}</p>`;
11466
11538
  }
11467
11539
  function sectionLabel(content) {
11468
11540
  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>`;
@@ -11492,10 +11564,13 @@ function detailBox(content, monospace = false) {
11492
11564
  function ctaButton(label, href) {
11493
11565
  const safeHref = safeHttpUrl(href);
11494
11566
  if (!safeHref) return "";
11495
- return table(row(
11496
- `<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>`,
11497
- "padding:8px 0 24px"
11498
- ), "width:auto");
11567
+ return table(
11568
+ row(
11569
+ `<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>`,
11570
+ "padding:8px 0 24px"
11571
+ ),
11572
+ "width:auto"
11573
+ );
11499
11574
  }
11500
11575
  function safeLinkDetailBox(href) {
11501
11576
  const safeHref = safeHttpUrl(href);
@@ -11503,9 +11578,33 @@ function safeLinkDetailBox(href) {
11503
11578
  return detailBox(safeHref, true);
11504
11579
  }
11505
11580
  function alertBox(content) {
11506
- 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}`);
11581
+ return table(
11582
+ row(
11583
+ escapeHtml(content),
11584
+ `padding:13px 16px;font-family:${emailTokens.font};font-size:13px;line-height:1.5;color:${emailTokens.colors.dangerText}`
11585
+ ),
11586
+ `width:100%;background:${emailTokens.colors.dangerSurface};border:1px solid ${emailTokens.colors.dangerBorder};border-radius:${emailTokens.radius.control}`
11587
+ );
11507
11588
  }
11508
- function layout({ preheader, title, content, footer }) {
11589
+ function resolveBlocks(input) {
11590
+ if (Array.isArray(input)) {
11591
+ const parts = [];
11592
+ for (const item of input) {
11593
+ if (Array.isArray(item)) {
11594
+ for (const sub of item) {
11595
+ if (typeof sub === "string" && sub.length > 0) parts.push(sub);
11596
+ }
11597
+ } else if (typeof item === "string" && item.length > 0) {
11598
+ parts.push(item);
11599
+ }
11600
+ }
11601
+ return parts.join("\n");
11602
+ }
11603
+ return typeof input === "string" ? input : "";
11604
+ }
11605
+ function layout({ preheader, title, content, footer, siteName }) {
11606
+ const resolvedContent = resolveBlocks(content);
11607
+ const resolvedFooter = resolveBlocks(footer);
11509
11608
  return `<!doctype html>
11510
11609
  <html lang="en" dir="ltr" xmlns="http://www.w3.org/1999/xhtml">
11511
11610
  <head>
@@ -11526,8 +11625,8 @@ function layout({ preheader, title, content, footer }) {
11526
11625
  ${table(
11527
11626
  row(
11528
11627
  table(
11529
- 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(
11530
- `${divider()}${paragraph(footer, "20px 0 6px")}${paragraph("Privacy: this message contains account-related information; please avoid forwarding it.", "0")}`,
11628
+ 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(
11629
+ `${divider()}${paragraph(resolvedFooter, "20px 0 6px")}${paragraph("Privacy: this message contains account-related information; please avoid forwarding it.", "10px 0px 0px 0px", "12px")}`,
11531
11630
  "padding:0 32px 28px"
11532
11631
  ),
11533
11632
  `width:100%;max-width:${emailTokens.width};background:${emailTokens.colors.surface};border:1px solid ${emailTokens.colors.border};border-radius:${emailTokens.radius.card};overflow:hidden`
@@ -11540,6 +11639,81 @@ function layout({ preheader, title, content, footer }) {
11540
11639
  </body>
11541
11640
  </html>`;
11542
11641
  }
11642
+ function getDefaultEmailTemplate(purpose, options) {
11643
+ const siteName = options?.siteName ?? "Dyrected";
11644
+ switch (purpose) {
11645
+ case "invite":
11646
+ return {
11647
+ subject: `You've been invited to ${siteName}`,
11648
+ rawHtml: layout({
11649
+ siteName,
11650
+ preheader: `You've been invited to join ${siteName}.`,
11651
+ title: "You've been invited",
11652
+ content: [
11653
+ paragraph("Use the invitation link below to create your account. The link expires in 7 days."),
11654
+ ctaButton("Accept invitation", "{{url}}"),
11655
+ sectionLabel("Invitation link"),
11656
+ safeLinkDetailBox("{{url}}"),
11657
+ paragraph("If the button does not work, copy and paste the link above into your browser.", "12px 0 0")
11658
+ ],
11659
+ footer: "If you weren't expecting this invitation, you can safely ignore this email."
11660
+ })
11661
+ };
11662
+ case "resetPassword":
11663
+ return {
11664
+ subject: "Reset your password",
11665
+ rawHtml: layout({
11666
+ siteName,
11667
+ preheader: `Reset your ${siteName} password.`,
11668
+ title: "Reset your password",
11669
+ content: [
11670
+ paragraph("We received a request to reset your password. The reset link expires in 1 hour."),
11671
+ ctaButton("Reset password", "{{url}}"),
11672
+ sectionLabel("Reset link"),
11673
+ safeLinkDetailBox("{{url}}"),
11674
+ paragraph("If the button does not work, copy and paste the link above into your browser.", "12px 0 0")
11675
+ ],
11676
+ footer: "If you didn't request a password reset, you can safely ignore this email."
11677
+ })
11678
+ };
11679
+ case "welcome":
11680
+ return {
11681
+ subject: `Welcome to ${siteName} \u2014 your account is ready`,
11682
+ rawHtml: layout({
11683
+ siteName,
11684
+ preheader: `Your ${siteName} account is ready.`,
11685
+ title: "Welcome \u2014 your account is ready",
11686
+ content: [paragraph("Your account has been created. You can now log in with:"), detailBox("{{email}}")],
11687
+ footer: "If you didn't create this account, you can safely ignore this email."
11688
+ })
11689
+ };
11690
+ case "passwordChanged":
11691
+ return {
11692
+ subject: "Your password has been changed",
11693
+ rawHtml: layout({
11694
+ siteName,
11695
+ preheader: `Your ${siteName} password was changed.`,
11696
+ title: "Password changed",
11697
+ content: [
11698
+ paragraph("The password for your account has been changed."),
11699
+ alertBox("If you did not make this change, please contact support or reset your password immediately.")
11700
+ ],
11701
+ footer: "Security notice: this confirms a recent credential change."
11702
+ })
11703
+ };
11704
+ default:
11705
+ return {
11706
+ subject: `Notification from ${siteName}`,
11707
+ rawHtml: layout({
11708
+ siteName,
11709
+ preheader: `Account notification from ${siteName}.`,
11710
+ title: "Account Notification",
11711
+ content: [paragraph(`This is an account notification from ${siteName}.`)],
11712
+ footer: "If you have any questions, please reach out to our team."
11713
+ })
11714
+ };
11715
+ }
11716
+ }
11543
11717
 
11544
11718
  // src/services/email.service.ts
11545
11719
  var _devSend = null;
@@ -11567,11 +11741,18 @@ async function getDevSend() {
11567
11741
  password: account.pass
11568
11742
  });
11569
11743
  _devSend = async ({ to, subject, html }) => {
11570
- const info = await transport.sendMail({ from: '"Dyrected Dev" <dev@dyrected.local>', to, subject, html });
11744
+ const info = await transport.sendMail({
11745
+ from: '"Dyrected Dev" <dev@dyrected.local>',
11746
+ to,
11747
+ subject,
11748
+ html
11749
+ });
11750
+ const previewUrl = nodemailer.default.getTestMessageUrl(info) || void 0;
11571
11751
  logger.info({
11572
11752
  msg: "Email preview URL",
11573
- previewUrl: nodemailer.default.getTestMessageUrl(info)
11753
+ previewUrl
11574
11754
  });
11755
+ return { previewUrl };
11575
11756
  };
11576
11757
  return _devSend;
11577
11758
  } catch {
@@ -11597,69 +11778,286 @@ async function sendEmail(config, payload) {
11597
11778
  err,
11598
11779
  msg: "Email provider send failed",
11599
11780
  to: payload.to,
11600
- subject: payload.subject
11781
+ subject: "subject" in payload ? payload.subject : void 0,
11782
+ template: "template" in payload ? payload.template : void 0
11601
11783
  });
11602
11784
  throw err;
11603
11785
  }
11604
- return;
11786
+ return { success: true };
11605
11787
  }
11606
11788
  if (process.env.NODE_ENV !== "production") {
11607
11789
  const devSend = await getDevSend();
11608
- await devSend?.(payload);
11790
+ if (devSend) {
11791
+ if ("template" in payload && payload.template) {
11792
+ const devHtml = `
11793
+ <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
11794
+ <h3 style="margin-top:0; color: #1e293b;">External Template Email (Dev Preview)</h3>
11795
+ <p><strong>To:</strong> ${payload.to}</p>
11796
+ <p><strong>Template ID:</strong> <code>${payload.template}</code></p>
11797
+ ${payload.subject ? `<p><strong>Subject:</strong> ${payload.subject}</p>` : ""}
11798
+ <p><strong>Variables:</strong></p>
11799
+ <pre style="background: #f8fafc; padding: 12px; border-radius: 6px; overflow-x: auto;">${JSON.stringify(payload.variables, null, 2)}</pre>
11800
+ </div>
11801
+ `;
11802
+ const res = await devSend({
11803
+ to: payload.to,
11804
+ subject: payload.subject ?? `[Template: ${payload.template}] External Email`,
11805
+ html: devHtml
11806
+ });
11807
+ return { success: true, previewUrl: res?.previewUrl };
11808
+ } else if ("html" in payload) {
11809
+ const res = await devSend({
11810
+ to: payload.to,
11811
+ subject: payload.subject,
11812
+ html: payload.html
11813
+ });
11814
+ return { success: true, previewUrl: res?.previewUrl };
11815
+ }
11816
+ }
11817
+ }
11818
+ return { success: true };
11819
+ }
11820
+ function interpolateVariables(str, args, escapeMode = "html") {
11821
+ if (!str || typeof str !== "string") return str;
11822
+ return str.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, path) => {
11823
+ const parts = path.split(".");
11824
+ let val = args;
11825
+ for (const part of parts) {
11826
+ if (val == null) break;
11827
+ val = val[part];
11828
+ }
11829
+ if (val === void 0 || val === null) return "";
11830
+ const raw = String(val);
11831
+ return escapeMode === "html" ? escapeHtml(raw) : raw;
11832
+ });
11833
+ }
11834
+ function formatDbTemplateDoc(doc, args) {
11835
+ if (doc.format === "external_template" || doc.externalTemplateId) {
11836
+ const rawVars = doc.variables && typeof doc.variables === "object" ? doc.variables : {};
11837
+ const interpolatedVars = { ...args };
11838
+ for (const [key, val] of Object.entries(rawVars)) {
11839
+ if (typeof val === "string") {
11840
+ interpolatedVars[key] = interpolateVariables(val, args);
11841
+ } else {
11842
+ interpolatedVars[key] = val;
11843
+ }
11844
+ }
11845
+ return {
11846
+ template: doc.externalTemplateId ?? "",
11847
+ variables: interpolatedVars,
11848
+ ...doc.subject ? { subject: interpolateVariables(doc.subject, args) } : {}
11849
+ };
11850
+ }
11851
+ return {
11852
+ subject: interpolateVariables(doc.subject || "", args),
11853
+ html: interpolateVariables(doc.rawHtml || "", args)
11854
+ };
11855
+ }
11856
+ async function getDbEmailTemplateOverride(db, collectionSlug, purpose, args) {
11857
+ if (!db || typeof db.find !== "function") return null;
11858
+ try {
11859
+ if (collectionSlug) {
11860
+ const specific = await db.find({
11861
+ collection: "__email_templates",
11862
+ where: {
11863
+ collectionSlug,
11864
+ purpose,
11865
+ active: true
11866
+ },
11867
+ limit: 1
11868
+ });
11869
+ const doc2 = specific?.docs?.[0];
11870
+ if (doc2) {
11871
+ return formatDbTemplateDoc(doc2, args);
11872
+ }
11873
+ }
11874
+ const global = await db.find({
11875
+ collection: "__email_templates",
11876
+ where: {
11877
+ collectionSlug: "*",
11878
+ purpose,
11879
+ active: true
11880
+ },
11881
+ limit: 1
11882
+ });
11883
+ const doc = global?.docs?.[0];
11884
+ if (doc) {
11885
+ return formatDbTemplateDoc(doc, args);
11886
+ }
11887
+ } catch {
11888
+ return null;
11609
11889
  }
11890
+ return null;
11610
11891
  }
11611
- function buildWelcomeEmail(config, args) {
11612
- const custom = config.email?.templates?.welcome?.(args);
11892
+ function enrichArgs(config, collection, args) {
11893
+ const slug = collection?.slug;
11894
+ const readableSlug = slug?.replace(/^__/, "");
11895
+ const fallbackLabel = readableSlug ? readableSlug.charAt(0).toUpperCase() + readableSlug.slice(1) : "Account";
11613
11896
  return {
11614
- subject: custom?.subject ?? "Welcome \u2014 your account is ready",
11615
- html: custom?.html ?? layout({
11616
- preheader: "Your Dyrected account is ready.",
11897
+ ...args,
11898
+ collection: slug,
11899
+ collectionLabel: collection?.labels?.singular || collection?.labels?.plural || fallbackLabel,
11900
+ collectionLabels: collection?.labels,
11901
+ siteName: config.admin?.branding?.logoText ?? "Dyrected"
11902
+ };
11903
+ }
11904
+ function toOutboundEmail(to, result, metadata) {
11905
+ if ("template" in result) {
11906
+ return {
11907
+ to,
11908
+ type: "template",
11909
+ template: result.template,
11910
+ variables: result.variables,
11911
+ ...result.subject ? { subject: result.subject } : {},
11912
+ ...metadata
11913
+ };
11914
+ }
11915
+ return {
11916
+ to,
11917
+ type: "html",
11918
+ subject: result.subject ?? "Notification",
11919
+ html: result.html,
11920
+ ...result.text ? { text: result.text } : {},
11921
+ ...metadata
11922
+ };
11923
+ }
11924
+ function buildWelcomeEmail(config, args, collection, dbOverride) {
11925
+ if (dbOverride) return dbOverride;
11926
+ const enriched = enrichArgs(config, collection, args);
11927
+ const collectionAuth = collection?.auth;
11928
+ if (collectionAuth && typeof collectionAuth === "object" && collectionAuth.email?.templates?.welcome) {
11929
+ return collectionAuth.email.templates.welcome(enriched);
11930
+ }
11931
+ const custom = config.email?.templates?.welcome?.(enriched);
11932
+ if (custom) return custom;
11933
+ return {
11934
+ subject: "Welcome \u2014 your account is ready",
11935
+ html: layout({
11936
+ siteName: enriched.siteName,
11937
+ preheader: `Your ${enriched.siteName} account is ready.`,
11617
11938
  title: "Welcome \u2014 your account is ready",
11618
- content: `${paragraph("Your account has been created. You can now log in with:")}${detailBox(args.email)}`,
11939
+ content: [
11940
+ paragraph("Your account has been created. You can now log in with:"),
11941
+ detailBox(args.email)
11942
+ ],
11619
11943
  footer: "If you didn't create this account, you can safely ignore this email."
11620
11944
  })
11621
11945
  };
11622
11946
  }
11623
- function buildInviteEmail(config, args) {
11624
- const custom = config.email?.templates?.invite?.(args);
11947
+ function buildInviteEmail(config, args, collection, dbOverride) {
11948
+ if (dbOverride) return dbOverride;
11949
+ const enriched = enrichArgs(config, collection, args);
11950
+ const collectionAuth = collection?.auth;
11951
+ if (collectionAuth && typeof collectionAuth === "object" && collectionAuth.email?.templates?.invite) {
11952
+ return collectionAuth.email.templates.invite(enriched);
11953
+ }
11954
+ const custom = config.email?.templates?.invite?.(enriched);
11955
+ if (custom) return custom;
11625
11956
  const inviteLink = args.url;
11626
11957
  const inviteLinkBlock = inviteLink ? safeLinkDetailBox(inviteLink) : "";
11627
11958
  return {
11628
- subject: custom?.subject ?? "You've been invited",
11629
- html: custom?.html ?? layout({
11630
- preheader: "You've been invited to join a Dyrected admin area.",
11959
+ subject: "You've been invited",
11960
+ html: layout({
11961
+ siteName: enriched.siteName,
11962
+ preheader: `You've been invited to join ${enriched.siteName}.`,
11631
11963
  title: "You've been invited",
11632
- 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)}`,
11964
+ content: [
11965
+ args.invitedByEmail && paragraph(`You were invited by ${args.invitedByEmail}.`),
11966
+ inviteLink ? [
11967
+ paragraph("Use the invitation link below to create your account. The link expires in 7 days."),
11968
+ ctaButton("Accept invitation", inviteLink),
11969
+ sectionLabel("Invitation link"),
11970
+ inviteLinkBlock,
11971
+ paragraph("If the button does not work, copy and paste the link above into your browser.", "12px 0 0")
11972
+ ] : [
11973
+ paragraph("Use the invitation token below to accept your invitation. It expires in 7 days."),
11974
+ sectionLabel("Invitation token"),
11975
+ detailBox(args.token, true)
11976
+ ]
11977
+ ],
11633
11978
  footer: "If you weren't expecting this invitation, you can safely ignore this email."
11634
11979
  })
11635
11980
  };
11636
11981
  }
11637
- function buildResetPasswordEmail(config, args) {
11638
- const custom = config.email?.templates?.resetPassword?.(args);
11982
+ function buildResetPasswordEmail(config, args, collection, dbOverride) {
11983
+ if (dbOverride) return dbOverride;
11984
+ const enriched = enrichArgs(config, collection, args);
11985
+ const collectionAuth = collection?.auth;
11986
+ if (collectionAuth && typeof collectionAuth === "object" && collectionAuth.email?.templates?.resetPassword) {
11987
+ return collectionAuth.email.templates.resetPassword(enriched);
11988
+ }
11989
+ const custom = config.email?.templates?.resetPassword?.(enriched);
11990
+ if (custom) return custom;
11639
11991
  const resetLink = args.url;
11640
11992
  const resetLinkBlock = resetLink ? safeLinkDetailBox(resetLink) : "";
11641
11993
  return {
11642
- subject: custom?.subject ?? "Reset your password",
11643
- html: custom?.html ?? layout({
11644
- preheader: "Reset your Dyrected password.",
11994
+ subject: "Reset your password",
11995
+ html: layout({
11996
+ siteName: enriched.siteName,
11997
+ preheader: `Reset your ${enriched.siteName} password.`,
11645
11998
  title: "Reset your password",
11646
- 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)}`,
11999
+ content: [
12000
+ resetLink ? [
12001
+ paragraph("We received a request to reset your password. The reset link expires in 1 hour."),
12002
+ ctaButton("Reset password", resetLink),
12003
+ sectionLabel("Reset link"),
12004
+ resetLinkBlock,
12005
+ paragraph("If the button does not work, copy and paste the link above into your browser.", "12px 0 0")
12006
+ ] : [
12007
+ paragraph("We received a request to reset your password. The reset token expires in 1 hour."),
12008
+ sectionLabel("Reset token"),
12009
+ detailBox(args.token, true)
12010
+ ]
12011
+ ],
11647
12012
  footer: "If you didn't request a password reset, you can safely ignore this email."
11648
12013
  })
11649
12014
  };
11650
12015
  }
11651
- function buildPasswordChangedEmail(config, args) {
11652
- const custom = config.email?.templates?.passwordChanged?.(args);
12016
+ function buildPasswordChangedEmail(config, args, collection, dbOverride) {
12017
+ if (dbOverride) return dbOverride;
12018
+ const enriched = enrichArgs(config, collection, args);
12019
+ const collectionAuth = collection?.auth;
12020
+ if (collectionAuth && typeof collectionAuth === "object" && collectionAuth.email?.templates?.passwordChanged) {
12021
+ return collectionAuth.email.templates.passwordChanged(enriched);
12022
+ }
12023
+ const custom = config.email?.templates?.passwordChanged?.(enriched);
12024
+ if (custom) return custom;
11653
12025
  return {
11654
- subject: custom?.subject ?? "Your password has been changed",
11655
- html: custom?.html ?? layout({
11656
- preheader: "Your Dyrected password was changed.",
12026
+ subject: "Your password has been changed",
12027
+ html: layout({
12028
+ siteName: enriched.siteName,
12029
+ preheader: `Your ${enriched.siteName} password was changed.`,
11657
12030
  title: "Password changed",
11658
- 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.")}`,
12031
+ content: [
12032
+ paragraph("The password for this account was just changed:"),
12033
+ detailBox(args.email),
12034
+ spacer(),
12035
+ alertBox("If you did not make this change, please contact support immediately.")
12036
+ ],
11659
12037
  footer: "This is an automated security notification."
11660
12038
  })
11661
12039
  };
11662
12040
  }
12041
+ async function resolveEmailTemplate(params) {
12042
+ const { config, collection, purpose, args, db } = params;
12043
+ const enriched = enrichArgs(config, collection, args);
12044
+ let dbOverride = null;
12045
+ if (config.email?.adminEditable !== false && db) {
12046
+ dbOverride = await getDbEmailTemplateOverride(db, collection?.slug, purpose, enriched);
12047
+ }
12048
+ switch (purpose) {
12049
+ case "welcome":
12050
+ return buildWelcomeEmail(config, enriched, collection, dbOverride);
12051
+ case "invite":
12052
+ return buildInviteEmail(config, enriched, collection, dbOverride);
12053
+ case "resetPassword":
12054
+ return buildResetPasswordEmail(config, enriched, collection, dbOverride);
12055
+ case "passwordChanged":
12056
+ return buildPasswordChangedEmail(config, enriched, collection, dbOverride);
12057
+ default:
12058
+ throw new Error(`Unknown email template purpose: ${purpose}`);
12059
+ }
12060
+ }
11663
12061
 
11664
12062
  // src/controllers/auth.controller.ts
11665
12063
  var AuthController = class {
@@ -11696,9 +12094,10 @@ var AuthController = class {
11696
12094
  const data = {
11697
12095
  ...safeExtraFields,
11698
12096
  email,
11699
- password: await hashPassword(randomBytes3(32).toString("hex"))
12097
+ password: null,
12098
+ invitedAt: Date.now()
11700
12099
  };
11701
- if (this.hasField("status")) {
12100
+ if (this.hasField("status") && !data.status) {
11702
12101
  data.status = "pending";
11703
12102
  }
11704
12103
  return data;
@@ -11821,6 +12220,7 @@ var AuthController = class {
11821
12220
  ...rolePayload
11822
12221
  }
11823
12222
  });
12223
+ const safeUser = this.sanitizeUser(user);
11824
12224
  const token = await issueAuthSessionToken({
11825
12225
  config,
11826
12226
  userId: user.id,
@@ -11830,15 +12230,28 @@ var AuthController = class {
11830
12230
  ip: c.get("clientIp"),
11831
12231
  authSource: "local"
11832
12232
  });
11833
- const { subject, html } = buildWelcomeEmail(config, { email: body.email });
11834
- sendEmail(config, { to: body.email, subject, html }).catch(
12233
+ resolveEmailTemplate({
12234
+ config,
12235
+ collection: this.collection,
12236
+ purpose: "welcome",
12237
+ args: { email: body.email, user: safeUser },
12238
+ db
12239
+ }).then(
12240
+ (emailResult) => sendEmail(
12241
+ config,
12242
+ toOutboundEmail(body.email, emailResult, {
12243
+ collection: this.collection.slug,
12244
+ purpose: "welcome",
12245
+ user: safeUser
12246
+ })
12247
+ )
12248
+ ).catch(
11835
12249
  (err) => getRequestLogger(c, "auth").error({
11836
12250
  err,
11837
12251
  msg: "Failed to send welcome email",
11838
12252
  email: body.email
11839
12253
  })
11840
12254
  );
11841
- const safeUser = this.sanitizeUser(user);
11842
12255
  return c.json({ token, user: safeUser });
11843
12256
  }
11844
12257
  // ---------------------------------------------------------------------------
@@ -11872,6 +12285,16 @@ var AuthController = class {
11872
12285
  403
11873
12286
  );
11874
12287
  }
12288
+ if (!hasUsablePassword(user.password)) {
12289
+ return c.json(
12290
+ {
12291
+ error: true,
12292
+ code: "PASSWORD_NOT_SET",
12293
+ message: "This account does not have a password set. Please use the invite link or reset password."
12294
+ },
12295
+ 401
12296
+ );
12297
+ }
11875
12298
  const lockedUntilMs = getLockedUntilMs(user.lockedUntil);
11876
12299
  if (lockedUntilMs !== null && lockedUntilMs > Date.now()) {
11877
12300
  const retryAfterSeconds = Math.max(
@@ -12049,34 +12472,67 @@ var AuthController = class {
12049
12472
  limit: 1
12050
12473
  });
12051
12474
  const user = result.docs[0];
12475
+ let resetToken;
12476
+ let url;
12477
+ let emailSent = false;
12478
+ let emailError;
12052
12479
  if (user) {
12053
- const resetToken = await signCollectionToken(
12480
+ const siteId = c.get("siteId");
12481
+ resetToken = await signCollectionToken(
12054
12482
  {
12055
- sub: user.id,
12483
+ sub: String(user.id),
12056
12484
  email: user.email,
12057
12485
  collection: this.collection.slug,
12058
- purpose: "reset"
12486
+ purpose: "reset",
12487
+ ...siteId ? { siteId } : {}
12059
12488
  },
12060
- "1h"
12489
+ resolveResetPasswordTokenExpiry(this.collection)
12061
12490
  );
12062
- const url = appendQueryParam(resolveAdminUrl(c, config, body?.resetUrl), "token", resetToken);
12063
- try {
12064
- const { subject, html } = buildResetPasswordEmail(config, {
12065
- token: resetToken,
12066
- url
12067
- });
12068
- await sendEmail(config, { to: user.email, subject, html });
12069
- } catch (err) {
12070
- getRequestLogger(c, "auth").error({
12071
- err,
12072
- msg: "Failed to send password reset email",
12073
- email: user.email
12074
- });
12491
+ const customResetUrl = body?.resetUrl ?? (typeof this.collection.auth === "object" ? this.collection.auth.urls?.resetPassword : void 0);
12492
+ url = appendQueryParam(
12493
+ resolveActionUrl(c, config, customResetUrl),
12494
+ "token",
12495
+ resetToken
12496
+ );
12497
+ if (body?.sendEmail !== false) {
12498
+ try {
12499
+ const emailResult = await resolveEmailTemplate({
12500
+ config,
12501
+ collection: this.collection,
12502
+ purpose: "resetPassword",
12503
+ args: {
12504
+ token: resetToken,
12505
+ url,
12506
+ user
12507
+ },
12508
+ db
12509
+ });
12510
+ await sendEmail(
12511
+ config,
12512
+ toOutboundEmail(user.email, emailResult, {
12513
+ collection: this.collection.slug,
12514
+ purpose: "resetPassword",
12515
+ user
12516
+ })
12517
+ );
12518
+ emailSent = true;
12519
+ } catch (err) {
12520
+ emailSent = false;
12521
+ emailError = err?.message ?? "Email delivery failed";
12522
+ getRequestLogger(c, "auth").error({
12523
+ err,
12524
+ msg: "Failed to send password reset email",
12525
+ email: user.email
12526
+ });
12527
+ }
12075
12528
  }
12076
12529
  }
12077
12530
  return c.json({
12078
12531
  success: true,
12079
- message: "If an account with that email exists, a reset link has been sent."
12532
+ message: "If an account with that email exists, a reset link has been sent.",
12533
+ ...body?.sendEmail === false && resetToken ? { token: resetToken, resetUrl: url } : {},
12534
+ emailSent,
12535
+ ...emailError ? { emailError } : {}
12080
12536
  });
12081
12537
  }
12082
12538
  // ---------------------------------------------------------------------------
@@ -12110,6 +12566,13 @@ var AuthController = class {
12110
12566
  400
12111
12567
  );
12112
12568
  }
12569
+ const currentSiteId = c.get("siteId");
12570
+ if (payload.siteId && currentSiteId && payload.siteId !== currentSiteId) {
12571
+ return c.json(
12572
+ { error: true, code: "SITE_MISMATCH", message: "Token is not valid for this site." },
12573
+ 403
12574
+ );
12575
+ }
12113
12576
  const hashedPassword = await hashPassword(body.password);
12114
12577
  await db.update({
12115
12578
  collection: this.collection.slug,
@@ -12124,10 +12587,24 @@ var AuthController = class {
12124
12587
  userId: payload.sub,
12125
12588
  collection: this.collection.slug
12126
12589
  });
12127
- const { subject, html } = buildPasswordChangedEmail(config, {
12128
- email: payload.email
12129
- });
12130
- sendEmail(config, { to: payload.email, subject, html }).catch(
12590
+ resolveEmailTemplate({
12591
+ config,
12592
+ collection: this.collection,
12593
+ purpose: "passwordChanged",
12594
+ args: {
12595
+ email: payload.email,
12596
+ user: { id: payload.sub, email: payload.email }
12597
+ },
12598
+ db
12599
+ }).then(
12600
+ (emailResult) => sendEmail(
12601
+ config,
12602
+ toOutboundEmail(payload.email, emailResult, {
12603
+ collection: this.collection.slug,
12604
+ purpose: "passwordChanged"
12605
+ })
12606
+ )
12607
+ ).catch(
12131
12608
  (err) => getRequestLogger(c, "auth").error({
12132
12609
  err,
12133
12610
  msg: "Failed to send password-changed email",
@@ -12156,61 +12633,117 @@ var AuthController = class {
12156
12633
  if (!body?.email) {
12157
12634
  return c.json({ error: true, message: "email is required." }, 400);
12158
12635
  }
12159
- const inviteData = body?.data && typeof body.data === "object" && !Array.isArray(body.data) ? body.data : {};
12636
+ const {
12637
+ email,
12638
+ inviteUrl,
12639
+ sendEmail: shouldSendEmail,
12640
+ data: nestedData,
12641
+ ...topLevelFields
12642
+ } = body;
12643
+ const inviteData = {
12644
+ ...topLevelFields,
12645
+ ...nestedData && typeof nestedData === "object" && !Array.isArray(nestedData) ? nestedData : {}
12646
+ };
12160
12647
  const existing = await db.find({
12161
12648
  collection: this.collection.slug,
12162
- where: { email: body.email },
12649
+ where: { email },
12163
12650
  limit: 1
12164
12651
  });
12165
12652
  const existingUser = existing.docs[0];
12166
12653
  const existingIsPending = existingUser?.status === "pending";
12167
- if (existingUser && !existingIsPending) {
12654
+ if (existingUser && !existingIsPending && hasUsablePassword(existingUser.password)) {
12168
12655
  return c.json(
12169
- { error: true, message: "An account with that email already exists." },
12656
+ {
12657
+ error: true,
12658
+ code: "USER_ALREADY_EXISTS",
12659
+ message: "An account with that email already exists and has a password set. Use forgot password if needed."
12660
+ },
12170
12661
  409
12171
12662
  );
12172
12663
  }
12664
+ const now2 = Date.now();
12665
+ let targetUser;
12173
12666
  if (!existingUser) {
12174
- await db.create({
12667
+ const pendingData = await this.buildPendingInviteData(email, inviteData);
12668
+ pendingData.invitedAt = now2;
12669
+ targetUser = await db.create({
12175
12670
  collection: this.collection.slug,
12176
- data: await this.buildPendingInviteData(body.email, inviteData)
12671
+ data: pendingData
12177
12672
  });
12178
- } else if (Object.keys(inviteData).length > 0) {
12179
- await db.update({
12673
+ } else {
12674
+ const updateData = {
12675
+ ...inviteData,
12676
+ invitedAt: now2
12677
+ };
12678
+ if (this.hasField("status") && !existingUser.status) {
12679
+ updateData.status = "pending";
12680
+ }
12681
+ targetUser = await db.update({
12180
12682
  collection: this.collection.slug,
12181
12683
  id: String(existingUser.id),
12182
- data: await this.buildPendingInviteData(body.email, inviteData)
12684
+ data: updateData
12183
12685
  });
12184
12686
  }
12687
+ const siteId = c.get("siteId");
12185
12688
  const inviteToken = await signCollectionToken(
12186
12689
  {
12187
- sub: body.email,
12188
- email: body.email,
12690
+ sub: email,
12691
+ email,
12189
12692
  collection: this.collection.slug,
12190
- purpose: "invite"
12693
+ purpose: "invite",
12694
+ ...siteId ? { siteId } : {}
12191
12695
  },
12192
- "7d"
12696
+ resolveInviteTokenExpiry(this.collection)
12193
12697
  );
12194
- const url = appendQueryParam(resolveAdminUrl(c, config, body?.inviteUrl), "inviteToken", inviteToken);
12195
- try {
12196
- const { subject, html } = buildInviteEmail(config, {
12197
- token: inviteToken,
12198
- invitedByEmail: requestUser.email,
12199
- url
12200
- });
12201
- await sendEmail(config, { to: body.email, subject, html });
12202
- } catch (err) {
12203
- getRequestLogger(c, "auth").error({
12204
- err,
12205
- msg: "Failed to send invite email",
12206
- email: body.email
12207
- });
12698
+ const customInviteUrl = inviteUrl ?? (typeof this.collection.auth === "object" ? this.collection.auth.urls?.invite : void 0);
12699
+ const url = appendQueryParam(
12700
+ resolveActionUrl(c, config, customInviteUrl),
12701
+ "inviteToken",
12702
+ inviteToken
12703
+ );
12704
+ let emailSent = false;
12705
+ let emailError;
12706
+ if (shouldSendEmail !== false) {
12707
+ try {
12708
+ const emailResult = await resolveEmailTemplate({
12709
+ config,
12710
+ collection: this.collection,
12711
+ purpose: "invite",
12712
+ args: {
12713
+ token: inviteToken,
12714
+ invitedByEmail: requestUser.email,
12715
+ url,
12716
+ data: inviteData,
12717
+ user: targetUser
12718
+ },
12719
+ db
12720
+ });
12721
+ await sendEmail(
12722
+ config,
12723
+ toOutboundEmail(email, emailResult, {
12724
+ collection: this.collection.slug,
12725
+ purpose: "invite",
12726
+ user: targetUser
12727
+ })
12728
+ );
12729
+ emailSent = true;
12730
+ } catch (err) {
12731
+ emailSent = false;
12732
+ emailError = err?.message ?? "Email delivery failed";
12733
+ getRequestLogger(c, "auth").error({
12734
+ err,
12735
+ msg: "Failed to send invite email",
12736
+ email
12737
+ });
12738
+ }
12208
12739
  }
12209
12740
  return c.json({
12210
12741
  success: true,
12211
- message: `Invite sent to ${body.email}.`,
12742
+ message: emailSent ? `Invite sent to ${email}.` : shouldSendEmail === false ? `Invite created for ${email}.` : `Invite created for ${email}, but email delivery failed.`,
12212
12743
  token: inviteToken,
12213
- inviteUrl: url
12744
+ inviteUrl: url,
12745
+ emailSent,
12746
+ ...emailError ? { emailError } : {}
12214
12747
  });
12215
12748
  }
12216
12749
  // ---------------------------------------------------------------------------
@@ -12244,6 +12777,13 @@ var AuthController = class {
12244
12777
  400
12245
12778
  );
12246
12779
  }
12780
+ const currentSiteId = c.get("siteId");
12781
+ if (payload.siteId && currentSiteId && payload.siteId !== currentSiteId) {
12782
+ return c.json(
12783
+ { error: true, code: "SITE_MISMATCH", message: "Token is not valid for this site." },
12784
+ 403
12785
+ );
12786
+ }
12247
12787
  const inviteeEmail = payload.sub;
12248
12788
  const existing = await db.find({
12249
12789
  collection: this.collection.slug,
@@ -12252,12 +12792,26 @@ var AuthController = class {
12252
12792
  });
12253
12793
  const existingUser = existing.docs[0];
12254
12794
  const existingIsPending = existingUser?.status === "pending";
12255
- if (existingUser && !existingIsPending) {
12795
+ if (existingUser && !existingIsPending && hasUsablePassword(existingUser.password)) {
12256
12796
  return c.json(
12257
- { error: true, message: "An account with that email already exists." },
12797
+ {
12798
+ error: true,
12799
+ code: "INVITE_ALREADY_ACCEPTED",
12800
+ message: "This invitation has already been accepted. Please log in or use forgot password."
12801
+ },
12258
12802
  409
12259
12803
  );
12260
12804
  }
12805
+ if (existingUser && payload.iat && typeof existingUser.invitedAt === "number" && payload.iat * 1e3 < existingUser.invitedAt - 2e3) {
12806
+ return c.json(
12807
+ {
12808
+ error: true,
12809
+ code: "INVITE_SUPERSEDED",
12810
+ message: "This invitation link has been superseded by a newer invitation. Please use the latest link."
12811
+ },
12812
+ 400
12813
+ );
12814
+ }
12261
12815
  const { token: _t, password: _p, ...extraFields } = body;
12262
12816
  const hashedPassword = await hashPassword(body.password);
12263
12817
  const user = existingUser ? await db.update({
@@ -12281,23 +12835,179 @@ var AuthController = class {
12281
12835
  ip: c.get("clientIp"),
12282
12836
  authSource: "local"
12283
12837
  });
12284
- const { subject, html } = buildWelcomeEmail(config, {
12285
- email: inviteeEmail
12286
- });
12287
- sendEmail(config, { to: inviteeEmail, subject, html }).catch(
12838
+ const safeUser = this.sanitizeUser(user);
12839
+ resolveEmailTemplate({
12840
+ config,
12841
+ collection: this.collection,
12842
+ purpose: "welcome",
12843
+ args: {
12844
+ email: inviteeEmail,
12845
+ user: safeUser
12846
+ },
12847
+ db
12848
+ }).then(
12849
+ (emailResult) => sendEmail(
12850
+ config,
12851
+ toOutboundEmail(inviteeEmail, emailResult, {
12852
+ collection: this.collection.slug,
12853
+ purpose: "welcome",
12854
+ user: safeUser
12855
+ })
12856
+ )
12857
+ ).catch(
12288
12858
  (err) => getRequestLogger(c, "auth").error({
12289
12859
  err,
12290
12860
  msg: "Failed to send welcome email",
12291
12861
  email: inviteeEmail
12292
12862
  })
12293
12863
  );
12294
- const safeUser = this.sanitizeUser(user);
12295
12864
  return c.json({ token: sessionToken, user: safeUser }, 201);
12296
12865
  }
12866
+ // ---------------------------------------------------------------------------
12867
+ // GET /tokens/verify?token=...&purpose=invite|reset
12868
+ // Public. Validates token signature, expiration, database existence, and state.
12869
+ // ---------------------------------------------------------------------------
12870
+ async verifyToken(c) {
12871
+ const db = c.get("config").db;
12872
+ if (!db) return c.json({ message: "Database not configured" }, 500);
12873
+ const token = c.req.query("token");
12874
+ const purpose = c.req.query("purpose");
12875
+ if (!token) {
12876
+ return c.json(
12877
+ { valid: false, code: "TOKEN_REQUIRED", message: "Token is required." },
12878
+ 400
12879
+ );
12880
+ }
12881
+ let payload;
12882
+ try {
12883
+ payload = await verifyCollectionToken(token);
12884
+ } catch {
12885
+ return c.json(
12886
+ {
12887
+ valid: false,
12888
+ code: "TOKEN_EXPIRED_OR_INVALID",
12889
+ message: "Token is invalid or has expired."
12890
+ },
12891
+ 200
12892
+ );
12893
+ }
12894
+ if (payload.collection !== this.collection.slug) {
12895
+ return c.json(
12896
+ {
12897
+ valid: false,
12898
+ code: "COLLECTION_MISMATCH",
12899
+ message: "Token does not belong to this collection."
12900
+ },
12901
+ 200
12902
+ );
12903
+ }
12904
+ if (purpose && payload.purpose !== purpose) {
12905
+ return c.json(
12906
+ {
12907
+ valid: false,
12908
+ code: "PURPOSE_MISMATCH",
12909
+ message: `Token was issued for '${payload.purpose}', not '${purpose}'.`
12910
+ },
12911
+ 200
12912
+ );
12913
+ }
12914
+ const currentSiteId = c.get("siteId");
12915
+ if (payload.siteId && currentSiteId && payload.siteId !== currentSiteId) {
12916
+ return c.json(
12917
+ {
12918
+ valid: false,
12919
+ code: "SITE_MISMATCH",
12920
+ message: "Token is not valid for this site."
12921
+ },
12922
+ 200
12923
+ );
12924
+ }
12925
+ if (payload.purpose === "invite") {
12926
+ const result = await db.find({
12927
+ collection: this.collection.slug,
12928
+ where: { email: payload.email ?? payload.sub },
12929
+ limit: 1
12930
+ });
12931
+ const user = result.docs[0];
12932
+ if (!user) {
12933
+ return c.json(
12934
+ {
12935
+ valid: false,
12936
+ code: "USER_NOT_FOUND",
12937
+ message: "Invited account was not found."
12938
+ },
12939
+ 200
12940
+ );
12941
+ }
12942
+ if (user._deleted || user.deletedAt) {
12943
+ return c.json(
12944
+ {
12945
+ valid: false,
12946
+ code: "USER_DELETED",
12947
+ message: "Account has been deleted."
12948
+ },
12949
+ 200
12950
+ );
12951
+ }
12952
+ if (user.status !== "pending" && hasUsablePassword(user.password)) {
12953
+ return c.json(
12954
+ {
12955
+ valid: false,
12956
+ code: "INVITE_ALREADY_ACCEPTED",
12957
+ message: "This invitation has already been accepted."
12958
+ },
12959
+ 200
12960
+ );
12961
+ }
12962
+ if (payload.iat && typeof user.invitedAt === "number" && payload.iat * 1e3 < user.invitedAt - 2e3) {
12963
+ return c.json(
12964
+ {
12965
+ valid: false,
12966
+ code: "INVITE_SUPERSEDED",
12967
+ message: "This invitation link has been superseded by a newer one."
12968
+ },
12969
+ 200
12970
+ );
12971
+ }
12972
+ return c.json({
12973
+ valid: true,
12974
+ email: user.email,
12975
+ collection: this.collection.slug
12976
+ });
12977
+ }
12978
+ if (payload.purpose === "reset") {
12979
+ const result = await db.find({
12980
+ collection: this.collection.slug,
12981
+ where: { id: payload.sub },
12982
+ limit: 1
12983
+ });
12984
+ const user = result.docs[0];
12985
+ if (!user || user._deleted || user.deletedAt) {
12986
+ return c.json(
12987
+ {
12988
+ valid: false,
12989
+ code: "USER_NOT_FOUND",
12990
+ message: "Account was not found."
12991
+ },
12992
+ 200
12993
+ );
12994
+ }
12995
+ return c.json({
12996
+ valid: true,
12997
+ email: user.email,
12998
+ collection: this.collection.slug
12999
+ });
13000
+ }
13001
+ return c.json({
13002
+ valid: true,
13003
+ email: payload.email,
13004
+ collection: this.collection.slug
13005
+ });
13006
+ }
12297
13007
  };
12298
13008
 
12299
13009
  // src/controllers/admin-auth.controller.ts
12300
- import { randomBytes as randomBytes4 } from "crypto";
13010
+ import { randomBytes as randomBytes3 } from "crypto";
12301
13011
  import { TextEncoder as TextEncoder2 } from "util";
12302
13012
  import {
12303
13013
  SignJWT as SignJWT2,
@@ -12512,7 +13222,7 @@ var AdminAuthController = class {
12512
13222
  access.roles,
12513
13223
  access.data
12514
13224
  ),
12515
- password: await hashPassword(randomBytes4(32).toString("hex"))
13225
+ password: await hashPassword(randomBytes3(32).toString("hex"))
12516
13226
  }
12517
13227
  });
12518
13228
  } else if (user) {
@@ -12599,7 +13309,7 @@ var AdminAuthController = class {
12599
13309
  }
12600
13310
  async buildOidcStartUrl(provider, returnTo, siteId, origin) {
12601
13311
  const discovery = await this.getOidcDiscovery(provider);
12602
- const nonce = randomBytes4(16).toString("hex");
13312
+ const nonce = randomBytes3(16).toString("hex");
12603
13313
  const state = await this.signState({
12604
13314
  providerId: provider.id,
12605
13315
  returnTo,
@@ -15422,6 +16132,31 @@ function generateOpenApi(config) {
15422
16132
  spec.paths[`${path}/accept-invite`] = {
15423
16133
  post: publicAuthPost("Accept an invitation")
15424
16134
  };
16135
+ spec.paths[`${path}/tokens/verify`] = {
16136
+ get: {
16137
+ tags: [collectionTag],
16138
+ summary: `Verify an invite or password reset token`,
16139
+ parameters: [
16140
+ {
16141
+ name: "token",
16142
+ in: "query",
16143
+ required: true,
16144
+ schema: { type: "string" }
16145
+ },
16146
+ {
16147
+ name: "purpose",
16148
+ in: "query",
16149
+ required: false,
16150
+ schema: { type: "string", enum: ["invite", "reset"] }
16151
+ }
16152
+ ],
16153
+ responses: {
16154
+ 200: {
16155
+ description: "Token verification result"
16156
+ }
16157
+ }
16158
+ }
16159
+ };
15425
16160
  spec.paths[`${path}/{id}/change-password`] = {
15426
16161
  post: {
15427
16162
  tags: [collectionTag],
@@ -16032,7 +16767,7 @@ function registerRoutes(app, config) {
16032
16767
  return resolveBooleanAccess(requestConfig, access, accessArgs);
16033
16768
  };
16034
16769
  const filteredCollections = await Promise.all(
16035
- collections.filter((col) => !col.slug.startsWith("__") && (!siteId || col.shared || !col.siteId || col.siteId === siteId)).map(async (col) => ({
16770
+ collections.filter((col) => !INTERNAL_SYSTEM_COLLECTIONS.has(col.slug) && (!siteId || col.shared || !col.siteId || col.siteId === siteId)).map(async (col) => ({
16036
16771
  slug: col.slug,
16037
16772
  labels: col.labels,
16038
16773
  shared: !!col.shared,
@@ -16174,7 +16909,7 @@ function registerRoutes(app, config) {
16174
16909
  model: effectiveAi?.model
16175
16910
  },
16176
16911
  trash: {
16177
- enabled: collections.some((col) => !col.slug.startsWith("__") && resolveTrashConfig(col, requestConfig).enabled),
16912
+ enabled: collections.some((col) => !INTERNAL_SYSTEM_COLLECTIONS.has(col.slug) && resolveTrashConfig(col, requestConfig).enabled),
16178
16913
  retentionDays: requestConfig.trash?.retentionDays !== void 0 ? requestConfig.trash.retentionDays : 30,
16179
16914
  allowPermanentDelete: requestConfig.trash?.allowPermanentDelete !== false
16180
16915
  },
@@ -16185,7 +16920,7 @@ function registerRoutes(app, config) {
16185
16920
  uploadCollectionConfigured: requestConfig.collections.some((collection) => !!collection.upload),
16186
16921
  trashPurgeOverdue: await (async () => {
16187
16922
  const hasRetention = collections.some(
16188
- (col) => !col.slug.startsWith("__") && resolveTrashConfig(col, requestConfig).retentionDays !== null
16923
+ (col) => !INTERNAL_SYSTEM_COLLECTIONS.has(col.slug) && resolveTrashConfig(col, requestConfig).retentionDays !== null
16189
16924
  );
16190
16925
  if (!hasRetention || !requestConfig.db) return false;
16191
16926
  try {
@@ -16823,6 +17558,7 @@ function registerRoutes(app, config) {
16823
17558
  app.post(`${path}/reset-password`, (c) => authController.resetPassword(c));
16824
17559
  app.post(`${path}/invite`, requireAuth(config), (c) => authController.invite(c));
16825
17560
  app.post(`${path}/accept-invite`, (c) => authController.acceptInvite(c));
17561
+ app.get(`${path}/tokens/verify`, (c) => authController.verifyToken(c));
16826
17562
  }
16827
17563
  const auditController = new AuditController();
16828
17564
  for (const collection of config.collections) {
@@ -16867,6 +17603,126 @@ function registerRoutes(app, config) {
16867
17603
  app.get(path, (c) => controller.get(c));
16868
17604
  app.patch(path, (c) => controller.update(c));
16869
17605
  }
17606
+ const testEmailRateLimitMap = /* @__PURE__ */ new Map();
17607
+ const TEST_EMAIL_MAX = 5;
17608
+ const TEST_EMAIL_WINDOW_MS = 6e4;
17609
+ const handleTestEmailDispatch = async (c) => {
17610
+ const user = c.get("user");
17611
+ const adminCol = config.collections.find((col) => col.slug === "__admins") || config.collections.find((col) => col.auth);
17612
+ if (!isUserAdmin(user, adminCol)) {
17613
+ return c.json({ error: true, message: "Forbidden: Admin access required to dispatch test emails" }, 403);
17614
+ }
17615
+ const userId = String(user?.id ?? user?.sub ?? "unknown");
17616
+ const now2 = Date.now();
17617
+ const window2 = (testEmailRateLimitMap.get(userId) ?? []).filter(
17618
+ (ts) => now2 - ts < TEST_EMAIL_WINDOW_MS
17619
+ );
17620
+ if (window2.length >= TEST_EMAIL_MAX) {
17621
+ return c.json(
17622
+ { error: true, message: `Rate limit exceeded: maximum ${TEST_EMAIL_MAX} test emails per minute.` },
17623
+ 429
17624
+ );
17625
+ }
17626
+ window2.push(now2);
17627
+ testEmailRateLimitMap.set(userId, window2);
17628
+ const body = await c.req.json().catch(() => ({}));
17629
+ const {
17630
+ to,
17631
+ subject,
17632
+ html,
17633
+ format = "html",
17634
+ externalTemplateId,
17635
+ purpose = "invite",
17636
+ collectionSlug = "users"
17637
+ } = body;
17638
+ if (!to || typeof to !== "string" || !to.includes("@")) {
17639
+ return c.json({ error: true, message: "A valid recipient email address is required" }, 400);
17640
+ }
17641
+ const siteName = config.admin?.branding?.logoText || "Dyrected";
17642
+ let appUrl = config.admin?.siteUrl;
17643
+ if (!appUrl) {
17644
+ try {
17645
+ appUrl = new URL(c.req.url).origin;
17646
+ } catch {
17647
+ appUrl = "http://localhost:3000";
17648
+ }
17649
+ }
17650
+ const targetCol = config.collections.find((x) => x.slug === collectionSlug);
17651
+ 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));
17652
+ const mockData = {
17653
+ "{{url}}": `${appUrl}/auth/setup-password?token=mock_test_token_${Date.now()}`,
17654
+ "{{token}}": `mock_test_token_${Date.now()}`,
17655
+ "{{email}}": to,
17656
+ "{{collectionLabel}}": resolvedCollectionLabel,
17657
+ "{{siteName}}": siteName,
17658
+ "{{user.name}}": typeof user?.name === "string" ? user.name : "Test Recipient",
17659
+ "{{user.first_name}}": typeof user?.first_name === "string" ? user.first_name : "Test",
17660
+ "{{user.email}}": to
17661
+ };
17662
+ try {
17663
+ let result;
17664
+ if (format === "external_template") {
17665
+ if (!externalTemplateId) {
17666
+ return c.json({ error: true, message: "External template ID is required when format is external_template" }, 400);
17667
+ }
17668
+ const variables = {
17669
+ email: to,
17670
+ url: `${appUrl}/auth/setup-password?token=mock_test_token_${Date.now()}`,
17671
+ token: `mock_test_token_${Date.now()}`,
17672
+ collection: collectionSlug,
17673
+ collectionLabel: resolvedCollectionLabel,
17674
+ siteName,
17675
+ user: {
17676
+ name: typeof user?.name === "string" ? user.name : "Test Recipient",
17677
+ first_name: typeof user?.first_name === "string" ? user.first_name : "Test",
17678
+ email: to
17679
+ }
17680
+ };
17681
+ let testSubject = subject || `[TEST] ${purpose} email`;
17682
+ for (const [tag, sample] of Object.entries(mockData)) {
17683
+ testSubject = testSubject.split(tag).join(sample);
17684
+ }
17685
+ result = await sendEmail(config, {
17686
+ to,
17687
+ type: "template",
17688
+ template: externalTemplateId,
17689
+ variables,
17690
+ subject: testSubject,
17691
+ purpose,
17692
+ collection: collectionSlug
17693
+ });
17694
+ } else {
17695
+ let finalSubject = subject || `[TEST] ${purpose} email`;
17696
+ let finalHtml = html || "";
17697
+ for (const [tag, sample] of Object.entries(mockData)) {
17698
+ finalSubject = finalSubject.split(tag).join(sample);
17699
+ finalHtml = finalHtml.split(tag).join(sample);
17700
+ }
17701
+ result = await sendEmail(config, {
17702
+ to,
17703
+ type: "html",
17704
+ subject: finalSubject,
17705
+ html: finalHtml,
17706
+ purpose,
17707
+ collection: collectionSlug
17708
+ });
17709
+ }
17710
+ return c.json({
17711
+ success: true,
17712
+ message: `Test email sent to ${to}`,
17713
+ previewUrl: result?.previewUrl
17714
+ });
17715
+ } catch (err) {
17716
+ getConfigLogger(config, "email").error({
17717
+ err,
17718
+ msg: "Failed to dispatch test email",
17719
+ to
17720
+ });
17721
+ return c.json({ error: true, message: err.message || "Failed to send test email" }, 500);
17722
+ }
17723
+ };
17724
+ app.post("/api/__email_templates/test", requireAuth(config), handleTestEmailDispatch);
17725
+ app.post("/api/email-templates/test", requireAuth(config), handleTestEmailDispatch);
16870
17726
  if (!process.env.DYRECTED_JWT_SECRET) {
16871
17727
  getConfigLogger(config, "router").warn({
16872
17728
  msg: "DYRECTED_JWT_SECRET is not set; token-mode live preview is signing with an insecure default"
@@ -17321,6 +18177,114 @@ var AUTH_SESSIONS_COLLECTION_CONFIG = {
17321
18177
  },
17322
18178
  admin: { hidden: true }
17323
18179
  };
18180
+ var EMAIL_TEMPLATES_COLLECTION_SLUG = "__email_templates";
18181
+ var EMAIL_TEMPLATES_COLLECTION_CONFIG = {
18182
+ slug: EMAIL_TEMPLATES_COLLECTION_SLUG,
18183
+ labels: { singular: "Email Template", plural: "Email Templates" },
18184
+ detail: false,
18185
+ fields: [
18186
+ {
18187
+ name: "collectionSlug",
18188
+ type: "select",
18189
+ label: "Applies To",
18190
+ required: true,
18191
+ defaultValue: "*",
18192
+ options: [
18193
+ { label: "Global (All Users & Collections)", value: "*" }
18194
+ ],
18195
+ admin: {
18196
+ description: "Choose which user collection this template applies to, or 'Global' for all users."
18197
+ }
18198
+ },
18199
+ {
18200
+ name: "purpose",
18201
+ type: "select",
18202
+ label: "Purpose",
18203
+ required: true,
18204
+ options: [
18205
+ { label: "Invite", value: "invite" },
18206
+ { label: "Password Reset", value: "resetPassword" },
18207
+ { label: "Welcome", value: "welcome" },
18208
+ { label: "Password Changed", value: "passwordChanged" }
18209
+ ],
18210
+ admin: { width: "50%" }
18211
+ },
18212
+ {
18213
+ name: "format",
18214
+ type: "select",
18215
+ label: "Format",
18216
+ required: true,
18217
+ defaultValue: "html",
18218
+ options: [
18219
+ { label: "HTML", value: "html" },
18220
+ { label: "External Template", value: "external_template" }
18221
+ ],
18222
+ admin: { width: "50%" }
18223
+ },
18224
+ {
18225
+ name: "subject",
18226
+ type: "text",
18227
+ label: "Subject",
18228
+ admin: {
18229
+ description: "Subject line (optional when using external provider template)"
18230
+ }
18231
+ },
18232
+ {
18233
+ name: "rawHtml",
18234
+ type: "textarea",
18235
+ label: "HTML Content",
18236
+ admin: {
18237
+ description: "Raw HTML email content with {{variable}} tags"
18238
+ }
18239
+ },
18240
+ {
18241
+ name: "externalTemplateId",
18242
+ type: "text",
18243
+ label: "External Template ID",
18244
+ admin: {
18245
+ description: "Template ID or alias from Seamailer, Postmark, SendGrid, etc."
18246
+ }
18247
+ },
18248
+ {
18249
+ name: "active",
18250
+ type: "boolean",
18251
+ label: "Active",
18252
+ defaultValue: true,
18253
+ admin: {
18254
+ description: "When active, this template overrides code config. When disabled, falls back to code."
18255
+ }
18256
+ }
18257
+ ],
18258
+ admin: {
18259
+ group: "Settings",
18260
+ icon: "Mail",
18261
+ groupBy: "collectionSlug",
18262
+ defaultColumns: ["purpose", "collectionSlug", "format", "active"]
18263
+ },
18264
+ hooks: {
18265
+ beforeChange: [
18266
+ async ({ data, req }) => {
18267
+ if (data && data.format === "html") {
18268
+ const siteName = req?.config?.admin?.branding?.logoText || "Dyrected";
18269
+ const defaults = getDefaultEmailTemplate(data.purpose || "invite", { siteName });
18270
+ if (!data.subject) {
18271
+ data.subject = defaults.subject;
18272
+ }
18273
+ if (!data.rawHtml) {
18274
+ data.rawHtml = defaults.rawHtml;
18275
+ }
18276
+ }
18277
+ return data;
18278
+ }
18279
+ ]
18280
+ },
18281
+ access: {
18282
+ read: ({ user }) => isUserAdmin(user),
18283
+ create: ({ user }) => isUserAdmin(user),
18284
+ update: ({ user }) => isUserAdmin(user),
18285
+ delete: ({ user }) => isUserAdmin(user)
18286
+ }
18287
+ };
17324
18288
  function normalizeConfig(config) {
17325
18289
  const schemaAwareConfig = normalizeSchemaFragment(config);
17326
18290
  assertValidDeclarativeHooksInConfig(schemaAwareConfig, "config");
@@ -17335,10 +18299,13 @@ function normalizeConfig(config) {
17335
18299
  const needsTasks = (config.tasks?.length ?? 0) > 0;
17336
18300
  const needsAuthSessions = collections.some((col) => !!col.auth);
17337
18301
  const needsTrash = schemaAwareConfig.trash?.enabled === true || collections.some((col) => resolveTrashConfig(col, schemaAwareConfig).enabled);
17338
- const adminAuthCollectionSlug = getAdminAuthCollection({
18302
+ const adminAuthCollection = getAdminAuthCollection({
17339
18303
  collections,
17340
18304
  adminAuth: schemaAwareConfig.adminAuth
17341
- })?.slug;
18305
+ });
18306
+ const adminAuthCollectionSlug = adminAuthCollection?.slug;
18307
+ const adminOnly = ({ user }) => isUserAdmin(user, adminAuthCollection);
18308
+ const adminOtherThanSelf = ({ user, id }) => isUserAdmin(user, adminAuthCollection) && user?.id !== id;
17342
18309
  const normalizedCollections = collections.map((col) => {
17343
18310
  let fields = col.fields || [];
17344
18311
  const existingFieldNames = new Set(fields.map((f) => f.name));
@@ -17387,7 +18354,7 @@ function normalizeConfig(config) {
17387
18354
  { value: "viewer", label: "Viewer" }
17388
18355
  ],
17389
18356
  access: {
17390
- update: "user.role == 'admin' || (user.roles != null && 'admin' in user.roles)"
18357
+ update: adminOnly
17391
18358
  }
17392
18359
  }
17393
18360
  ];
@@ -17405,7 +18372,7 @@ function normalizeConfig(config) {
17405
18372
  { value: "pending", label: "Pending" }
17406
18373
  ],
17407
18374
  access: {
17408
- update: "(user.role == 'admin' || (user.roles != null && 'admin' in user.roles)) && user.id != id"
18375
+ update: adminOtherThanSelf
17409
18376
  },
17410
18377
  admin: {
17411
18378
  condition: `!(data.roles && "admin" in data.roles) && data.role != "admin"`
@@ -17474,7 +18441,7 @@ function normalizeConfig(config) {
17474
18441
  access: {
17475
18442
  ...field.access || {},
17476
18443
  // Must be an admin; cannot edit own roles (no self-elevation).
17477
- update: "(user.role == 'admin' || (user.roles != null && 'admin' in user.roles)) && user.id != id"
18444
+ update: adminOtherThanSelf
17478
18445
  }
17479
18446
  };
17480
18447
  }
@@ -17483,7 +18450,7 @@ function normalizeConfig(config) {
17483
18450
  ...field,
17484
18451
  access: {
17485
18452
  ...field.access || {},
17486
- update: "(user.role == 'admin' || (user.roles != null && 'admin' in user.roles)) && user.id != id"
18453
+ update: adminOtherThanSelf
17487
18454
  }
17488
18455
  };
17489
18456
  }
@@ -17575,6 +18542,52 @@ function normalizeConfig(config) {
17575
18542
  if (needsTrash && !normalizedCollections.some((col) => col.slug === TRASH_COLLECTION)) {
17576
18543
  systemCollections.push(TRASH_COLLECTION_CONFIG);
17577
18544
  }
18545
+ const needsEmailTemplates = schemaAwareConfig.email?.adminEditable === true;
18546
+ if (needsEmailTemplates && !normalizedCollections.some((col) => col.slug === EMAIL_TEMPLATES_COLLECTION_SLUG)) {
18547
+ const adminCol = getAdminAuthCollection(schemaAwareConfig);
18548
+ const customAccess = schemaAwareConfig.email?.access;
18549
+ const resolveOpAccess = (op) => {
18550
+ if (typeof customAccess === "function") return customAccess;
18551
+ if (customAccess && typeof customAccess === "object" && customAccess[op]) return customAccess[op];
18552
+ return ({ user }) => isUserAdmin(user, adminCol);
18553
+ };
18554
+ const customPurposes = schemaAwareConfig.email?.purposes ?? [];
18555
+ const purposeOptions = [
18556
+ { label: "Invite", value: "invite" },
18557
+ { label: "Password Reset", value: "resetPassword" },
18558
+ { label: "Welcome", value: "welcome" },
18559
+ { label: "Password Changed", value: "passwordChanged" },
18560
+ ...customPurposes
18561
+ ];
18562
+ const targetableCollections = normalizedCollections.filter(
18563
+ (c) => !c.slug.startsWith("__")
18564
+ );
18565
+ const audienceOptions = [
18566
+ { label: "Global (All Collections)", value: "*" },
18567
+ ...targetableCollections.map((c) => ({
18568
+ label: `${c.labels?.singular || c.labels?.plural || c.slug} (${c.slug})`,
18569
+ value: c.slug
18570
+ }))
18571
+ ];
18572
+ systemCollections.push({
18573
+ ...EMAIL_TEMPLATES_COLLECTION_CONFIG,
18574
+ fields: EMAIL_TEMPLATES_COLLECTION_CONFIG.fields.map((f) => {
18575
+ if (f.name === "collectionSlug") {
18576
+ return { ...f, options: audienceOptions };
18577
+ }
18578
+ if (f.name === "purpose") {
18579
+ return { ...f, options: purposeOptions };
18580
+ }
18581
+ return f;
18582
+ }),
18583
+ access: {
18584
+ read: resolveOpAccess("read"),
18585
+ create: resolveOpAccess("create"),
18586
+ update: resolveOpAccess("update"),
18587
+ delete: resolveOpAccess("delete")
18588
+ }
18589
+ });
18590
+ }
17578
18591
  return {
17579
18592
  ...schemaAwareConfig,
17580
18593
  collections: [...normalizedCollections, ...systemCollections],
@@ -17888,8 +18901,10 @@ export {
17888
18901
  AUTH_SESSIONS_COLLECTION,
17889
18902
  AuthController,
17890
18903
  CollectionController,
18904
+ DEFAULT_INVITE_EXPIRY,
17891
18905
  DEFAULT_LOCK_TIME_MS,
17892
18906
  DEFAULT_MAX_LOGIN_ATTEMPTS,
18907
+ DEFAULT_RESET_PASSWORD_EXPIRY,
17893
18908
  DEFAULT_SESSION_EXPIRY,
17894
18909
  EmbeddingService,
17895
18910
  GlobalController,
@@ -17921,13 +18936,16 @@ export {
17921
18936
  getAIModel,
17922
18937
  getAuthSession,
17923
18938
  getConfigLogger,
18939
+ getDbEmailTemplateOverride,
17924
18940
  getEmbeddingModel,
17925
18941
  getLockedUntilMs,
17926
18942
  getObservabilityRuntime,
17927
18943
  getRequestLogger,
17928
18944
  hasEmbeddingApiKey,
18945
+ hasUsablePassword,
17929
18946
  hashContent,
17930
18947
  hashPassword,
18948
+ interpolateVariables,
17931
18949
  isAuthSessionActive,
17932
18950
  issueAuthSessionToken,
17933
18951
  normalizeFieldValue,
@@ -17936,7 +18954,10 @@ export {
17936
18954
  registerRoutes,
17937
18955
  renderPrometheusMetrics,
17938
18956
  resolveAuthLockoutConfig,
18957
+ resolveEmailTemplate,
18958
+ resolveInviteTokenExpiry,
17939
18959
  resolveObservabilityConfig,
18960
+ resolveResetPasswordTokenExpiry,
17940
18961
  resolveSessionTokenExpiry,
17941
18962
  revokeAllAuthSessions,
17942
18963
  revokeAuthSession,
@@ -17946,6 +18967,7 @@ export {
17946
18967
  serializeViewForApi,
17947
18968
  shouldSampleRequest,
17948
18969
  signCollectionToken,
18970
+ toOutboundEmail,
17949
18971
  touchAuthSession,
17950
18972
  validatePayloadAgainstFields,
17951
18973
  verifyCollectionToken,