@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/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],
@@ -4822,6 +5247,10 @@ function parseSqlWhere(where, getJsonField, placeholder = "?", likeOperator = pl
4822
5247
  }
4823
5248
  return `${c} = ${next()}`;
4824
5249
  case "not_equals":
5250
+ case "not":
5251
+ case "ne":
5252
+ case "not_equal":
5253
+ case "notEquals":
4825
5254
  if (operand === null) {
4826
5255
  return `${c} IS NOT NULL`;
4827
5256
  }
@@ -4837,9 +5266,33 @@ function parseSqlWhere(where, getJsonField, placeholder = "?", likeOperator = pl
4837
5266
  } else {
4838
5267
  params.push(operand);
4839
5268
  }
4840
- } else {
4841
- params.push(operand);
5269
+ return `${c} != ${next()}`;
5270
+ }
5271
+ if (Array.isArray(operand)) {
5272
+ if (operand.length === 0) return "1=1";
5273
+ const placeholders = operand.map((v) => {
5274
+ if (typeof v === "boolean") {
5275
+ if (placeholder === "?") {
5276
+ params.push(v ? 1 : 0);
5277
+ } else if (isJsonExtract) {
5278
+ params.push(String(v));
5279
+ } else {
5280
+ params.push(v);
5281
+ }
5282
+ } else {
5283
+ params.push(v);
5284
+ }
5285
+ return next();
5286
+ });
5287
+ return `${c} NOT IN (${placeholders.join(", ")})`;
4842
5288
  }
5289
+ if (typeof operand === "object") {
5290
+ const innerEntries = Object.entries(operand);
5291
+ if (innerEntries.length === 0) return "1=1";
5292
+ const innerSql = innerEntries.map(([innerOp, innerOperand]) => buildSingleOp(c, innerOp, innerOperand)).join(" AND ");
5293
+ return `NOT (${innerSql})`;
5294
+ }
5295
+ params.push(operand);
4843
5296
  return `${c} != ${next()}`;
