@dyrected/core 2.19.0 → 2.20.1

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