@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/index.cjs CHANGED
@@ -1799,8 +1799,11 @@ __export(index_exports, {
1799
1799
  DuplicateKeyError: () => DuplicateKeyError,
1800
1800
  DyrectedAIError: () => DyrectedAIError,
1801
1801
  DyrectedError: () => DyrectedError,
1802
+ EMAIL_TEMPLATES_COLLECTION_CONFIG: () => EMAIL_TEMPLATES_COLLECTION_CONFIG,
1803
+ EMAIL_TEMPLATES_COLLECTION_SLUG: () => EMAIL_TEMPLATES_COLLECTION_SLUG,
1802
1804
  FieldConditionBuilder: () => FieldConditionBuilder,
1803
1805
  ForbiddenError: () => ForbiddenError,
1806
+ INTERNAL_SYSTEM_COLLECTIONS: () => INTERNAL_SYSTEM_COLLECTIONS,
1804
1807
  LIFECYCLE_EVENTS_COLLECTION: () => LIFECYCLE_EVENTS_COLLECTION,
1805
1808
  LIFECYCLE_EVENT_NAMES: () => LIFECYCLE_EVENT_NAMES,
1806
1809
  MatchBuilder: () => MatchBuilder,
@@ -1818,6 +1821,7 @@ __export(index_exports, {
1818
1821
  WORKFLOW_HISTORY_COLLECTION: () => WORKFLOW_HISTORY_COLLECTION,
1819
1822
  abs: () => abs,
1820
1823
  addDays: () => addDays,
1824
+ alertBox: () => alertBox,
1821
1825
  asCollection: () => asCollection,
1822
1826
  assertValidAdminConditionsInConfig: () => assertValidAdminConditionsInConfig,
1823
1827
  assertValidDeclarativeAccessInConfig: () => assertValidDeclarativeAccessInConfig,
@@ -1847,6 +1851,7 @@ __export(index_exports, {
1847
1851
  createTaskRunner: () => createTaskRunner,
1848
1852
  createTrashPurgeTask: () => createTrashPurgeTask,
1849
1853
  createWorkflowDocument: () => createWorkflowDocument,
1854
+ ctaButton: () => ctaButton,
1850
1855
  defaultValue: () => defaultValue,
1851
1856
  defineAction: () => defineAction,
1852
1857
  defineArrayField: () => defineArrayField,
@@ -1893,6 +1898,7 @@ __export(index_exports, {
1893
1898
  defineUrlField: () => defineUrlField,
1894
1899
  defineView: () => defineView,
1895
1900
  defineWorkspace: () => defineWorkspace,
1901
+ detailBox: () => detailBox,
1896
1902
  diffDays: () => diffDays,
1897
1903
  dispatchLifecycleEvent: () => dispatchLifecycleEvent,
1898
1904
  dispatchPendingLifecycleEvents: () => dispatchPendingLifecycleEvents,
@@ -1908,10 +1914,14 @@ __export(index_exports, {
1908
1914
  displayTab: () => displayTab,
1909
1915
  displayTabs: () => displayTabs,
1910
1916
  displayText: () => displayText,
1917
+ divider: () => divider,
1918
+ emailTokens: () => emailTokens,
1911
1919
  endsWith: () => endsWith,
1920
+ escapeHtml: () => escapeHtml,
1912
1921
  evaluateDetailComputed: () => evaluateDetailComputed,
1913
1922
  evaluateJexl: () => evaluateJexl,
1914
1923
  evaluateJexlSync: () => evaluateJexlSync,
1924
+ eventList: () => eventList,
1915
1925
  executeFieldAfterRead: () => executeFieldAfterRead,
1916
1926
  executeFieldBeforeChange: () => executeFieldBeforeChange,
1917
1927
  expr: () => expr,
@@ -1928,8 +1938,10 @@ __export(index_exports, {
1928
1938
  getAdminAuthCollection: () => getAdminAuthCollection,
1929
1939
  getAdminRoleForCollection: () => getAdminRoleForCollection,
1930
1940
  getAllowedSitesForUser: () => getAllowedSitesForUser,
1941
+ getDefaultEmailTemplate: () => getDefaultEmailTemplate,
1931
1942
  getPublicAdminAuthConfig: () => getPublicAdminAuthConfig,
1932
1943
  getTrashEntryId: () => getTrashEntryId,
1944
+ heading: () => heading,
1933
1945
  includes: () => includes,
1934
1946
  initializeWorkflowDocument: () => initializeWorkflowDocument,
1935
1947
  isAICollection: () => isAICollection,
@@ -1942,6 +1954,7 @@ __export(index_exports, {
1942
1954
  isUserAdmin: () => isUserAdmin,
1943
1955
  join: () => join,
1944
1956
  last: () => last,
1957
+ layout: () => layout,
1945
1958
  length: () => length,
1946
1959
  lower: () => lower,
1947
1960
  maskEmail: () => maskEmail,
@@ -1957,6 +1970,7 @@ __export(index_exports, {
1957
1970
  normalizeGroupKey: () => normalizeGroupKey,
1958
1971
  now: () => now,
1959
1972
  numericFieldNames: () => numericFieldNames,
1973
+ paragraph: () => paragraph,
1960
1974
  parseCron: () => parseCron,
1961
1975
  parseMongoWhere: () => parseMongoWhere,
1962
1976
  parseSort: () => parseSort,
@@ -1973,21 +1987,27 @@ __export(index_exports, {
1973
1987
  resolveAdminAuthCollection: () => resolveAdminAuthCollection,
1974
1988
  resolveAllCollectionViews: () => resolveAllCollectionViews,
1975
1989
  resolveAuthorizedSiteId: () => resolveAuthorizedSiteId,
1990
+ resolveBlocks: () => resolveBlocks,
1976
1991
  resolveDocumentTitle: () => resolveDocumentTitle,
1977
1992
  resolveIndexName: () => resolveIndexName,
1978
1993
  resolvePrefix: () => resolvePrefix,
1979
1994
  resolveTrashConfig: () => resolveTrashConfig,
1980
1995
  restoreDocument: () => restoreDocument,
1981
1996
  round: () => round,
1997
+ row: () => row,
1982
1998
  runCollectionHooks: () => runCollectionHooks,
1999
+ safeLinkDetailBox: () => safeLinkDetailBox,
1983
2000
  sanitizeDocForAI: () => sanitizeDocForAI,
1984
2001
  saveWorkflowDraft: () => saveWorkflowDraft,
2002
+ sectionLabel: () => sectionLabel,
1985
2003
  simplePublishingWorkflow: () => simplePublishingWorkflow,
1986
2004
  slugify: () => slugify,
1987
2005
  softDeleteDocument: () => softDeleteDocument,
2006
+ spacer: () => spacer,
1988
2007
  sqrt: () => sqrt,
1989
2008
  startsWith: () => startsWith,
1990
2009
  sum: () => sum,
2010
+ table: () => table,
1991
2011
  today: () => today,
1992
2012
  transitionWorkflow: () => transitionWorkflow,
1993
2013
  trim: () => trim,
@@ -3243,6 +3263,14 @@ async function executeFieldAfterRead(fields, doc, user, db) {
3243
3263
 
3244
3264
  // src/trash.ts
3245
3265
  var TRASH_COLLECTION = "__trash";
3266
+ var INTERNAL_SYSTEM_COLLECTIONS = /* @__PURE__ */ new Set([
3267
+ "__audit",
3268
+ "__sessions",
3269
+ "__task_locks",
3270
+ "__trash",
3271
+ "__workflow_history",
3272
+ "__lifecycle_events"
3273
+ ]);
3246
3274
  var TRASH_COLLECTION_CONFIG = {
3247
3275
  slug: TRASH_COLLECTION,
3248
3276
  labels: { singular: "Trash entry", plural: "Trash entries" },
@@ -3334,7 +3362,7 @@ function assertValidTrashInConfig(config, source = "config") {
3334
3362
  if (col.slug === "trash") {
3335
3363
  throw new Error(`Collection slug "trash" is reserved by Dyrected for the trash system.`);
3336
3364
  }
3337
- const isSystem = col.slug.startsWith("__");
3365
+ const isSystem = INTERNAL_SYSTEM_COLLECTIONS.has(col.slug);
3338
3366
  if (isSystem && col.trash) {
3339
3367
  throw new Error(`System collection "${col.slug}" cannot enable trash.`);
3340
3368
  }
@@ -3713,7 +3741,7 @@ function createTaskRunner(config) {
3713
3741
  throw error;
3714
3742
  }
3715
3743
  }
3716
- async function execute(task, row, scheduledFor) {
3744
+ async function execute(task, row2, scheduledFor) {
3717
3745
  const started = Date.now();
3718
3746
  const lockMs = (task.lockTimeout ?? DEFAULT_LOCK_TIMEOUT_SECONDS) * 1e3;
3719
3747
  const token = `${instanceId}:${started}`;
@@ -3721,7 +3749,7 @@ function createTaskRunner(config) {
3721
3749
  id: { equals: task.name },
3722
3750
  lockedUntil: { less_than: started }
3723
3751
  };
3724
- if (scheduledFor) where.lastScheduledFor = { equals: row.lastScheduledFor };
3752
+ if (scheduledFor) where.lastScheduledFor = { equals: row2.lastScheduledFor };
3725
3753
  const claimed = await db.update({
3726
3754
  collection: TASK_LOCKS_COLLECTION,
3727
3755
  where,
@@ -3782,21 +3810,21 @@ function createTaskRunner(config) {
3782
3810
  async runTask(name) {
3783
3811
  const task = tasks.get(name);
3784
3812
  if (!task) throw new Error(`Unknown task "${name}".`);
3785
- const row = await ensureLockRow(name, /* @__PURE__ */ new Date());
3786
- return track(execute(task, row, void 0));
3813
+ const row2 = await ensureLockRow(name, /* @__PURE__ */ new Date());
3814
+ return track(execute(task, row2, void 0));
3787
3815
  },
3788
3816
  async runDue(now2 = /* @__PURE__ */ new Date()) {
3789
3817
  const results = [];
3790
3818
  for (const task of tasks.values()) {
3791
3819
  if (!task.schedule) continue;
3792
- const row = await ensureLockRow(task.name, now2);
3793
- const reference = new Date(String(row.lastScheduledFor));
3820
+ const row2 = await ensureLockRow(task.name, now2);
3821
+ const reference = new Date(String(row2.lastScheduledFor));
3794
3822
  const due = Number.isNaN(reference.getTime()) ? true : nextCronRun(task.schedule, reference).getTime() <= now2.getTime();
3795
3823
  if (!due) {
3796
3824
  results.push({ name: task.name, status: "skipped", reason: "not-due", durationMs: 0 });
3797
3825
  continue;
3798
3826
  }
3799
- results.push(await track(execute(task, row, floorToMinute(now2))));
3827
+ results.push(await track(execute(task, row2, floorToMinute(now2))));
3800
3828
  }
3801
3829
  return results;
3802
3830
  },
@@ -4311,13 +4339,34 @@ function getAdminRoleForCollection(collection) {
4311
4339
  }
4312
4340
  return "admin";
4313
4341
  }
4342
+ var STANDARD_ADMIN_ROLES = /* @__PURE__ */ new Set([
4343
+ "admin",
4344
+ "super_admin",
4345
+ "superadmin",
4346
+ "super admin",
4347
+ "super-admin",
4348
+ "owner"
4349
+ ]);
4314
4350
  function isUserAdmin(user, collection) {
4315
4351
  if (!user) return false;
4316
- const roles = Array.isArray(user.roles) ? user.roles : typeof user.role === "string" ? [user.role] : [];
4317
- if (roles.length === 0) return false;
4352
+ 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] : [];
4353
+ if (rawRoles.length === 0) return false;
4354
+ const roles = rawRoles.filter((r) => typeof r === "string");
4318
4355
  const configuredRole = getAdminRoleForCollection(collection);
4319
- if (roles.includes(configuredRole)) return true;
4320
- return roles.includes("admin") || roles.includes("super_admin");
4356
+ for (const r of roles) {
4357
+ if (r === configuredRole || r.toLowerCase() === configuredRole.toLowerCase()) {
4358
+ return true;
4359
+ }
4360
+ const normalized = r.trim().toLowerCase();
4361
+ if (STANDARD_ADMIN_ROLES.has(normalized)) {
4362
+ return true;
4363
+ }
4364
+ const stripped = normalized.replace(/[\s_-]+/g, "");
4365
+ if (stripped === "admin" || stripped === "superadmin" || stripped === "owner") {
4366
+ return true;
4367
+ }
4368
+ }
4369
+ return false;
4321
4370
  }
4322
4371
 
4323
4372
  // src/utils/block-references.ts
@@ -4540,6 +4589,245 @@ function generateDocumentId(collectionOrSlug) {
4540
4589
  return generateId({ prefix, type });
4541
4590
  }
4542
4591
 
4592
+ // src/services/email-template.ts
4593
+ var emailTokens = {
4594
+ colors: {
4595
+ canvas: "#f6f7f2",
4596
+ surface: "#ffffff",
4597
+ text: "#171717",
4598
+ muted: "#62665b",
4599
+ subtle: "#8a8f82",
4600
+ border: "#dde0d7",
4601
+ accent: "#b6ff2e",
4602
+ code: "#f1f3ec",
4603
+ codeBorder: "#e2e5dc",
4604
+ dangerSurface: "#fff2f0",
4605
+ dangerBorder: "#ffc9c2",
4606
+ dangerText: "#9f251b"
4607
+ },
4608
+ font: "Arial, 'Helvetica Neue', Helvetica, sans-serif",
4609
+ mono: "'Courier New', Courier, monospace",
4610
+ radius: { card: "12px", control: "6px" },
4611
+ width: "600px"
4612
+ };
4613
+ function escapeHtml(value) {
4614
+ return value.replace(
4615
+ /[&<>'"]/g,
4616
+ (character) => ({
4617
+ "&": "&amp;",
4618
+ "<": "&lt;",
4619
+ ">": "&gt;",
4620
+ "'": "&#39;",
4621
+ '"': "&quot;"
4622
+ })[character]
4623
+ );
4624
+ }
4625
+ function safeHttpUrl(value) {
4626
+ if (value.startsWith("{{") && value.endsWith("}}")) {
4627
+ return value;
4628
+ }
4629
+ try {
4630
+ const url = new URL(value);
4631
+ return url.protocol === "https:" || url.protocol === "http:" ? escapeHtml(url.toString()) : void 0;
4632
+ } catch {
4633
+ return void 0;
4634
+ }
4635
+ }
4636
+ function heading(content) {
4637
+ 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>`;
4638
+ }
4639
+ function paragraph(content, margin = "0 0 16px", size = "15px") {
4640
+ return `<p style="margin:${margin};font-family:${emailTokens.font};font-size:${size};line-height:1.6;color:${emailTokens.colors.muted}">${escapeHtml(content)}</p>`;
4641
+ }
4642
+ function sectionLabel(content) {
4643
+ 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>`;
4644
+ }
4645
+ function divider() {
4646
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"><tr><td style="height:1px;background:${emailTokens.colors.border};font-size:0;line-height:0">&nbsp;</td></tr></table>`;
4647
+ }
4648
+ function spacer(height = 16) {
4649
+ return table(row("&nbsp;", `height:${height}px;font-size:0;line-height:0`));
4650
+ }
4651
+ function row(content, cellStyle = "") {
4652
+ return `<tr><td style="${cellStyle}">${content}</td></tr>`;
4653
+ }
4654
+ function table(content, style = "width:100%") {
4655
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="${style}">${content}</table>`;
4656
+ }
4657
+ function detailBox(content, monospace = false) {
4658
+ const font = monospace ? emailTokens.mono : emailTokens.font;
4659
+ return table(
4660
+ row(
4661
+ escapeHtml(content),
4662
+ `padding:14px 16px;font-family:${font};font-size:13px;line-height:1.5;font-weight:${monospace ? "400" : "700"};color:${emailTokens.colors.text};word-break:break-all`
4663
+ ),
4664
+ `width:100%;background:${emailTokens.colors.code};border:1px solid ${emailTokens.colors.codeBorder};border-radius:${emailTokens.radius.control}`
4665
+ );
4666
+ }
4667
+ function eventList(events) {
4668
+ return table(
4669
+ events.map(
4670
+ ({ label, value }) => row(
4671
+ `${sectionLabel(label)}${paragraph(value, "0")}`,
4672
+ `padding:12px 0;border-bottom:1px solid ${emailTokens.colors.border}`
4673
+ )
4674
+ ).join("")
4675
+ );
4676
+ }
4677
+ function ctaButton(label, href) {
4678
+ const safeHref = safeHttpUrl(href);
4679
+ if (!safeHref) return "";
4680
+ return table(
4681
+ row(
4682
+ `<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>`,
4683
+ "padding:8px 0 24px"
4684
+ ),
4685
+ "width:auto"
4686
+ );
4687
+ }
4688
+ function safeLinkDetailBox(href) {
4689
+ const safeHref = safeHttpUrl(href);
4690
+ if (!safeHref) return "";
4691
+ return detailBox(safeHref, true);
4692
+ }
4693
+ function alertBox(content) {
4694
+ return table(
4695
+ row(
4696
+ escapeHtml(content),
4697
+ `padding:13px 16px;font-family:${emailTokens.font};font-size:13px;line-height:1.5;color:${emailTokens.colors.dangerText}`
4698
+ ),
4699
+ `width:100%;background:${emailTokens.colors.dangerSurface};border:1px solid ${emailTokens.colors.dangerBorder};border-radius:${emailTokens.radius.control}`
4700
+ );
4701
+ }
4702
+ function resolveBlocks(input) {
4703
+ if (Array.isArray(input)) {
4704
+ const parts = [];
4705
+ for (const item of input) {
4706
+ if (Array.isArray(item)) {
4707
+ for (const sub of item) {
4708
+ if (typeof sub === "string" && sub.length > 0) parts.push(sub);
4709
+ }
4710
+ } else if (typeof item === "string" && item.length > 0) {
4711
+ parts.push(item);
4712
+ }
4713
+ }
4714
+ return parts.join("\n");
4715
+ }
4716
+ return typeof input === "string" ? input : "";
4717
+ }
4718
+ function layout({ preheader, title, content, footer, siteName }) {
4719
+ const resolvedContent = resolveBlocks(content);
4720
+ const resolvedFooter = resolveBlocks(footer);
4721
+ return `<!doctype html>
4722
+ <html lang="en" dir="ltr" xmlns="http://www.w3.org/1999/xhtml">
4723
+ <head>
4724
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
4725
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
4726
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
4727
+ <meta name="x-apple-disable-message-reformatting">
4728
+ <meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no">
4729
+ <meta name="color-scheme" content="light">
4730
+ <meta name="supported-color-schemes" content="light">
4731
+ <title>${escapeHtml(title)}</title>
4732
+ </head>
4733
+ <body style="margin:0;padding:0;background-color:${emailTokens.colors.canvas};word-spacing:normal;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%">
4734
+ <div style="display:none;max-height:0;max-width:0;overflow:hidden;opacity:0;mso-hide:all;color:transparent;font-size:1px;line-height:1px">
4735
+ ${escapeHtml(preheader)}&#8203;&#847;&zwnj;&nbsp;&#8199;&#8199;&#847;&zwnj;&nbsp;
4736
+ </div>
4737
+ <center lang="en" style="width:100%;background-color:${emailTokens.colors.canvas}">
4738
+ ${table(
4739
+ row(
4740
+ table(
4741
+ 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(
4742
+ `${divider()}${paragraph(resolvedFooter, "20px 0 6px")}${paragraph("Privacy: this message contains account-related information; please avoid forwarding it.", "10px 0px 0px 0px", "12px")}`,
4743
+ "padding:0 32px 28px"
4744
+ ),
4745
+ `width:100%;max-width:${emailTokens.width};background:${emailTokens.colors.surface};border:1px solid ${emailTokens.colors.border};border-radius:${emailTokens.radius.card};overflow:hidden`
4746
+ ),
4747
+ "padding:32px 12px"
4748
+ ),
4749
+ `width:100%;background:${emailTokens.colors.canvas}`
4750
+ )}
4751
+ </center>
4752
+ </body>
4753
+ </html>`;
4754
+ }
4755
+ function getDefaultEmailTemplate(purpose, options) {
4756
+ const siteName = options?.siteName ?? "Dyrected";
4757
+ switch (purpose) {
4758
+ case "invite":
4759
+ return {
4760
+ subject: `You've been invited to ${siteName}`,
4761
+ rawHtml: layout({
4762
+ siteName,
4763
+ preheader: `You've been invited to join ${siteName}.`,
4764
+ title: "You've been invited",
4765
+ content: [
4766
+ paragraph("Use the invitation link below to create your account. The link expires in 7 days."),
4767
+ ctaButton("Accept invitation", "{{url}}"),
4768
+ sectionLabel("Invitation link"),
4769
+ safeLinkDetailBox("{{url}}"),
4770
+ paragraph("If the button does not work, copy and paste the link above into your browser.", "12px 0 0")
4771
+ ],
4772
+ footer: "If you weren't expecting this invitation, you can safely ignore this email."
4773
+ })
4774
+ };
4775
+ case "resetPassword":
4776
+ return {
4777
+ subject: "Reset your password",
4778
+ rawHtml: layout({
4779
+ siteName,
4780
+ preheader: `Reset your ${siteName} password.`,
4781
+ title: "Reset your password",
4782
+ content: [
4783
+ paragraph("We received a request to reset your password. The reset link expires in 1 hour."),
4784
+ ctaButton("Reset password", "{{url}}"),
4785
+ sectionLabel("Reset link"),
4786
+ safeLinkDetailBox("{{url}}"),
4787
+ paragraph("If the button does not work, copy and paste the link above into your browser.", "12px 0 0")
4788
+ ],
4789
+ footer: "If you didn't request a password reset, you can safely ignore this email."
4790
+ })
4791
+ };
4792
+ case "welcome":
4793
+ return {
4794
+ subject: `Welcome to ${siteName} \u2014 your account is ready`,
4795
+ rawHtml: layout({
4796
+ siteName,
4797
+ preheader: `Your ${siteName} account is ready.`,
4798
+ title: "Welcome \u2014 your account is ready",
4799
+ content: [paragraph("Your account has been created. You can now log in with:"), detailBox("{{email}}")],
4800
+ footer: "If you didn't create this account, you can safely ignore this email."
4801
+ })
4802
+ };
4803
+ case "passwordChanged":
4804
+ return {
4805
+ subject: "Your password has been changed",
4806
+ rawHtml: layout({
4807
+ siteName,
4808
+ preheader: `Your ${siteName} password was changed.`,
4809
+ title: "Password changed",
4810
+ content: [
4811
+ paragraph("The password for your account has been changed."),
4812
+ alertBox("If you did not make this change, please contact support or reset your password immediately.")
4813
+ ],
4814
+ footer: "Security notice: this confirms a recent credential change."
4815
+ })
4816
+ };
4817
+ default:
4818
+ return {
4819
+ subject: `Notification from ${siteName}`,
4820
+ rawHtml: layout({
4821
+ siteName,
4822
+ preheader: `Account notification from ${siteName}.`,
4823
+ title: "Account Notification",
4824
+ content: [paragraph(`This is an account notification from ${siteName}.`)],
4825
+ footer: "If you have any questions, please reach out to our team."
4826
+ })
4827
+ };
4828
+ }
4829
+ }
4830
+
4543
4831
  // src/utils/config.ts
4544
4832
  var AUDIT_COLLECTION_SLUG = "__audit";
4545
4833
  var SYSTEM_FIELDS = [
@@ -4681,6 +4969,114 @@ var AUTH_SESSIONS_COLLECTION_CONFIG = {
4681
4969
  },
4682
4970
  admin: { hidden: true }
4683
4971
  };
4972
+ var EMAIL_TEMPLATES_COLLECTION_SLUG = "__email_templates";
4973
+ var EMAIL_TEMPLATES_COLLECTION_CONFIG = {
4974
+ slug: EMAIL_TEMPLATES_COLLECTION_SLUG,
4975
+ labels: { singular: "Email Template", plural: "Email Templates" },
4976
+ detail: false,
4977
+ fields: [
4978
+ {
4979
+ name: "collectionSlug",
4980
+ type: "select",
4981
+ label: "Applies To",
4982
+ required: true,
4983
+ defaultValue: "*",
4984
+ options: [
4985
+ { label: "Global (All Users & Collections)", value: "*" }
4986
+ ],
4987
+ admin: {
4988
+ description: "Choose which user collection this template applies to, or 'Global' for all users."
4989
+ }
4990
+ },
4991
+ {
4992
+ name: "purpose",
4993
+ type: "select",
4994
+ label: "Purpose",
4995
+ required: true,
4996
+ options: [
4997
+ { label: "Invite", value: "invite" },
4998
+ { label: "Password Reset", value: "resetPassword" },
4999
+ { label: "Welcome", value: "welcome" },
5000
+ { label: "Password Changed", value: "passwordChanged" }
5001
+ ],
5002
+ admin: { width: "50%" }
5003
+ },
5004
+ {
5005
+ name: "format",
5006
+ type: "select",
5007
+ label: "Format",
5008
+ required: true,
5009
+ defaultValue: "html",
5010
+ options: [
5011
+ { label: "HTML", value: "html" },
5012
+ { label: "External Template", value: "external_template" }
5013
+ ],
5014
+ admin: { width: "50%" }
5015
+ },
5016
+ {
5017
+ name: "subject",
5018
+ type: "text",
5019
+ label: "Subject",
5020
+ admin: {
5021
+ description: "Subject line (optional when using external provider template)"
5022
+ }
5023
+ },
5024
+ {
5025
+ name: "rawHtml",
5026
+ type: "textarea",
5027
+ label: "HTML Content",
5028
+ admin: {
5029
+ description: "Raw HTML email content with {{variable}} tags"
5030
+ }
5031
+ },
5032
+ {
5033
+ name: "externalTemplateId",
5034
+ type: "text",
5035
+ label: "External Template ID",
5036
+ admin: {
5037
+ description: "Template ID or alias from Seamailer, Postmark, SendGrid, etc."
5038
+ }
5039
+ },
5040
+ {
5041
+ name: "active",
5042
+ type: "boolean",
5043
+ label: "Active",
5044
+ defaultValue: true,
5045
+ admin: {
5046
+ description: "When active, this template overrides code config. When disabled, falls back to code."
5047
+ }
5048
+ }
5049
+ ],
5050
+ admin: {
5051
+ group: "Settings",
5052
+ icon: "Mail",
5053
+ groupBy: "collectionSlug",
5054
+ defaultColumns: ["purpose", "collectionSlug", "format", "active"]
5055
+ },
5056
+ hooks: {
5057
+ beforeChange: [
5058
+ async ({ data, req }) => {
5059
+ if (data && data.format === "html") {
5060
+ const siteName = req?.config?.admin?.branding?.logoText || "Dyrected";
5061
+ const defaults = getDefaultEmailTemplate(data.purpose || "invite", { siteName });
5062
+ if (!data.subject) {
5063
+ data.subject = defaults.subject;
5064
+ }
5065
+ if (!data.rawHtml) {
5066
+ data.rawHtml = defaults.rawHtml;
5067
+ }
5068
+ }
5069
+ return data;
5070
+ }
5071
+ ]
5072
+ },
5073
+ access: {
5074
+ read: ({ user }) => isUserAdmin(user),
5075
+ create: ({ user }) => isUserAdmin(user),
5076
+ update: ({ user }) => isUserAdmin(user),
5077
+ delete: ({ user }) => isUserAdmin(user)
5078
+ }
5079
+ };
4684
5080
  function normalizeConfig(config) {
4685
5081
  const schemaAwareConfig = normalizeSchemaFragment(config);
4686
5082
  assertValidDeclarativeHooksInConfig(schemaAwareConfig, "config");
@@ -4695,10 +5091,13 @@ function normalizeConfig(config) {
4695
5091
  const needsTasks = (config.tasks?.length ?? 0) > 0;
4696
5092
  const needsAuthSessions = collections.some((col) => !!col.auth);
4697
5093
  const needsTrash = schemaAwareConfig.trash?.enabled === true || collections.some((col) => resolveTrashConfig(col, schemaAwareConfig).enabled);
4698
- const adminAuthCollectionSlug = getAdminAuthCollection({
5094
+ const adminAuthCollection = getAdminAuthCollection({
4699
5095
  collections,
4700
5096
  adminAuth: schemaAwareConfig.adminAuth
4701
- })?.slug;
5097
+ });
5098
+ const adminAuthCollectionSlug = adminAuthCollection?.slug;
5099
+ const adminOnly = ({ user }) => isUserAdmin(user, adminAuthCollection);
5100
+ const adminOtherThanSelf = ({ user, id }) => isUserAdmin(user, adminAuthCollection) && user?.id !== id;
4702
5101
  const normalizedCollections = collections.map((col) => {
4703
5102
  let fields = col.fields || [];
4704
5103
  const existingFieldNames = new Set(fields.map((f) => f.name));
@@ -4747,7 +5146,7 @@ function normalizeConfig(config) {
4747
5146
  { value: "viewer", label: "Viewer" }
4748
5147
  ],
4749
5148
  access: {
4750
- update: "user.role == 'admin' || (user.roles != null && 'admin' in user.roles)"
5149
+ update: adminOnly
4751
5150
  }
4752
5151
  }
4753
5152
  ];
@@ -4765,7 +5164,7 @@ function normalizeConfig(config) {
4765
5164
  { value: "pending", label: "Pending" }
4766
5165
  ],
4767
5166
  access: {
4768
- update: "(user.role == 'admin' || (user.roles != null && 'admin' in user.roles)) && user.id != id"
5167
+ update: adminOtherThanSelf
4769
5168
  },
4770
5169
  admin: {
4771
5170
  condition: `!(data.roles && "admin" in data.roles) && data.role != "admin"`
@@ -4834,7 +5233,7 @@ function normalizeConfig(config) {
4834
5233
  access: {
4835
5234
  ...field.access || {},
4836
5235
  // Must be an admin; cannot edit own roles (no self-elevation).
4837
- update: "(user.role == 'admin' || (user.roles != null && 'admin' in user.roles)) && user.id != id"
5236
+ update: adminOtherThanSelf
4838
5237
  }
4839
5238
  };
4840
5239
  }
@@ -4843,7 +5242,7 @@ function normalizeConfig(config) {
4843
5242
  ...field,
4844
5243
  access: {
4845
5244
  ...field.access || {},
4846
- update: "(user.role == 'admin' || (user.roles != null && 'admin' in user.roles)) && user.id != id"
5245
+ update: adminOtherThanSelf
4847
5246
  }
4848
5247
  };
4849
5248
  }
@@ -4935,6 +5334,52 @@ function normalizeConfig(config) {
4935
5334
  if (needsTrash && !normalizedCollections.some((col) => col.slug === TRASH_COLLECTION)) {
4936
5335
  systemCollections.push(TRASH_COLLECTION_CONFIG);
4937
5336
  }
5337
+ const needsEmailTemplates = schemaAwareConfig.email?.adminEditable === true;
5338
+ if (needsEmailTemplates && !normalizedCollections.some((col) => col.slug === EMAIL_TEMPLATES_COLLECTION_SLUG)) {
5339
+ const adminCol = getAdminAuthCollection(schemaAwareConfig);
5340
+ const customAccess = schemaAwareConfig.email?.access;
5341
+ const resolveOpAccess = (op) => {
5342
+ if (typeof customAccess === "function") return customAccess;
5343
+ if (customAccess && typeof customAccess === "object" && customAccess[op]) return customAccess[op];
5344
+ return ({ user }) => isUserAdmin(user, adminCol);
5345
+ };
5346
+ const customPurposes = schemaAwareConfig.email?.purposes ?? [];
5347
+ const purposeOptions = [
5348
+ { label: "Invite", value: "invite" },
5349
+ { label: "Password Reset", value: "resetPassword" },
5350
+ { label: "Welcome", value: "welcome" },
5351
+ { label: "Password Changed", value: "passwordChanged" },
5352
+ ...customPurposes
5353
+ ];
5354
+ const targetableCollections = normalizedCollections.filter(
5355
+ (c) => !c.slug.startsWith("__")
5356
+ );
5357
+ const audienceOptions = [
5358
+ { label: "Global (All Collections)", value: "*" },
5359
+ ...targetableCollections.map((c) => ({
5360
+ label: `${c.labels?.singular || c.labels?.plural || c.slug} (${c.slug})`,
5361
+ value: c.slug
5362
+ }))
5363
+ ];
5364
+ systemCollections.push({
5365
+ ...EMAIL_TEMPLATES_COLLECTION_CONFIG,
5366
+ fields: EMAIL_TEMPLATES_COLLECTION_CONFIG.fields.map((f) => {
5367
+ if (f.name === "collectionSlug") {
5368
+ return { ...f, options: audienceOptions };
5369
+ }
5370
+ if (f.name === "purpose") {
5371
+ return { ...f, options: purposeOptions };
5372
+ }
5373
+ return f;
5374
+ }),
5375
+ access: {
5376
+ read: resolveOpAccess("read"),
5377
+ create: resolveOpAccess("create"),
5378
+ update: resolveOpAccess("update"),
5379
+ delete: resolveOpAccess("delete")
5380
+ }
5381
+ });
5382
+ }
4938
5383
  return {
4939
5384
  ...schemaAwareConfig,
4940
5385
  collections: [...normalizedCollections, ...systemCollections],
@@ -6262,6 +6707,31 @@ function generateOpenApi(config) {
6262
6707
  spec.paths[`${path}/accept-invite`] = {
6263
6708
  post: publicAuthPost("Accept an invitation")
6264
6709
  };
6710
+ spec.paths[`${path}/tokens/verify`] = {
6711
+ get: {
6712
+ tags: [collectionTag],
6713
+ summary: `Verify an invite or password reset token`,
6714
+ parameters: [
6715
+ {
6716
+ name: "token",
6717
+ in: "query",
6718
+ required: true,
6719
+ schema: { type: "string" }
6720
+ },
6721
+ {
6722
+ name: "purpose",
6723
+ in: "query",
6724
+ required: false,
6725
+ schema: { type: "string", enum: ["invite", "reset"] }
6726
+ }
6727
+ ],
6728
+ responses: {
6729
+ 200: {
6730
+ description: "Token verification result"
6731
+ }
6732
+ }
6733
+ }
6734
+ };
6265
6735
  spec.paths[`${path}/{id}/change-password`] = {
6266
6736
  post: {
6267
6737
  tags: [collectionTag],
@@ -7459,8 +7929,8 @@ function sanitizeDocForAI(options) {
7459
7929
  // src/utils/index-name.ts
7460
7930
  var import_node_crypto2 = require("crypto");
7461
7931
  var MAX_INDEX_NAME_LENGTH = 60;
7462
- function resolveIndexName(prefix, table, fields) {
7463
- const full = `${prefix}_${table}_${fields.join("_")}`;
7932
+ function resolveIndexName(prefix, table2, fields) {
7933
+ const full = `${prefix}_${table2}_${fields.join("_")}`;
7464
7934
  if (full.length <= MAX_INDEX_NAME_LENGTH) return full;
7465
7935
  const hash = (0, import_node_crypto2.createHash)("sha1").update(full).digest("hex").slice(0, 10);
7466
7936
  const tail = (fields[fields.length - 1] ?? "").slice(0, 30);
@@ -7932,7 +8402,7 @@ function compileNavigation(config) {
7932
8402
  const mentionedTrash = explicitNav.some((i) => i.trash || i.slug === "trash");
7933
8403
  if (!mentionedTrash) {
7934
8404
  const hasTrash = collections.some(
7935
- (col) => !col.slug.startsWith("__") && resolveTrashConfig(col, config).enabled
8405
+ (col) => !INTERNAL_SYSTEM_COLLECTIONS.has(col.slug) && resolveTrashConfig(col, config).enabled
7936
8406
  );
7937
8407
  if (hasTrash) {
7938
8408
  registerItem(void 0, {
@@ -8263,8 +8733,11 @@ function defineTab(args) {
8263
8733
  DuplicateKeyError,
8264
8734
  DyrectedAIError,
8265
8735
  DyrectedError,
8736
+ EMAIL_TEMPLATES_COLLECTION_CONFIG,
8737
+ EMAIL_TEMPLATES_COLLECTION_SLUG,
8266
8738
  FieldConditionBuilder,
8267
8739
  ForbiddenError,
8740
+ INTERNAL_SYSTEM_COLLECTIONS,
8268
8741
  LIFECYCLE_EVENTS_COLLECTION,
8269
8742
  LIFECYCLE_EVENT_NAMES,
8270
8743
  MatchBuilder,
@@ -8282,6 +8755,7 @@ function defineTab(args) {
8282
8755
  WORKFLOW_HISTORY_COLLECTION,
8283
8756
  abs,
8284
8757
  addDays,
8758
+ alertBox,
8285
8759
  asCollection,
8286
8760
  assertValidAdminConditionsInConfig,
8287
8761
  assertValidDeclarativeAccessInConfig,
@@ -8311,6 +8785,7 @@ function defineTab(args) {
8311
8785
  createTaskRunner,
8312
8786
  createTrashPurgeTask,
8313
8787
  createWorkflowDocument,
8788
+ ctaButton,
8314
8789
  defaultValue,
8315
8790
  defineAction,
8316
8791
  defineArrayField,
@@ -8357,6 +8832,7 @@ function defineTab(args) {
8357
8832
  defineUrlField,
8358
8833
  defineView,
8359
8834
  defineWorkspace,
8835
+ detailBox,
8360
8836
  diffDays,
8361
8837
  dispatchLifecycleEvent,
8362
8838
  dispatchPendingLifecycleEvents,
@@ -8372,10 +8848,14 @@ function defineTab(args) {
8372
8848
  displayTab,
8373
8849
  displayTabs,
8374
8850
  displayText,
8851
+ divider,
8852
+ emailTokens,
8375
8853
  endsWith,
8854
+ escapeHtml,
8376
8855
  evaluateDetailComputed,
8377
8856
  evaluateJexl,
8378
8857
  evaluateJexlSync,
8858
+ eventList,
8379
8859
  executeFieldAfterRead,
8380
8860
  executeFieldBeforeChange,
8381
8861
  expr,
@@ -8392,8 +8872,10 @@ function defineTab(args) {
8392
8872
  getAdminAuthCollection,
8393
8873
  getAdminRoleForCollection,
8394
8874
  getAllowedSitesForUser,
8875
+ getDefaultEmailTemplate,
8395
8876
  getPublicAdminAuthConfig,
8396
8877
  getTrashEntryId,
8878
+ heading,
8397
8879
  includes,
8398
8880
  initializeWorkflowDocument,
8399
8881
  isAICollection,
@@ -8406,6 +8888,7 @@ function defineTab(args) {
8406
8888
  isUserAdmin,
8407
8889
  join,
8408
8890
  last,
8891
+ layout,
8409
8892
  length,
8410
8893
  lower,
8411
8894
  maskEmail,
@@ -8421,6 +8904,7 @@ function defineTab(args) {
8421
8904
  normalizeGroupKey,
8422
8905
  now,
8423
8906
  numericFieldNames,
8907
+ paragraph,
8424
8908
  parseCron,
8425
8909
  parseMongoWhere,
8426
8910
  parseSort,
@@ -8437,21 +8921,27 @@ function defineTab(args) {
8437
8921
  resolveAdminAuthCollection,
8438
8922
  resolveAllCollectionViews,
8439
8923
  resolveAuthorizedSiteId,
8924
+ resolveBlocks,
8440
8925
  resolveDocumentTitle,
8441
8926
  resolveIndexName,
8442
8927
  resolvePrefix,
8443
8928
  resolveTrashConfig,
8444
8929
  restoreDocument,
8445
8930
  round,
8931
+ row,
8446
8932
  runCollectionHooks,
8933
+ safeLinkDetailBox,
8447
8934
  sanitizeDocForAI,
8448
8935
  saveWorkflowDraft,
8936
+ sectionLabel,
8449
8937
  simplePublishingWorkflow,
8450
8938
  slugify,
8451
8939
  softDeleteDocument,
8940
+ spacer,
8452
8941
  sqrt,
8453
8942
  startsWith,
8454
8943
  sum,
8944
+ table,
8455
8945
  today,
8456
8946
  transitionWorkflow,
8457
8947
  trim,