4844
5297
  case "in": {
4845
5298
  const vals = Array.isArray(operand) ? operand : [operand];
@@ -4910,6 +5363,15 @@ function parseSqlWhere(where, getJsonField, placeholder = "?", likeOperator = pl
4910
5363
  } else if (upperField === "AND" && Array.isArray(value)) {
4911
5364
  const sub = value.map((v) => `(${buildClause(v)})`).join(" AND ");
4912
5365
  parts.push(`(${sub})`);
5366
+ } else if (upperField === "NOT") {
5367
+ if (Array.isArray(value)) {
5368
+ const sub = value.map((v) => `(${buildClause(v)})`).join(" AND ");
5369
+ parts.push(`(NOT (${sub}))`);
5370
+ } else if (typeof value === "object" && value !== null && !("equals" in value || "not_equals" in value || "not" in value || "ne" in value || "not_equal" in value || "notEquals" in value || "in" in value || "not_in" in value || "gt" in value || "gte" in value || "lt" in value || "lte" in value || "contains" in value || "like" in value || "starts_with" in value || "exists" in value)) {
5371
+ parts.push(`(NOT (${buildClause(value)}))`);
5372
+ } else {
5373
+ parts.push(buildOperator(field, value));
5374
+ }
4913
5375
  } else {
4914
5376
  parts.push(buildOperator(field, value));
4915
5377
  }
@@ -4940,7 +5402,28 @@ function parseMongoWhere(where) {
4940
5402
  case "equals":
4941
5403
  return { [field]: { $eq: operand } };
4942
5404
  case "not_equals":
5405
+ case "not":
5406
+ case "ne":
5407
+ case "not_equal":
5408
+ case "notEquals": {
5409
+ if (Array.isArray(operand)) {
5410
+ return { [field]: { $nin: operand } };
5411
+ }
5412
+ if (typeof operand === "object" && operand !== null) {
5413
+ const innerEntries = Object.entries(operand);
5414
+ if (innerEntries.length === 1) {
5415
+ const [innerOp, innerOperand] = innerEntries[0];
5416
+ const inner = buildSingleOp(field, innerOp, innerOperand)[field];
5417
+ return { [field]: { $not: inner } };
5418
+ }
5419
+ const merged = {};
5420
+ for (const [innerOp, innerOperand] of innerEntries) {
5421
+ Object.assign(merged, buildSingleOp(field, innerOp, innerOperand)[field]);
5422
+ }
5423
+ return { [field]: { $not: merged } };
5424
+ }
4943
5425
  return { [field]: { $ne: operand } };
5426
+ }
4944
5427
  case "in":
4945
5428
  return { [field]: { $in: Array.isArray(operand) ? operand : [operand] } };
4946
5429
  case "not_in":
@@ -4978,6 +5461,14 @@ function parseMongoWhere(where) {
4978
5461
  conditions.push({ $or: value.map(buildClause) });
4979
5462
  } else if (upperField === "AND" && Array.isArray(value)) {
4980
5463
  conditions.push({ $and: value.map(buildClause) });
5464
+ } else if (upperField === "NOT") {
5465
+ if (Array.isArray(value)) {
5466
+ conditions.push({ $nor: value.map(buildClause) });
5467
+ } else if (typeof value === "object" && value !== null && !("equals" in value || "not_equals" in value || "not" in value || "ne" in value || "not_equal" in value || "notEquals" in value || "in" in value || "not_in" in value || "gt" in value || "gte" in value || "lt" in value || "lte" in value || "contains" in value || "like" in value || "starts_with" in value || "exists" in value)) {
5468
+ conditions.push({ $nor: [buildClause(value)] });
5469
+ } else {
5470
+ conditions.push(buildOperator(field, value));
5471
+ }
4981
5472
  } else {
4982
5473
  conditions.push(buildOperator(field, value));
4983
5474
  }
@@ -4999,7 +5490,7 @@ function coerceBooleanWhere(where, fields) {
4999
5490
  if (!node || typeof node !== "object") return node;
5000
5491
  const out = {};
5001
5492
  for (const [key, value] of Object.entries(node)) {
5002
- if (key.toUpperCase() === "AND" || key.toUpperCase() === "OR") {
5493
+ if (key.toUpperCase() === "AND" || key.toUpperCase() === "OR" || key.toUpperCase() === "NOT") {
5003
5494
  out[key] = walk(value);
5004
5495
  } else if (booleanFields.has(key)) {
5005
5496
  if (value && typeof value === "object" && !Array.isArray(value)) {
@@ -6030,6 +6521,31 @@ function generateOpenApi(config) {
6030
6521
  spec.paths[`${path}/accept-invite`] = {
6031
6522
  post: publicAuthPost("Accept an invitation")
6032
6523
  };
6524
+ spec.paths[`${path}/tokens/verify`] = {
6525
+ get: {
6526
+ tags: [collectionTag],
6527
+ summary: `Verify an invite or password reset token`,
6528
+ parameters: [
6529
+ {
6530
+ name: "token",
6531
+ in: "query",
6532
+ required: true,
6533
+ schema: { type: "string" }
6534
+ },
6535
+ {
6536
+ name: "purpose",
6537
+ in: "query",
6538
+ required: false,
6539
+ schema: { type: "string", enum: ["invite", "reset"] }
6540
+ }
6541
+ ],
6542
+ responses: {
6543
+ 200: {
6544
+ description: "Token verification result"
6545
+ }
6546
+ }
6547
+ }
6548
+ };
6033
6549
  spec.paths[`${path}/{id}/change-password`] = {
6034
6550
  post: {
6035
6551
  tags: [collectionTag],
@@ -7227,8 +7743,8 @@ function sanitizeDocForAI(options) {
7227
7743
  // src/utils/index-name.ts
7228
7744
  import { createHash } from "crypto";
7229
7745
  var MAX_INDEX_NAME_LENGTH = 60;
7230
- function resolveIndexName(prefix, table, fields) {
7231
- const full = `${prefix}_${table}_${fields.join("_")}`;
7746
+ function resolveIndexName(prefix, table2, fields) {
7747
+ const full = `${prefix}_${table2}_${fields.join("_")}`;
7232
7748
  if (full.length <= MAX_INDEX_NAME_LENGTH) return full;
7233
7749
  const hash = createHash("sha1").update(full).digest("hex").slice(0, 10);
7234
7750
  const tail = (fields[fields.length - 1] ?? "").slice(0, 30);
@@ -7700,7 +8216,7 @@ function compileNavigation(config) {
7700
8216
  const mentionedTrash = explicitNav.some((i) => i.trash || i.slug === "trash");
7701
8217
  if (!mentionedTrash) {
7702
8218
  const hasTrash = collections.some(
7703
- (col) => !col.slug.startsWith("__") && resolveTrashConfig(col, config).enabled
8219
+ (col) => !INTERNAL_SYSTEM_COLLECTIONS.has(col.slug) && resolveTrashConfig(col, config).enabled
7704
8220
  );
7705
8221
  if (hasTrash) {
7706
8222
  registerItem(void 0, {
@@ -8030,8 +8546,11 @@ export {
8030
8546
  DuplicateKeyError,
8031
8547
  DyrectedAIError,
8032
8548
  DyrectedError,
8549
+ EMAIL_TEMPLATES_COLLECTION_CONFIG,
8550
+ EMAIL_TEMPLATES_COLLECTION_SLUG,
8033
8551
  FieldConditionBuilder,
8034
8552
  ForbiddenError,
8553
+ INTERNAL_SYSTEM_COLLECTIONS,
8035
8554
  LIFECYCLE_EVENTS_COLLECTION,
8036
8555
  LIFECYCLE_EVENT_NAMES,
8037
8556
  MatchBuilder,
@@ -8049,6 +8568,7 @@ export {
8049
8568
  WORKFLOW_HISTORY_COLLECTION,
8050
8569
  abs,
8051
8570
  addDays,
8571
+ alertBox,
8052
8572
  asCollection,
8053
8573
  assertValidAdminConditionsInConfig,
8054
8574
  assertValidDeclarativeAccessInConfig,
@@ -8078,6 +8598,7 @@ export {
8078
8598
  createTaskRunner,
8079
8599
  createTrashPurgeTask,
8080
8600
  createWorkflowDocument,
8601
+ ctaButton,
8081
8602
  defaultValue,
8082
8603
  defineAction,
8083
8604
  defineArrayField,
@@ -8124,6 +8645,7 @@ export {
8124
8645
  defineUrlField,
8125
8646
  defineView,
8126
8647
  defineWorkspace,
8648
+ detailBox,
8127
8649
  diffDays,
8128
8650
  dispatchLifecycleEvent,
8129
8651
  dispatchPendingLifecycleEvents,
@@ -8139,10 +8661,14 @@ export {
8139
8661
  displayTab,
8140
8662
  displayTabs,
8141
8663
  displayText,
8664
+ divider,
8665
+ emailTokens,
8142
8666
  endsWith,
8667
+ escapeHtml,
8143
8668
  evaluateDetailComputed,
8144
8669
  evaluateJexl,
8145
8670
  evaluateJexlSync,
8671
+ eventList,
8146
8672
  executeFieldAfterRead,
8147
8673
  executeFieldBeforeChange,
8148
8674
  expr,
@@ -8159,8 +8685,10 @@ export {
8159
8685
  getAdminAuthCollection,
8160
8686
  getAdminRoleForCollection,
8161
8687
  getAllowedSitesForUser,
8688
+ getDefaultEmailTemplate,
8162
8689
  getPublicAdminAuthConfig,
8163
8690
  getTrashEntryId,
8691
+ heading,
8164
8692
  includes,
8165
8693
  initializeWorkflowDocument,
8166
8694
  isAICollection,
@@ -8173,6 +8701,7 @@ export {
8173
8701
  isUserAdmin,
8174
8702
  join,
8175
8703
  last,
8704
+ layout,
8176
8705
  length,
8177
8706
  lower,
8178
8707
  maskEmail,
@@ -8188,6 +8717,7 @@ export {
8188
8717
  normalizeGroupKey,
8189
8718
  now,
8190
8719
  numericFieldNames,
8720
+ paragraph,
8191
8721
  parseCron,
8192
8722
  parseMongoWhere,
8193
8723
  parseSort,
@@ -8204,21 +8734,27 @@ export {
8204
8734
  resolveAdminAuthCollection,
8205
8735
  resolveAllCollectionViews,
8206
8736
  resolveAuthorizedSiteId,
8737
+ resolveBlocks,
8207
8738
  resolveDocumentTitle,
8208
8739
  resolveIndexName,
8209
8740
  resolvePrefix,
8210
8741
  resolveTrashConfig,
8211
8742
  restoreDocument,
8212
8743
  round,
8744
+ row,
8213
8745
  runCollectionHooks,
8746
+ safeLinkDetailBox,
8214
8747
  sanitizeDocForAI,
8215
8748
  saveWorkflowDraft,
8749
+ sectionLabel,
8216
8750
  simplePublishingWorkflow,
8217
8751
  slugify,
8218
8752
  softDeleteDocument,
8753
+ spacer,
8219
8754
  sqrt,
8220
8755
  startsWith,
8221
8756
  sum,
8757
+ table,
8222
8758
  today,
8223
8759
  transitionWorkflow,
8224
8760
  trim,