@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.js CHANGED
@@ -3011,6 +3011,14 @@ async function executeFieldAfterRead(fields, doc, user, db) {
3011
3011
 
3012
3012
  // src/trash.ts
3013
3013
  var TRASH_COLLECTION = "__trash";
3014
+ var INTERNAL_SYSTEM_COLLECTIONS = /* @__PURE__ */ new Set([
3015
+ "__audit",
3016
+ "__sessions",
3017
+ "__task_locks",
3018
+ "__trash",
3019
+ "__workflow_history",
3020
+ "__lifecycle_events"
3021
+ ]);
3014
3022
  var TRASH_COLLECTION_CONFIG = {
3015
3023
  slug: TRASH_COLLECTION,
3016
3024
  labels: { singular: "Trash entry", plural: "Trash entries" },
@@ -3102,7 +3110,7 @@ function assertValidTrashInConfig(config, source = "config") {
3102
3110
  if (col.slug === "trash") {
3103
3111
  throw new Error(`Collection slug "trash" is reserved by Dyrected for the trash system.`);
3104
3112
  }
3105
- const isSystem = col.slug.startsWith("__");
3113
+ const isSystem = INTERNAL_SYSTEM_COLLECTIONS.has(col.slug);
3106
3114
  if (isSystem && col.trash) {
3107
3115
  throw new Error(`System collection "${col.slug}" cannot enable trash.`);
3108
3116
  }
@@ -3481,7 +3489,7 @@ function createTaskRunner(config) {
3481
3489
  throw error;
3482
3490
  }
3483
3491
  }
3484
- async function execute(task, row, scheduledFor) {
3492
+ async function execute(task, row2, scheduledFor) {
3485
3493
  const started = Date.now();
3486
3494
  const lockMs = (task.lockTimeout ?? DEFAULT_LOCK_TIMEOUT_SECONDS) * 1e3;
3487
3495
  const token = `${instanceId}:${started}`;
@@ -3489,7 +3497,7 @@ function createTaskRunner(config) {
3489
3497
  id: { equals: task.name },
3490
3498
  lockedUntil: { less_than: started }
3491
3499
  };
3492
- if (scheduledFor) where.lastScheduledFor = { equals: row.lastScheduledFor };
3500
+ if (scheduledFor) where.lastScheduledFor = { equals: row2.lastScheduledFor };
3493
3501
  const claimed = await db.update({
3494
3502
  collection: TASK_LOCKS_COLLECTION,
3495
3503
  where,
@@ -3550,21 +3558,21 @@ function createTaskRunner(config) {
3550
3558
  async runTask(name) {
3551
3559
  const task = tasks.get(name);
3552
3560
  if (!task) throw new Error(`Unknown task "${name}".`);
3553
- const row = await ensureLockRow(name, /* @__PURE__ */ new Date());
3554
- return track(execute(task, row, void 0));
3561
+ const row2 = await ensureLockRow(name, /* @__PURE__ */ new Date());
3562
+ return track(execute(task, row2, void 0));
3555
3563
  },
3556
3564
  async runDue(now2 = /* @__PURE__ */ new Date()) {
3557
3565
  const results = [];
3558
3566
  for (const task of tasks.values()) {
3559
3567
  if (!task.schedule) continue;
3560
- const row = await ensureLockRow(task.name, now2);
3561
- const reference = new Date(String(row.lastScheduledFor));
3568
+ const row2 = await ensureLockRow(task.name, now2);
3569
+ const reference = new Date(String(row2.lastScheduledFor));
3562
3570
  const due = Number.isNaN(reference.getTime()) ? true : nextCronRun(task.schedule, reference).getTime() <= now2.getTime();
3563
3571
  if (!due) {
3564
3572
  results.push({ name: task.name, status: "skipped", reason: "not-due", durationMs: 0 });
3565
3573
  continue;
3566
3574
  }
3567
- results.push(await track(execute(task, row, floorToMinute(now2))));
3575
+ results.push(await track(execute(task, row2, floorToMinute(now2))));
3568
3576
  }
3569
3577
  return results;
3570
3578
  },
@@ -4079,13 +4087,34 @@ function getAdminRoleForCollection(collection) {
4079
4087
  }
4080
4088
  return "admin";
4081
4089
  }
4090
+ var STANDARD_ADMIN_ROLES = /* @__PURE__ */ new Set([
4091
+ "admin",
4092
+ "super_admin",
4093
+ "superadmin",
4094
+ "super admin",
4095
+ "super-admin",
4096
+ "owner"
4097
+ ]);
4082
4098
  function isUserAdmin(user, collection) {
4083
4099
  if (!user) return false;
4084
- const roles = Array.isArray(user.roles) ? user.roles : typeof user.role === "string" ? [user.role] : [];
4085
- if (roles.length === 0) return false;
4100
+ 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] : [];
4101
+ if (rawRoles.length === 0) return false;
4102
+ const roles = rawRoles.filter((r) => typeof r === "string");
4086
4103
  const configuredRole = getAdminRoleForCollection(collection);
4087
- if (roles.includes(configuredRole)) return true;
4088
- return roles.includes("admin") || roles.includes("super_admin");
4104
+ for (const r of roles) {
4105
+ if (r === configuredRole || r.toLowerCase() === configuredRole.toLowerCase()) {
4106
+ return true;
4107
+ }
4108
+ const normalized = r.trim().toLowerCase();
4109
+ if (STANDARD_ADMIN_ROLES.has(normalized)) {
4110
+ return true;
4111
+ }
4112
+ const stripped = normalized.replace(/[\s_-]+/g, "");
4113
+ if (stripped === "admin" || stripped === "superadmin" || stripped === "owner") {
4114
+ return true;
4115
+ }
4116
+ }
4117
+ return false;
4089
4118
  }
4090
4119
 
4091
4120
  // src/utils/block-references.ts
@@ -4308,6 +4337,245 @@ function generateDocumentId(collectionOrSlug) {
4308
4337
  return generateId({ prefix, type });
4309
4338
  }
4310
4339
 
4340
+ // src/services/email-template.ts
4341
+ var emailTokens = {
4342
+ colors: {
4343
+ canvas: "#f6f7f2",
4344
+ surface: "#ffffff",
4345
+ text: "#171717",
4346
+ muted: "#62665b",
4347
+ subtle: "#8a8f82",
4348
+ border: "#dde0d7",
4349
+ accent: "#b6ff2e",
4350
+ code: "#f1f3ec",
4351
+ codeBorder: "#e2e5dc",
4352
+ dangerSurface: "#fff2f0",
4353
+ dangerBorder: "#ffc9c2",
4354
+ dangerText: "#9f251b"
4355
+ },
4356
+ font: "Arial, 'Helvetica Neue', Helvetica, sans-serif",
4357
+ mono: "'Courier New', Courier, monospace",
4358
+ radius: { card: "12px", control: "6px" },
4359
+ width: "600px"
4360
+ };
4361
+ function escapeHtml(value) {
4362
+ return value.replace(
4363
+ /[&<>'"]/g,
4364
+ (character) => ({
4365
+ "&": "&amp;",
4366
+ "<": "&lt;",
4367
+ ">": "&gt;",
4368
+ "'": "&#39;",
4369
+ '"': "&quot;"
4370
+ })[character]
4371
+ );
4372
+ }
4373
+ function safeHttpUrl(value) {
4374
+ if (value.startsWith("{{") && value.endsWith("}}")) {
4375
+ return value;
4376
+ }
4377
+ try {
4378
+ const url = new URL(value);
4379
+ return url.protocol === "https:" || url.protocol === "http:" ? escapeHtml(url.toString()) : void 0;
4380
+ } catch {
4381
+ return void 0;
4382
+ }
4383
+ }
4384
+ function heading(content) {
4385
+ 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>`;
4386
+ }
4387
+ function paragraph(content, margin = "0 0 16px", size = "15px") {
4388
+ return `<p style="margin:${margin};font-family:${emailTokens.font};font-size:${size};line-height:1.6;color:${emailTokens.colors.muted}">${escapeHtml(content)}</p>`;
4389
+ }
4390
+ function sectionLabel(content) {
4391
+ 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>`;
4392
+ }
4393
+ function divider() {
4394
+ 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>`;
4395
+ }
4396
+ function spacer(height = 16) {
4397
+ return table(row("&nbsp;", `height:${height}px;font-size:0;line-height:0`));
4398
+ }
4399
+ function row(content, cellStyle = "") {
4400
+ return `<tr><td style="${cellStyle}">${content}</td></tr>`;
4401
+ }
4402
+ function table(content, style = "width:100%") {
4403
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="${style}">${content}</table>`;
4404
+ }
4405
+ function detailBox(content, monospace = false) {
4406
+ const font = monospace ? emailTokens.mono : emailTokens.font;
4407
+ return table(
4408
+ row(
4409
+ escapeHtml(content),
4410
+ `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`
4411
+ ),
4412
+ `width:100%;background:${emailTokens.colors.code};border:1px solid ${emailTokens.colors.codeBorder};border-radius:${emailTokens.radius.control}`
4413
+ );
4414
+ }
4415
+ function eventList(events) {
4416
+ return table(
4417
+ events.map(
4418
+ ({ label, value }) => row(
4419
+ `${sectionLabel(label)}${paragraph(value, "0")}`,
4420
+ `padding:12px 0;border-bottom:1px solid ${emailTokens.colors.border}`
4421
+ )
4422
+ ).join("")
4423
+ );
4424
+ }
4425
+ function ctaButton(label, href) {
4426
+ const safeHref = safeHttpUrl(href);
4427
+ if (!safeHref) return "";
4428
+ return table(
4429
+ row(
4430
+ `<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>`,
4431
+ "padding:8px 0 24px"
4432
+ ),
4433
+ "width:auto"
4434
+ );
4435
+ }
4436
+ function safeLinkDetailBox(href) {
4437
+ const safeHref = safeHttpUrl(href);
4438
+ if (!safeHref) return "";
4439
+ return detailBox(safeHref, true);
4440
+ }
4441
+ function alertBox(content) {
4442
+ return table(
4443
+ row(
4444
+ escapeHtml(content),
4445
+ `padding:13px 16px;font-family:${emailTokens.font};font-size:13px;line-height:1.5;color:${emailTokens.colors.dangerText}`
4446
+ ),
4447
+ `width:100%;background:${emailTokens.colors.dangerSurface};border:1px solid ${emailTokens.colors.dangerBorder};border-radius:${emailTokens.radius.control}`
4448
+ );
4449
+ }
4450
+ function resolveBlocks(input) {
4451
+ if (Array.isArray(input)) {
4452
+ const parts = [];
4453
+ for (const item of input) {
4454
+ if (Array.isArray(item)) {
4455
+ for (const sub of item) {
4456
+ if (typeof sub === "string" && sub.length > 0) parts.push(sub);
4457
+ }
4458
+ } else if (typeof item === "string" && item.length > 0) {
4459
+ parts.push(item);
4460
+ }
4461
+ }
4462
+ return parts.join("\n");
4463
+ }
4464
+ return typeof input === "string" ? input : "";
4465
+ }
4466
+ function layout({ preheader, title, content, footer, siteName }) {
4467
+ const resolvedContent = resolveBlocks(content);
4468
+ const resolvedFooter = resolveBlocks(footer);
4469
+ return `<!doctype html>
4470
+ <html lang="en" dir="ltr" xmlns="http://www.w3.org/1999/xhtml">
4471
+ <head>
4472
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
4473
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
4474
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
4475
+ <meta name="x-apple-disable-message-reformatting">
4476
+ <meta name="format-detection" content="telephone=no,address=no,email=no,date=no,url=no">
4477
+ <meta name="color-scheme" content="light">
4478
+ <meta name="supported-color-schemes" content="light">
4479
+ <title>${escapeHtml(title)}</title>
4480
+ </head>
4481
+ <body style="margin:0;padding:0;background-color:${emailTokens.colors.canvas};word-spacing:normal;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%">
4482
+ <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">
4483
+ ${escapeHtml(preheader)}&#8203;&#847;&zwnj;&nbsp;&#8199;&#8199;&#847;&zwnj;&nbsp;
4484
+ </div>
4485
+ <center lang="en" style="width:100%;background-color:${emailTokens.colors.canvas}">
4486
+ ${table(
4487
+ row(
4488
+ table(
4489
+ 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(
4490
+ `${divider()}${paragraph(resolvedFooter, "20px 0 6px")}${paragraph("Privacy: this message contains account-related information; please avoid forwarding it.", "10px 0px 0px 0px", "12px")}`,
4491
+ "padding:0 32px 28px"
4492
+ ),
4493
+ `width:100%;max-width:${emailTokens.width};background:${emailTokens.colors.surface};border:1px solid ${emailTokens.colors.border};border-radius:${emailTokens.radius.card};overflow:hidden`
4494
+ ),
4495
+ "padding:32px 12px"
4496
+ ),
4497
+ `width:100%;background:${emailTokens.colors.canvas}`
4498
+ )}
4499
+ </center>
4500
+ </body>
4501
+ </html>`;
4502
+ }
4503
+ function getDefaultEmailTemplate(purpose, options) {
4504
+ const siteName = options?.siteName ?? "Dyrected";
4505
+ switch (purpose) {
4506
+ case "invite":
4507
+ return {
4508
+ subject: `You've been invited to ${siteName}`,
4509
+ rawHtml: layout({
4510
+ siteName,
4511
+ preheader: `You've been invited to join ${siteName}.`,
4512
+ title: "You've been invited",
4513
+ content: [
4514
+ paragraph("Use the invitation link below to create your account. The link expires in 7 days."),
4515
+ ctaButton("Accept invitation", "{{url}}"),
4516
+ sectionLabel("Invitation link"),
4517
+ safeLinkDetailBox("{{url}}"),
4518
+ paragraph("If the button does not work, copy and paste the link above into your browser.", "12px 0 0")
4519
+ ],
4520
+ footer: "If you weren't expecting this invitation, you can safely ignore this email."
4521
+ })
4522
+ };
4523
+ case "resetPassword":
4524
+ return {
4525
+ subject: "Reset your password",
4526
+ rawHtml: layout({
4527
+ siteName,
4528
+ preheader: `Reset your ${siteName} password.`,
4529
+ title: "Reset your password",
4530
+ content: [
4531
+ paragraph("We received a request to reset your password. The reset link expires in 1 hour."),
4532
+ ctaButton("Reset password", "{{url}}"),
4533
+ sectionLabel("Reset link"),
4534
+ safeLinkDetailBox("{{url}}"),
4535
+ paragraph("If the button does not work, copy and paste the link above into your browser.", "12px 0 0")
4536
+ ],
4537
+ footer: "If you didn't request a password reset, you can safely ignore this email."
4538
+ })
4539
+ };
4540
+ case "welcome":
4541
+ return {
4542
+ subject: `Welcome to ${siteName} \u2014 your account is ready`,
4543
+ rawHtml: layout({
4544
+ siteName,
4545
+ preheader: `Your ${siteName} account is ready.`,
4546
+ title: "Welcome \u2014 your account is ready",
4547
+ content: [paragraph("Your account has been created. You can now log in with:"), detailBox("{{email}}")],
4548
+ footer: "If you didn't create this account, you can safely ignore this email."
4549
+ })
4550
+ };
4551
+ case "passwordChanged":
4552
+ return {
4553
+ subject: "Your password has been changed",
4554
+ rawHtml: layout({
4555
+ siteName,
4556
+ preheader: `Your ${siteName} password was changed.`,
4557
+ title: "Password changed",
4558
+ content: [
4559
+ paragraph("The password for your account has been changed."),
4560
+ alertBox("If you did not make this change, please contact support or reset your password immediately.")
4561
+ ],
4562
+ footer: "Security notice: this confirms a recent credential change."
4563
+ })
4564
+ };
4565
+ default:
4566
+ return {
4567
+ subject: `Notification from ${siteName}`,
4568
+ rawHtml: layout({
4569
+ siteName,
4570
+ preheader: `Account notification from ${siteName}.`,
4571
+ title: "Account Notification",
4572
+ content: [paragraph(`This is an account notification from ${siteName}.`)],
4573
+ footer: "If you have any questions, please reach out to our team."
4574
+ })
4575
+ };
4576
+ }
4577
+ }
4578
+
4311
4579
  // src/utils/config.ts
4312
4580
  var AUDIT_COLLECTION_SLUG = "__audit";
4313
4581
  var SYSTEM_FIELDS = [
@@ -4449,6 +4717,114 @@ var AUTH_SESSIONS_COLLECTION_CONFIG = {
4449
4717
  },
4450
4718
  admin: { hidden: true }
4451
4719
  };
4720
+ var EMAIL_TEMPLATES_COLLECTION_SLUG = "__email_templates";
4721
+ var EMAIL_TEMPLATES_COLLECTION_CONFIG = {
4722
+ slug: EMAIL_TEMPLATES_COLLECTION_SLUG,
4723
+ labels: { singular: "Email Template", plural: "Email Templates" },
4724
+ detail: false,
4725
+ fields: [
4726
+ {
4727
+ name: "collectionSlug",
4728
+ type: "select",
4729
+ label: "Applies To",
4730
+ required: true,
4731
+ defaultValue: "*",
4732
+ options: [
4733
+ { label: "Global (All Users & Collections)", value: "*" }
4734
+ ],
4735
+ admin: {
4736
+ description: "Choose which user collection this template applies to, or 'Global' for all users."
4737
+ }
4738
+ },
4739
+ {
4740
+ name: "purpose",
4741
+ type: "select",
4742
+ label: "Purpose",
4743
+ required: true,
4744
+ options: [
4745
+ { label: "Invite", value: "invite" },
4746
+ { label: "Password Reset", value: "resetPassword" },
4747
+ { label: "Welcome", value: "welcome" },
4748
+ { label: "Password Changed", value: "passwordChanged" }
4749
+ ],
4750
+ admin: { width: "50%" }
4751
+ },
4752
+ {
4753
+ name: "format",
4754
+ type: "select",
4755
+ label: "Format",
4756
+ required: true,
4757
+ defaultValue: "html",
4758
+ options: [
4759
+ { label: "HTML", value: "html" },
4760
+ { label: "External Template", value: "external_template" }
4761
+ ],
4762
+ admin: { width: "50%" }
4763
+ },
4764
+ {
4765
+ name: "subject",
4766
+ type: "text",
4767
+ label: "Subject",
4768
+ admin: {
4769
+ description: "Subject line (optional when using external provider template)"
4770
+ }
4771
+ },
4772
+ {
4773
+ name: "rawHtml",
4774
+ type: "textarea",
4775
+ label: "HTML Content",
4776
+ admin: {
4777
+ description: "Raw HTML email content with {{variable}} tags"
4778
+ }
4779
+ },
4780
+ {
4781
+ name: "externalTemplateId",
4782
+ type: "text",
4783
+ label: "External Template ID",
4784
+ admin: {
4785
+ description: "Template ID or alias from Seamailer, Postmark, SendGrid, etc."
4786
+ }
4787
+ },
4788
+ {
4789
+ name: "active",
4790
+ type: "boolean",
4791
+ label: "Active",
4792
+ defaultValue: true,
4793
+ admin: {
4794
+ description: "When active, this template overrides code config. When disabled, falls back to code."
4795
+ }
4796
+ }
4797
+ ],
4798
+ admin: {
4799
+ group: "Settings",
4800
+ icon: "Mail",
4801
+ groupBy: "collectionSlug",
4802
+ defaultColumns: ["purpose", "collectionSlug", "format", "active"]
4803
+ },
4804
+ hooks: {
4805
+ beforeChange: [
4806
+ async ({ data, req }) => {
4807
+ if (data && data.format === "html") {
4808
+ const siteName = req?.config?.admin?.branding?.logoText || "Dyrected";
4809
+ const defaults = getDefaultEmailTemplate(data.purpose || "invite", { siteName });
4810
+ if (!data.subject) {
4811
+ data.subject = defaults.subject;
4812
+ }
4813
+ if (!data.rawHtml) {
4814
+ data.rawHtml = defaults.rawHtml;
4815
+ }
4816
+ }
4817
+ return data;
4818
+ }
4819
+ ]
4820
+ },
4821
+ access: {
4822
+ read: ({ user }) => isUserAdmin(user),
4823
+ create: ({ user }) => isUserAdmin(user),
4824
+ update: ({ user }) => isUserAdmin(user),
4825
+ delete: ({ user }) => isUserAdmin(user)
4826
+ }
4827
+ };
4452
4828
  function normalizeConfig(config) {
4453
4829
  const schemaAwareConfig = normalizeSchemaFragment(config);
4454
4830
  assertValidDeclarativeHooksInConfig(schemaAwareConfig, "config");
@@ -4463,10 +4839,13 @@ function normalizeConfig(config) {
4463
4839
  const needsTasks = (config.tasks?.length ?? 0) > 0;
4464
4840
  const needsAuthSessions = collections.some((col) => !!col.auth);
4465
4841
  const needsTrash = schemaAwareConfig.trash?.enabled === true || collections.some((col) => resolveTrashConfig(col, schemaAwareConfig).enabled);
4466
- const adminAuthCollectionSlug = getAdminAuthCollection({
4842
+ const adminAuthCollection = getAdminAuthCollection({
4467
4843
  collections,
4468
4844
  adminAuth: schemaAwareConfig.adminAuth
4469
- })?.slug;
4845
+ });
4846
+ const adminAuthCollectionSlug = adminAuthCollection?.slug;
4847
+ const adminOnly = ({ user }) => isUserAdmin(user, adminAuthCollection);
4848
+ const adminOtherThanSelf = ({ user, id }) => isUserAdmin(user, adminAuthCollection) && user?.id !== id;
4470
4849
  const normalizedCollections = collections.map((col) => {
4471
4850
  let fields = col.fields || [];
4472
4851
  const existingFieldNames = new Set(fields.map((f) => f.name));
@@ -4515,7 +4894,7 @@ function normalizeConfig(config) {
4515
4894
  { value: "viewer", label: "Viewer" }
4516
4895
  ],
4517
4896
  access: {
4518
- update: "user.role == 'admin' || (user.roles != null && 'admin' in user.roles)"
4897
+ update: adminOnly
4519
4898
  }
4520
4899
  }
4521
4900
  ];
@@ -4533,7 +4912,7 @@ function normalizeConfig(config) {
4533
4912
  { value: "pending", label: "Pending" }
4534
4913
  ],
4535
4914
  access: {
4536
- update: "(user.role == 'admin' || (user.roles != null && 'admin' in user.roles)) && user.id != id"
4915
+ update: adminOtherThanSelf
4537
4916
  },
4538
4917
  admin: {
4539
4918
  condition: `!(data.roles && "admin" in data.roles) && data.role != "admin"`
@@ -4602,7 +4981,7 @@ function normalizeConfig(config) {
4602
4981
  access: {
4603
4982
  ...field.access || {},
4604
4983
  // Must be an admin; cannot edit own roles (no self-elevation).
4605
- update: "(user.role == 'admin' || (user.roles != null && 'admin' in user.roles)) && user.id != id"
4984
+ update: adminOtherThanSelf
4606
4985
  }
4607
4986
  };
4608
4987
  }
@@ -4611,7 +4990,7 @@ function normalizeConfig(config) {
4611
4990
  ...field,
4612
4991
  access: {
4613
4992
  ...field.access || {},
4614
- update: "(user.role == 'admin' || (user.roles != null && 'admin' in user.roles)) && user.id != id"
4993
+ update: adminOtherThanSelf
4615
4994
  }
4616
4995
  };
4617
4996
  }
@@ -4703,6 +5082,52 @@ function normalizeConfig(config) {
4703
5082
  if (needsTrash && !normalizedCollections.some((col) => col.slug === TRASH_COLLECTION)) {
4704
5083
  systemCollections.push(TRASH_COLLECTION_CONFIG);
4705
5084
  }
5085
+ const needsEmailTemplates = schemaAwareConfig.email?.adminEditable === true;
5086
+ if (needsEmailTemplates && !normalizedCollections.some((col) => col.slug === EMAIL_TEMPLATES_COLLECTION_SLUG)) {
5087
+ const adminCol = getAdminAuthCollection(schemaAwareConfig);
5088
+ const customAccess = schemaAwareConfig.email?.access;
5089
+ const resolveOpAccess = (op) => {
5090
+ if (typeof customAccess === "function") return customAccess;
5091
+ if (customAccess && typeof customAccess === "object" && customAccess[op]) return customAccess[op];
5092
+ return ({ user }) => isUserAdmin(user, adminCol);
5093
+ };
5094
+ const customPurposes = schemaAwareConfig.email?.purposes ?? [];
5095
+ const purposeOptions = [
5096
+ { label: "Invite", value: "invite" },
5097
+ { label: "Password Reset", value: "resetPassword" },
5098
+ { label: "Welcome", value: "welcome" },
5099
+ { label: "Password Changed", value: "passwordChanged" },
5100
+ ...customPurposes
5101
+ ];
5102
+ const targetableCollections = normalizedCollections.filter(
5103
+ (c) => !c.slug.startsWith("__")
5104
+ );
5105
+ const audienceOptions = [
5106
+ { label: "Global (All Collections)", value: "*" },
5107
+ ...targetableCollections.map((c) => ({
5108
+ label: `${c.labels?.singular || c.labels?.plural || c.slug} (${c.slug})`,
5109
+ value: c.slug
5110
+ }))
5111
+ ];
5112
+ systemCollections.push({
5113
+ ...EMAIL_TEMPLATES_COLLECTION_CONFIG,
5114
+ fields: EMAIL_TEMPLATES_COLLECTION_CONFIG.fields.map((f) => {
5115
+ if (f.name === "collectionSlug") {
5116
+ return { ...f, options: audienceOptions };
5117
+ }
5118
+ if (f.name === "purpose") {
5119
+ return { ...f, options: purposeOptions };
5120
+ }
5121
+ return f;
5122
+ }),
5123
+ access: {
5124
+ read: resolveOpAccess("read"),
5125
+ create: resolveOpAccess("create"),
5126
+ update: resolveOpAccess("update"),
5127
+ delete: resolveOpAccess("delete")
5128
+ }
5129
+ });
5130
+ }
4706
5131
  return {
4707
5132
  ...schemaAwareConfig,
4708
5133
  collections: [...normalizedCollections, ...systemCollections],
@@ -6030,6 +6455,31 @@ function generateOpenApi(config) {
6030
6455
  spec.paths[`${path}/accept-invite`] = {
6031
6456
  post: publicAuthPost("Accept an invitation")
6032
6457
  };
6458
+ spec.paths[`${path}/tokens/verify`] = {
6459
+ get: {
6460
+ tags: [collectionTag],
6461
+ summary: `Verify an invite or password reset token`,
6462
+ parameters: [
6463
+ {
6464
+ name: "token",
6465
+ in: "query",
6466
+ required: true,
6467
+ schema: { type: "string" }
6468
+ },
6469
+ {
6470
+ name: "purpose",
6471
+ in: "query",
6472
+ required: false,
6473
+ schema: { type: "string", enum: ["invite", "reset"] }
6474
+ }
6475
+ ],
6476
+ responses: {
6477
+ 200: {
6478
+ description: "Token verification result"
6479
+ }
6480
+ }
6481
+ }
6482
+ };
6033
6483
  spec.paths[`${path}/{id}/change-password`] = {
6034
6484
  post: {
6035
6485
  tags: [collectionTag],
@@ -7227,8 +7677,8 @@ function sanitizeDocForAI(options) {
7227
7677
  // src/utils/index-name.ts
7228
7678
  import { createHash } from "crypto";
7229
7679
  var MAX_INDEX_NAME_LENGTH = 60;
7230
- function resolveIndexName(prefix, table, fields) {
7231
- const full = `${prefix}_${table}_${fields.join("_")}`;
7680
+ function resolveIndexName(prefix, table2, fields) {
7681
+ const full = `${prefix}_${table2}_${fields.join("_")}`;
7232
7682
  if (full.length <= MAX_INDEX_NAME_LENGTH) return full;
7233
7683
  const hash = createHash("sha1").update(full).digest("hex").slice(0, 10);
7234
7684
  const tail = (fields[fields.length - 1] ?? "").slice(0, 30);
@@ -7700,7 +8150,7 @@ function compileNavigation(config) {
7700
8150
  const mentionedTrash = explicitNav.some((i) => i.trash || i.slug === "trash");
7701
8151
  if (!mentionedTrash) {
7702
8152
  const hasTrash = collections.some(
7703
- (col) => !col.slug.startsWith("__") && resolveTrashConfig(col, config).enabled
8153
+ (col) => !INTERNAL_SYSTEM_COLLECTIONS.has(col.slug) && resolveTrashConfig(col, config).enabled
7704
8154
  );
7705
8155
  if (hasTrash) {
7706
8156
  registerItem(void 0, {
@@ -8030,8 +8480,11 @@ export {
8030
8480
  DuplicateKeyError,
8031
8481
  DyrectedAIError,
8032
8482
  DyrectedError,
8483
+ EMAIL_TEMPLATES_COLLECTION_CONFIG,
8484
+ EMAIL_TEMPLATES_COLLECTION_SLUG,
8033
8485
  FieldConditionBuilder,
8034
8486
  ForbiddenError,
8487
+ INTERNAL_SYSTEM_COLLECTIONS,
8035
8488
  LIFECYCLE_EVENTS_COLLECTION,
8036
8489
  LIFECYCLE_EVENT_NAMES,
8037
8490
  MatchBuilder,
@@ -8049,6 +8502,7 @@ export {
8049
8502
  WORKFLOW_HISTORY_COLLECTION,
8050
8503
  abs,
8051
8504
  addDays,
8505
+ alertBox,
8052
8506
  asCollection,
8053
8507
  assertValidAdminConditionsInConfig,
8054
8508
  assertValidDeclarativeAccessInConfig,
@@ -8078,6 +8532,7 @@ export {
8078
8532
  createTaskRunner,
8079
8533
  createTrashPurgeTask,
8080
8534
  createWorkflowDocument,
8535
+ ctaButton,
8081
8536
  defaultValue,
8082
8537
  defineAction,
8083
8538
  defineArrayField,
@@ -8124,6 +8579,7 @@ export {
8124
8579
  defineUrlField,
8125
8580
  defineView,
8126
8581
  defineWorkspace,
8582
+ detailBox,
8127
8583
  diffDays,
8128
8584
  dispatchLifecycleEvent,
8129
8585
  dispatchPendingLifecycleEvents,
@@ -8139,10 +8595,14 @@ export {
8139
8595
  displayTab,
8140
8596
  displayTabs,
8141
8597
  displayText,
8598
+ divider,
8599
+ emailTokens,
8142
8600
  endsWith,
8601
+ escapeHtml,
8143
8602
  evaluateDetailComputed,
8144
8603
  evaluateJexl,
8145
8604
  evaluateJexlSync,
8605
+ eventList,
8146
8606
  executeFieldAfterRead,
8147
8607
  executeFieldBeforeChange,
8148
8608
  expr,
@@ -8159,8 +8619,10 @@ export {
8159
8619
  getAdminAuthCollection,
8160
8620
  getAdminRoleForCollection,
8161
8621
  getAllowedSitesForUser,
8622
+ getDefaultEmailTemplate,
8162
8623
  getPublicAdminAuthConfig,
8163
8624
  getTrashEntryId,
8625
+ heading,
8164
8626
  includes,
8165
8627
  initializeWorkflowDocument,
8166
8628
  isAICollection,
@@ -8173,6 +8635,7 @@ export {
8173
8635
  isUserAdmin,
8174
8636
  join,
8175
8637
  last,
8638
+ layout,
8176
8639
  length,
8177
8640
  lower,
8178
8641
  maskEmail,
@@ -8188,6 +8651,7 @@ export {
8188
8651
  normalizeGroupKey,
8189
8652
  now,
8190
8653
  numericFieldNames,
8654
+ paragraph,
8191
8655
  parseCron,
8192
8656
  parseMongoWhere,
8193
8657
  parseSort,
@@ -8204,21 +8668,27 @@ export {
8204
8668
  resolveAdminAuthCollection,
8205
8669
  resolveAllCollectionViews,
8206
8670
  resolveAuthorizedSiteId,
8671
+ resolveBlocks,
8207
8672
  resolveDocumentTitle,
8208
8673
  resolveIndexName,
8209
8674
  resolvePrefix,
8210
8675
  resolveTrashConfig,
8211
8676
  restoreDocument,
8212
8677
  round,
8678
+ row,
8213
8679
  runCollectionHooks,
8680
+ safeLinkDetailBox,
8214
8681
  sanitizeDocForAI,
8215
8682
  saveWorkflowDraft,
8683
+ sectionLabel,
8216
8684
  simplePublishingWorkflow,
8217
8685
  slugify,
8218
8686
  softDeleteDocument,
8687
+ spacer,
8219
8688
  sqrt,
8220
8689
  startsWith,
8221
8690
  sum,
8691
+ table,
8222
8692
  today,
8223
8693
  transitionWorkflow,
8224
8694
  trim,