@dyrected/core 2.24.2 → 2.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.cjs CHANGED
@@ -1961,10 +1961,11 @@ var init_readonly_fix = __esm({
1961
1961
  });
1962
1962
 
1963
1963
  // src/types/navigation.ts
1964
- var CURRENT_NAV_PREFERENCES_VERSION, DEFAULT_USER_NAV_PREFERENCES;
1964
+ var NAVIGATION_POLICY_KEY, CURRENT_NAV_PREFERENCES_VERSION, DEFAULT_USER_NAV_PREFERENCES;
1965
1965
  var init_navigation = __esm({
1966
1966
  "src/types/navigation.ts"() {
1967
1967
  "use strict";
1968
+ NAVIGATION_POLICY_KEY = "admin:navigation-policy";
1968
1969
  CURRENT_NAV_PREFERENCES_VERSION = 1;
1969
1970
  DEFAULT_USER_NAV_PREFERENCES = {
1970
1971
  _version: CURRENT_NAV_PREFERENCES_VERSION,
@@ -7183,6 +7184,7 @@ function reconcileNavigation(baseTree, prefs, schemas, options) {
7183
7184
  };
7184
7185
  }
7185
7186
  const includeHidden = options?.includeHidden ?? false;
7187
+ const allowUserItems = options?.allowUserItems ?? true;
7186
7188
  const effectivePrefs = prefs || DEFAULT_USER_NAV_PREFERENCES;
7187
7189
  const hiddenSet = new Set(effectivePrefs.hidden || []);
7188
7190
  const knownCollectionSlugs = new Set((schemas?.collections || []).map((c) => c.slug));
@@ -7214,8 +7216,14 @@ function reconcileNavigation(baseTree, prefs, schemas, options) {
7214
7216
  });
7215
7217
  }
7216
7218
  }
7219
+ const ungroupedKeys = /* @__PURE__ */ new Set();
7220
+ for (const item of baseTree.ungrouped || []) {
7221
+ ungroupedKeys.add(item.id);
7222
+ if (item.slug) ungroupedKeys.add(item.slug);
7223
+ }
7217
7224
  for (const itemOpt of effectivePrefs.items || []) {
7218
7225
  const itemId = itemOpt.slug ? `workspace_${itemOpt.slug}` : itemOpt.collection ? `collection_${itemOpt.collection}` : `custom_${Math.random()}`;
7226
+ if (ungroupedKeys.has(itemId) || itemOpt.slug && ungroupedKeys.has(itemOpt.slug)) continue;
7219
7227
  let existingItem;
7220
7228
  let existingParentGroup;
7221
7229
  for (const g of groupsList) {
@@ -7226,6 +7234,7 @@ function reconcileNavigation(baseTree, prefs, schemas, options) {
7226
7234
  break;
7227
7235
  }
7228
7236
  }
7237
+ if (!existingItem && !allowUserItems) continue;
7229
7238
  const targetGroup = typeof itemOpt.group === "object" ? itemOpt.group?.name : itemOpt.group || existingParentGroup?.name || "Custom";
7230
7239
  const groupKey = targetGroup;
7231
7240
  let group = findGroup(groupKey);
@@ -7255,7 +7264,7 @@ function reconcileNavigation(baseTree, prefs, schemas, options) {
7255
7264
  const baseViews = existingItem?.views || [];
7256
7265
  const userViews = (itemOpt.views || []).map((uv) => {
7257
7266
  const bv = baseViews.find((b) => b.slug === uv.slug);
7258
- if (!bv) return uv;
7267
+ if (!bv) return { ...uv, access: void 0 };
7259
7268
  const metricsScope = uv.metricsScope ?? bv.metricsScope;
7260
7269
  let metrics = uv.metrics;
7261
7270
  if (Array.isArray(uv.metrics) && Array.isArray(bv.metrics)) {
@@ -7276,6 +7285,7 @@ function reconcileNavigation(baseTree, prefs, schemas, options) {
7276
7285
  return {
7277
7286
  ...bv,
7278
7287
  ...uv,
7288
+ access: bv.access,
7279
7289
  metricsScope,
7280
7290
  metrics
7281
7291
  };
@@ -7312,10 +7322,12 @@ function reconcileNavigation(baseTree, prefs, schemas, options) {
7312
7322
  group: group.name,
7313
7323
  order: itemOpt.order ?? existingItem?.order ?? 100,
7314
7324
  views: mergedViews,
7315
- collection: itemOpt.collection || existingItem?.collection,
7316
- global: itemOpt.global || existingItem?.global,
7317
- badge: itemOpt.badge || existingItem?.badge,
7318
- access: itemOpt.access || existingItem?.access
7325
+ collection: existingItem?.collection || itemOpt.collection,
7326
+ global: existingItem?.global || itemOpt.global,
7327
+ // Access and badges are policy/server-computed: only the server tree may set them.
7328
+ badge: existingItem?.badge,
7329
+ access: existingItem?.access,
7330
+ ...existingItem ? existingItem.userDefined ? { userDefined: true } : {} : { userDefined: true }
7319
7331
  };
7320
7332
  if (existingIndex >= 0) {
7321
7333
  group.items[existingIndex] = compiledItem;
@@ -7414,7 +7426,7 @@ function reconcileNavigation(baseTree, prefs, schemas, options) {
7414
7426
  label: pin.label || match.label,
7415
7427
  icon: pin.icon || match.icon
7416
7428
  });
7417
- } else {
7429
+ } else if (allowUserItems) {
7418
7430
  pinnedItems.push({
7419
7431
  id: `pin_${pin.slug}`,
7420
7432
  type: pin.type,
@@ -7433,6 +7445,62 @@ function reconcileNavigation(baseTree, prefs, schemas, options) {
7433
7445
  };
7434
7446
  }
7435
7447
 
7448
+ // src/utils/navigation-policy.ts
7449
+ init_navigation();
7450
+ var isStringArray = (value) => Array.isArray(value) && value.every((v) => typeof v === "string");
7451
+ var itemKeys = (item) => [item.id, ...item.slug ? [item.slug] : []];
7452
+ var allTreeItems = (tree) => [
7453
+ ...tree.groups.flatMap((g) => g.items),
7454
+ ...tree.ungrouped
7455
+ ];
7456
+ function sanitizeNavigationLayout(prefs, tree) {
7457
+ const known = new Set(allTreeItems(tree).flatMap(itemKeys));
7458
+ return {
7459
+ _version: CURRENT_NAV_PREFERENCES_VERSION,
7460
+ hidden: isStringArray(prefs?.hidden) ? prefs.hidden : [],
7461
+ groupOrder: isStringArray(prefs?.groupOrder) ? prefs.groupOrder : [],
7462
+ itemOrder: Object.fromEntries(
7463
+ Object.entries(prefs?.itemOrder ?? {}).filter(([, order]) => isStringArray(order))
7464
+ ),
7465
+ groups: Array.isArray(prefs?.groups) ? prefs.groups : [],
7466
+ items: (Array.isArray(prefs?.items) ? prefs.items : []).filter((item) => {
7467
+ if (item.slug) return known.has(item.slug) || known.has(`workspace_${item.slug}`);
7468
+ if (item.collection) return known.has(`collection_${item.collection}`);
7469
+ if (item.global) return known.has(`global_${item.global}`);
7470
+ return false;
7471
+ })
7472
+ };
7473
+ }
7474
+ function parseNavigationRolePolicy(value, tree) {
7475
+ if (!value || typeof value !== "object") return null;
7476
+ const raw = value;
7477
+ if (typeof raw.enforced !== "boolean" || !isStringArray(raw.allowed)) return null;
7478
+ const layout2 = sanitizeNavigationLayout(raw.layout, tree);
7479
+ return {
7480
+ _version: CURRENT_NAV_PREFERENCES_VERSION,
7481
+ enforced: raw.enforced,
7482
+ allowed: raw.allowed,
7483
+ layout: {
7484
+ _version: layout2._version,
7485
+ hidden: layout2.hidden,
7486
+ groupOrder: layout2.groupOrder,
7487
+ itemOrder: layout2.itemOrder,
7488
+ groups: layout2.groups,
7489
+ items: layout2.items
7490
+ }
7491
+ };
7492
+ }
7493
+ function applyNavigationRolePolicies(tree, policies) {
7494
+ const enforced = policies.filter((p) => !!p?.enforced);
7495
+ if (policies.length === 0 || enforced.length !== policies.length) return tree;
7496
+ const allowed = new Set(enforced.flatMap((p) => p.allowed));
7497
+ const isAllowed = (item) => itemKeys(item).some((key) => allowed.has(key));
7498
+ const groups = tree.groups.map((g) => ({ ...g, items: g.items.filter(isAllowed) })).filter((g) => g.items.length > 0);
7499
+ const filtered = { groups, ungrouped: tree.ungrouped.filter(isAllowed) };
7500
+ const reconciled = reconcileNavigation(filtered, enforced[0].layout, void 0, { allowUserItems: false });
7501
+ return { groups: reconciled.groups, ungrouped: reconciled.ungrouped, locked: true };
7502
+ }
7503
+
7436
7504
  // src/utils/navigation.ts
7437
7505
  var RESERVED_NAVIGATION_SLUGS = /* @__PURE__ */ new Set([
7438
7506
  "collections",
@@ -12052,7 +12120,7 @@ function layout({ preheader, title, content, footer, siteName }) {
12052
12120
  ${table(
12053
12121
  row(
12054
12122
  table(
12055
- row(" ", `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(
12123
+ row(" ", `height:5px;background:${emailTokens.colors.accent};font-size:0;line-height:0`) + row(`${siteName?.trim() ? sectionLabel(siteName) : ""}${heading(title)}`, "padding:30px 32px 24px") + row(resolvedContent, "padding:0 32px 32px") + row(
12056
12124
  `${divider()}${paragraph(resolvedFooter, "20px 0 6px")}${paragraph("Privacy: this message contains account-related information; please avoid forwarding it.", "10px 0px 0px 0px", "12px")}`,
12057
12125
  "padding:0 32px 28px"
12058
12126
  ),
@@ -12067,14 +12135,15 @@ function layout({ preheader, title, content, footer, siteName }) {
12067
12135
  </html>`;
12068
12136
  }
12069
12137
  function getDefaultEmailTemplate(purpose, options) {
12070
- const siteName = options?.siteName ?? "Dyrected";
12138
+ const siteName = options?.siteName?.trim() ?? "";
12139
+ const account = siteName ? `${siteName} account` : "account";
12071
12140
  switch (purpose) {
12072
12141
  case "invite":
12073
12142
  return {
12074
- subject: `You've been invited to ${siteName}`,
12143
+ subject: siteName ? `You've been invited to ${siteName}` : "You've been invited",
12075
12144
  rawHtml: layout({
12076
12145
  siteName,
12077
- preheader: `You've been invited to join ${siteName}.`,
12146
+ preheader: siteName ? `You've been invited to join ${siteName}.` : "You've been invited to join.",
12078
12147
  title: "You've been invited",
12079
12148
  content: [
12080
12149
  paragraph("Use the invitation link below to create your account. The link expires in 7 days."),
@@ -12091,7 +12160,7 @@ function getDefaultEmailTemplate(purpose, options) {
12091
12160
  subject: "Reset your password",
12092
12161
  rawHtml: layout({
12093
12162
  siteName,
12094
- preheader: `Reset your ${siteName} password.`,
12163
+ preheader: siteName ? `Reset your ${siteName} password.` : "Reset your password.",
12095
12164
  title: "Reset your password",
12096
12165
  content: [
12097
12166
  paragraph("We received a request to reset your password. The reset link expires in 1 hour."),
@@ -12105,10 +12174,10 @@ function getDefaultEmailTemplate(purpose, options) {
12105
12174
  };
12106
12175
  case "welcome":
12107
12176
  return {
12108
- subject: `Welcome to ${siteName} \u2014 your account is ready`,
12177
+ subject: siteName ? `Welcome to ${siteName} \u2014 your account is ready` : "Welcome \u2014 your account is ready",
12109
12178
  rawHtml: layout({
12110
12179
  siteName,
12111
- preheader: `Your ${siteName} account is ready.`,
12180
+ preheader: `Your ${account} is ready.`,
12112
12181
  title: "Welcome \u2014 your account is ready",
12113
12182
  content: [paragraph("Your account has been created. You can now log in with:"), detailBox("{{email}}")],
12114
12183
  footer: "If you didn't create this account, you can safely ignore this email."
@@ -12119,7 +12188,7 @@ function getDefaultEmailTemplate(purpose, options) {
12119
12188
  subject: "Your password has been changed",
12120
12189
  rawHtml: layout({
12121
12190
  siteName,
12122
- preheader: `Your ${siteName} password was changed.`,
12191
+ preheader: siteName ? `Your ${siteName} password was changed.` : "Your password was changed.",
12123
12192
  title: "Password changed",
12124
12193
  content: [
12125
12194
  paragraph("The password for your account has been changed."),
@@ -12130,18 +12199,33 @@ function getDefaultEmailTemplate(purpose, options) {
12130
12199
  };
12131
12200
  default:
12132
12201
  return {
12133
- subject: `Notification from ${siteName}`,
12202
+ subject: siteName ? `Notification from ${siteName}` : "Account notification",
12134
12203
  rawHtml: layout({
12135
12204
  siteName,
12136
- preheader: `Account notification from ${siteName}.`,
12205
+ preheader: siteName ? `Account notification from ${siteName}.` : "Account notification.",
12137
12206
  title: "Account Notification",
12138
- content: [paragraph(`This is an account notification from ${siteName}.`)],
12207
+ content: [paragraph(siteName ? `This is an account notification from ${siteName}.` : "This is an account notification.")],
12139
12208
  footer: "If you have any questions, please reach out to our team."
12140
12209
  })
12141
12210
  };
12142
12211
  }
12143
12212
  }
12144
12213
 
12214
+ // src/utils/app-name.ts
12215
+ var SUFFIX_SEPARATOR = /^[\s\-\u2013\u2014|\u00b7:]+/;
12216
+ function resolveAppName(admin) {
12217
+ const candidates = [
12218
+ admin?.branding?.appName,
12219
+ admin?.branding?.logoText,
12220
+ admin?.meta?.titleSuffix?.replace(SUFFIX_SEPARATOR, "")
12221
+ ];
12222
+ for (const candidate of candidates) {
12223
+ const value = typeof candidate === "string" ? candidate.trim() : "";
12224
+ if (value) return value;
12225
+ }
12226
+ return "";
12227
+ }
12228
+
12145
12229
  // src/services/email.service.ts
12146
12230
  var _devSend = null;
12147
12231
  var _devSendPromise = null;
@@ -12325,7 +12409,7 @@ function enrichArgs(config, collection, args) {
12325
12409
  collection: slug,
12326
12410
  collectionLabel: collection?.labels?.singular || collection?.labels?.plural || fallbackLabel,
12327
12411
  collectionLabels: collection?.labels,
12328
- siteName: config.admin?.branding?.logoText ?? "Dyrected"
12412
+ siteName: resolveAppName(config.admin)
12329
12413
  };
12330
12414
  }
12331
12415
  function toOutboundEmail(to, result, metadata) {
@@ -12361,7 +12445,7 @@ function buildWelcomeEmail(config, args, collection, dbOverride) {
12361
12445
  subject: "Welcome \u2014 your account is ready",
12362
12446
  html: layout({
12363
12447
  siteName: enriched.siteName,
12364
- preheader: `Your ${enriched.siteName} account is ready.`,
12448
+ preheader: enriched.siteName ? `Your ${enriched.siteName} account is ready.` : "Your account is ready.",
12365
12449
  title: "Welcome \u2014 your account is ready",
12366
12450
  content: [
12367
12451
  paragraph("Your account has been created. You can now log in with:"),
@@ -12386,7 +12470,7 @@ function buildInviteEmail(config, args, collection, dbOverride) {
12386
12470
  subject: "You've been invited",
12387
12471
  html: layout({
12388
12472
  siteName: enriched.siteName,
12389
- preheader: `You've been invited to join ${enriched.siteName}.`,
12473
+ preheader: enriched.siteName ? `You've been invited to join ${enriched.siteName}.` : "You've been invited to join.",
12390
12474
  title: "You've been invited",
12391
12475
  content: [
12392
12476
  args.invitedByEmail && paragraph(`You were invited by ${args.invitedByEmail}.`),
@@ -12421,7 +12505,7 @@ function buildResetPasswordEmail(config, args, collection, dbOverride) {
12421
12505
  subject: "Reset your password",
12422
12506
  html: layout({
12423
12507
  siteName: enriched.siteName,
12424
- preheader: `Reset your ${enriched.siteName} password.`,
12508
+ preheader: enriched.siteName ? `Reset your ${enriched.siteName} password.` : "Reset your password.",
12425
12509
  title: "Reset your password",
12426
12510
  content: [
12427
12511
  resetLink ? [
@@ -12453,7 +12537,7 @@ function buildPasswordChangedEmail(config, args, collection, dbOverride) {
12453
12537
  subject: "Your password has been changed",
12454
12538
  html: layout({
12455
12539
  siteName: enriched.siteName,
12456
- preheader: `Your ${enriched.siteName} password was changed.`,
12540
+ preheader: enriched.siteName ? `Your ${enriched.siteName} password was changed.` : "Your password was changed.",
12457
12541
  title: "Password changed",
12458
12542
  content: [
12459
12543
  paragraph("The password for this account was just changed:"),
@@ -14315,10 +14399,10 @@ function buildDyrectedSystemPrompt(context) {
14315
14399
  return summary;
14316
14400
  }).join("\n");
14317
14401
  return `
14318
- You are the Dyrected AI Assistant \u2014 an elite conversion copywriter, content strategist, and proactive editorial partner embedded natively inside Dyrected Headless CMS.
14402
+ You are the ${context.project?.name ? `${context.project.name} ` : ""}AI Assistant \u2014 an elite conversion copywriter, content strategist, and proactive editorial partner embedded natively inside this headless CMS.
14319
14403
 
14320
14404
  ### 1. CMS CONTEXT & PROJECT
14321
- - Project: "${project.name}" (ID: ${project.id})
14405
+ - Project: "${project.name || project.id}" (ID: ${project.id})
14322
14406
  - User: ${user.name || "Editor"} (Role: ${user.role || "editor"})
14323
14407
  ${globalPrompt ? `
14324
14408
  ### BRAND VOICE & DIRECTIVES
@@ -14413,7 +14497,7 @@ var AIAgent = class {
14413
14497
  prompt: g.ai?.prompt
14414
14498
  }));
14415
14499
  const project = {
14416
- name: this.config.admin?.meta?.titleSuffix?.replace(/^- /, "") || "Dyrected Project",
14500
+ name: resolveAppName(this.config.admin),
14417
14501
  id: this.projectId
14418
14502
  };
14419
14503
  this.contextCache = {
@@ -15577,12 +15661,13 @@ function getGlobalTag(global) {
15577
15661
  return `Global: ${global.label || global.slug}`;
15578
15662
  }
15579
15663
  function generateOpenApi(config) {
15664
+ const appName = resolveAppName(config.admin);
15580
15665
  const spec = {
15581
15666
  openapi: "3.0.0",
15582
15667
  info: {
15583
- title: "Dyrected API",
15668
+ title: appName ? `${appName} API` : "API",
15584
15669
  version: "1.0.0",
15585
- description: "Automatically generated OpenAPI specification for the Dyrected project."
15670
+ description: appName ? `Automatically generated OpenAPI specification for ${appName}.` : "Automatically generated OpenAPI specification for this project."
15586
15671
  },
15587
15672
  components: {
15588
15673
  schemas: {
@@ -15693,7 +15778,7 @@ function generateOpenApi(config) {
15693
15778
  spec.paths["/api/schemas"] = {
15694
15779
  get: {
15695
15780
  tags: ["System"],
15696
- summary: "Get the serialized Dyrected schema",
15781
+ summary: "Get the serialized schema",
15697
15782
  security: [],
15698
15783
  responses: {
15699
15784
  200: { description: "Collection and global schema definitions" }
@@ -16885,7 +16970,9 @@ function fieldToSchema(field) {
16885
16970
  }
16886
16971
 
16887
16972
  // src/utils/swagger.ts
16888
- function getSwaggerHtml(specUrl) {
16973
+ var escapeTitle = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
16974
+ function getSwaggerHtml(specUrl, options) {
16975
+ const title = [options?.appName?.trim(), "API Documentation"].filter(Boolean).join(" ");
16889
16976
  return `
16890
16977
  <!DOCTYPE html>
16891
16978
  <html lang="en">
@@ -16893,7 +16980,7 @@ function getSwaggerHtml(specUrl) {
16893
16980
  <meta charset="utf-8" />
16894
16981
  <meta name="viewport" content="width=device-width, initial-scale=1" />
16895
16982
  <meta name="description" content="SwaggerUI" />
16896
- <title>Dyrected API Documentation</title>
16983
+ <title>${escapeTitle(title)}</title>
16897
16984
  <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css" />
16898
16985
  </head>
16899
16986
  <body>
@@ -16939,6 +17026,7 @@ function getSwaggerHtml(specUrl) {
16939
17026
  }
16940
17027
 
16941
17028
  // src/router.ts
17029
+ init_navigation();
16942
17030
  init_access2();
16943
17031
  init_access_control();
16944
17032
  init_trash();
@@ -17493,7 +17581,7 @@ function registerRoutes(app, config) {
17493
17581
  return c.json(generateOpenApi(config));
17494
17582
  });
17495
17583
  app.get("/api/docs", (c) => {
17496
- return c.html(getSwaggerHtml());
17584
+ return c.html(getSwaggerHtml(void 0, { appName: resolveAppName(config.admin) }));
17497
17585
  });
17498
17586
  app.get("/api/preferences/:key", optionalAuth(config), async (c) => {
17499
17587
  const db = config.db;
@@ -17569,6 +17657,24 @@ function registerRoutes(app, config) {
17569
17657
  if (!user?.collection || !user.sub) return c.json({ error: true, message: "Authentication required." }, 401);
17570
17658
  if (!key) return c.json({ error: true, message: "Preference key is required." }, 400);
17571
17659
  const body = await c.req.json().catch(() => ({}));
17660
+ if (key === NAVIGATION_POLICY_KEY) {
17661
+ if (scope !== "role") {
17662
+ return c.json(
17663
+ { error: true, message: "Navigation policies are stored per role (scope=role)." },
17664
+ 400
17665
+ );
17666
+ }
17667
+ const policy = parseNavigationRolePolicy(body.value, compileNavigation(config));
17668
+ if (!policy) {
17669
+ return c.json(
17670
+ { error: true, message: "Invalid navigation policy: expected { enforced: boolean, allowed: string[] }." },
17671
+ 400
17672
+ );
17673
+ }
17674
+ body.value = policy;
17675
+ } else if (key === "admin:navigation" && (scope === "role" || scope === "global")) {
17676
+ body.value = sanitizeNavigationLayout(body.value, compileNavigation(config));
17677
+ }
17572
17678
  if (scope === "global") {
17573
17679
  const userCol = config.collections?.find((col) => col.slug === user?.collection);
17574
17680
  const isAdminUser = isUserAdmin(user, userCol);
@@ -17709,8 +17815,8 @@ function registerRoutes(app, config) {
17709
17815
  });
17710
17816
  return c.json({ success: true });
17711
17817
  });
17712
- const createNavAccessChecker = (c) => {
17713
- const user = c.get("user");
17818
+ const createNavAccessChecker = (c, userOverride) => {
17819
+ const user = userOverride ?? c.get("user");
17714
17820
  const roles = /* @__PURE__ */ new Set([...user?.role ? [user.role] : [], ...Array.isArray(user?.roles) ? user.roles : []]);
17715
17821
  const args = { user, req: toHookRequestContext(c.req) };
17716
17822
  const allows = async (rule) => {
@@ -17728,11 +17834,47 @@ function registerRoutes(app, config) {
17728
17834
  canAccessView: (view) => allows(view.access?.read)
17729
17835
  };
17730
17836
  };
17731
- app.get("/api/admin/navigation", optionalAuth(config), async (c) => {
17837
+ const loadNavigationPolicies = async (user, compiled) => {
17838
+ const db = config.db;
17839
+ const roles = [
17840
+ .../* @__PURE__ */ new Set([
17841
+ ...typeof user?.role === "string" ? [user.role] : [],
17842
+ ...Array.isArray(user?.roles) ? user.roles : []
17843
+ ])
17844
+ ];
17845
+ if (!db) return [];
17846
+ return Promise.all(
17847
+ roles.map(async (role) => {
17848
+ try {
17849
+ const doc = await db.findOne({
17850
+ collection: "__role_preferences",
17851
+ id: `${role}_${NAVIGATION_POLICY_KEY}`
17852
+ });
17853
+ return doc ? parseNavigationRolePolicy(doc.value, compiled) : null;
17854
+ } catch {
17855
+ return null;
17856
+ }
17857
+ })
17858
+ );
17859
+ };
17860
+ const resolveNavigationForUser = async (c, user, options) => {
17732
17861
  const compiled = compileNavigation(config);
17733
- const { canAccess, canAccessView } = createNavAccessChecker(c);
17862
+ const { canAccess, canAccessView } = createNavAccessChecker(c, user);
17734
17863
  const pruned = await pruneNavigationByAccess(compiled, canAccess, canAccessView);
17735
- return c.json(pruned);
17864
+ if (options?.ignorePolicy) return pruned;
17865
+ return applyNavigationRolePolicies(pruned, await loadNavigationPolicies(user ?? c.get("user"), compiled));
17866
+ };
17867
+ app.get("/api/admin/navigation", optionalAuth(config), async (c) => {
17868
+ const asRole = c.req.query("asRole");
17869
+ if (!asRole) return c.json(await resolveNavigationForUser(c));
17870
+ const user = c.get("user");
17871
+ if (!user) return c.json({ error: true, message: "Authentication required." }, 401);
17872
+ const userCol = config.collections?.find((col) => col.slug === user.collection);
17873
+ if (!isUserAdmin(user, userCol)) {
17874
+ return c.json({ error: true, message: "Only administrators can view navigation as another role." }, 403);
17875
+ }
17876
+ const asUser = { ...user, role: asRole, roles: [asRole] };
17877
+ return c.json(await resolveNavigationForUser(c, asUser, { ignorePolicy: c.req.query("raw") === "true" }));
17736
17878
  });
17737
17879
  app.on(["GET", "POST"], "/api/admin/navigation/badges", optionalAuth(config), async (c) => {
17738
17880
  const db = config.db;
@@ -17763,6 +17905,25 @@ function registerRoutes(app, config) {
17763
17905
  } catch {
17764
17906
  }
17765
17907
  }
17908
+ if (!userNavPrefs) {
17909
+ const userRoles = [
17910
+ ...typeof user?.role === "string" ? [user.role] : [],
17911
+ ...Array.isArray(user?.roles) ? user.roles : []
17912
+ ];
17913
+ for (const role of userRoles) {
17914
+ try {
17915
+ const roleDoc = await db.findOne({
17916
+ collection: "__role_preferences",
17917
+ id: `${role}_admin:navigation`
17918
+ });
17919
+ if (roleDoc?.value) {
17920
+ userNavPrefs = roleDoc.value;
17921
+ break;
17922
+ }
17923
+ } catch {
17924
+ }
17925
+ }
17926
+ }
17766
17927
  if (!userNavPrefs) {
17767
17928
  try {
17768
17929
  const globalDoc = await db.findOne({
@@ -17775,28 +17936,31 @@ function registerRoutes(app, config) {
17775
17936
  } catch {
17776
17937
  }
17777
17938
  }
17939
+ const baseTree = await resolveNavigationForUser(c);
17778
17940
  let allItems;
17779
17941
  if (userNavPrefs) {
17780
- const reconciled = reconcileNavigation(compiled, userNavPrefs, {
17781
- collections: config.collections,
17782
- globals: config.globals
17783
- });
17942
+ const reconciled = reconcileNavigation(
17943
+ baseTree,
17944
+ userNavPrefs,
17945
+ {
17946
+ collections: config.collections,
17947
+ globals: config.globals
17948
+ },
17949
+ { allowUserItems: !baseTree.locked }
17950
+ );
17784
17951
  allItems = [
17785
17952
  ...reconciled.ungrouped || [],
17786
17953
  ...reconciled.pinnedItems || [],
17787
17954
  ...(reconciled.groups || []).flatMap((g) => g.items || [])
17788
17955
  ];
17789
- } else {
17790
- const pruned = await pruneNavigationByAccess(compiled, createNavAccessChecker(c).canAccess);
17791
- allItems = [
17792
- ...pruned.ungrouped,
17793
- ...pruned.groups.flatMap((g) => g.items)
17794
- ];
17795
- }
17796
- if (userNavPrefs) {
17797
17956
  const { canAccess } = createNavAccessChecker(c);
17798
17957
  const allowed = await Promise.all(allItems.map((item) => canAccess(item)));
17799
17958
  allItems = allItems.filter((_, i) => allowed[i]);
17959
+ } else {
17960
+ allItems = [
17961
+ ...baseTree.ungrouped,
17962
+ ...baseTree.groups.flatMap((g) => g.items)
17963
+ ];
17800
17964
  }
17801
17965
  const badges = {};
17802
17966
  const requestsByCollection = /* @__PURE__ */ new Map();
@@ -18081,7 +18245,7 @@ function registerRoutes(app, config) {
18081
18245
  if (!to || typeof to !== "string" || !to.includes("@")) {
18082
18246
  return c.json({ error: true, message: "A valid recipient email address is required" }, 400);
18083
18247
  }
18084
- const siteName = config.admin?.branding?.logoText || "Dyrected";
18248
+ const siteName = resolveAppName(config.admin);
18085
18249
  let appUrl = config.admin?.siteUrl;
18086
18250
  if (!appUrl) {
18087
18251
  try {
@@ -18708,7 +18872,7 @@ var EMAIL_TEMPLATES_COLLECTION_CONFIG = {
18708
18872
  beforeChange: [
18709
18873
  async ({ data, req }) => {
18710
18874
  if (data && data.format === "html") {
18711
- const siteName = req?.config?.admin?.branding?.logoText || "Dyrected";
18875
+ const siteName = resolveAppName(req?.config?.admin);
18712
18876
  const defaults = getDefaultEmailTemplate(data.purpose || "invite", { siteName });
18713
18877
  if (!data.subject) {
18714
18878
  data.subject = defaults.subject;
package/dist/server.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { O as DyrectedContext, D as DyrectedConfig, C as CollectionConfig, G as GlobalConfig, ca as EmailTemplateArgs, cb as EmailTemplateResult, d5 as OutboundEmail, e as DatabaseAdapter, b as AuthenticatedUser, c6 as DyrectedAIContext, ax as AIThread, at as AIMessage, dd as RAGSearchResult, aw as AIRateLimitStore, av as AIRateLimitResult } from './app-Bg34iyUz.cjs';
2
- export { e6 as CollectionTokenPayload, e7 as DEFAULT_INVITE_EXPIRY, e8 as DEFAULT_RESET_PASSWORD_EXPIRY, e9 as DEFAULT_SESSION_EXPIRY, ea as DyrectedObservabilityRuntime, eb as RequestBodyCapture, ec as RequestTraceContext, ed as ResolvedObservabilityConfig, ee as attachRequestMetrics, ef as bindObservabilityRuntime, eg as buildRequestLogger, eh as captureRequestBody, ei as createDyrectedApp, ej as createObservabilityRuntime, ek as createRequestSpan, el as decodeCollectionToken, em as endRequestSpan, en as getConfigLogger, eo as getObservabilityRuntime, ep as getRequestLogger, eq as redactBody, er as redactHeaders, es as renderPrometheusMetrics, et as resolveInviteTokenExpiry, eu as resolveObservabilityConfig, ev as resolveResetPasswordTokenExpiry, ew as resolveSessionTokenExpiry, ex as shouldSampleRequest, ey as signCollectionToken, ez as verifyCollectionToken } from './app-Bg34iyUz.cjs';
1
+ import { O as DyrectedContext, D as DyrectedConfig, C as CollectionConfig, G as GlobalConfig, cd as EmailTemplateArgs, ce as EmailTemplateResult, d9 as OutboundEmail, e as DatabaseAdapter, b as AuthenticatedUser, c9 as DyrectedAIContext, aA as AIThread, aw as AIMessage, dh as RAGSearchResult, az as AIRateLimitStore, ay as AIRateLimitResult } from './app-Ti5woUOq.cjs';
2
+ export { ea as CollectionTokenPayload, eb as DEFAULT_INVITE_EXPIRY, ec as DEFAULT_RESET_PASSWORD_EXPIRY, ed as DEFAULT_SESSION_EXPIRY, ee as DyrectedObservabilityRuntime, ef as RequestBodyCapture, eg as RequestTraceContext, eh as ResolvedObservabilityConfig, ei as attachRequestMetrics, ej as bindObservabilityRuntime, ek as buildRequestLogger, el as captureRequestBody, em as createDyrectedApp, en as createObservabilityRuntime, eo as createRequestSpan, ep as decodeCollectionToken, eq as endRequestSpan, er as getConfigLogger, es as getObservabilityRuntime, et as getRequestLogger, eu as redactBody, ev as redactHeaders, ew as renderPrometheusMetrics, ex as resolveInviteTokenExpiry, ey as resolveObservabilityConfig, ez as resolveResetPasswordTokenExpiry, eA as resolveSessionTokenExpiry, eB as shouldSampleRequest, eC as signCollectionToken, eD as verifyCollectionToken } from './app-Ti5woUOq.cjs';
3
3
  import * as hono from 'hono';
4
4
  import { Hono, Context, Next } from 'hono';
5
5
  import * as hono_utils_http_status from 'hono/utils/http-status';
package/dist/server.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { O as DyrectedContext, D as DyrectedConfig, C as CollectionConfig, G as GlobalConfig, ca as EmailTemplateArgs, cb as EmailTemplateResult, d5 as OutboundEmail, e as DatabaseAdapter, b as AuthenticatedUser, c6 as DyrectedAIContext, ax as AIThread, at as AIMessage, dd as RAGSearchResult, aw as AIRateLimitStore, av as AIRateLimitResult } from './app-Bg34iyUz.js';
2
- export { e6 as CollectionTokenPayload, e7 as DEFAULT_INVITE_EXPIRY, e8 as DEFAULT_RESET_PASSWORD_EXPIRY, e9 as DEFAULT_SESSION_EXPIRY, ea as DyrectedObservabilityRuntime, eb as RequestBodyCapture, ec as RequestTraceContext, ed as ResolvedObservabilityConfig, ee as attachRequestMetrics, ef as bindObservabilityRuntime, eg as buildRequestLogger, eh as captureRequestBody, ei as createDyrectedApp, ej as createObservabilityRuntime, ek as createRequestSpan, el as decodeCollectionToken, em as endRequestSpan, en as getConfigLogger, eo as getObservabilityRuntime, ep as getRequestLogger, eq as redactBody, er as redactHeaders, es as renderPrometheusMetrics, et as resolveInviteTokenExpiry, eu as resolveObservabilityConfig, ev as resolveResetPasswordTokenExpiry, ew as resolveSessionTokenExpiry, ex as shouldSampleRequest, ey as signCollectionToken, ez as verifyCollectionToken } from './app-Bg34iyUz.js';
1
+ import { O as DyrectedContext, D as DyrectedConfig, C as CollectionConfig, G as GlobalConfig, cd as EmailTemplateArgs, ce as EmailTemplateResult, d9 as OutboundEmail, e as DatabaseAdapter, b as AuthenticatedUser, c9 as DyrectedAIContext, aA as AIThread, aw as AIMessage, dh as RAGSearchResult, az as AIRateLimitStore, ay as AIRateLimitResult } from './app-Ti5woUOq.js';
2
+ export { ea as CollectionTokenPayload, eb as DEFAULT_INVITE_EXPIRY, ec as DEFAULT_RESET_PASSWORD_EXPIRY, ed as DEFAULT_SESSION_EXPIRY, ee as DyrectedObservabilityRuntime, ef as RequestBodyCapture, eg as RequestTraceContext, eh as ResolvedObservabilityConfig, ei as attachRequestMetrics, ej as bindObservabilityRuntime, ek as buildRequestLogger, el as captureRequestBody, em as createDyrectedApp, en as createObservabilityRuntime, eo as createRequestSpan, ep as decodeCollectionToken, eq as endRequestSpan, er as getConfigLogger, es as getObservabilityRuntime, et as getRequestLogger, eu as redactBody, ev as redactHeaders, ew as renderPrometheusMetrics, ex as resolveInviteTokenExpiry, ey as resolveObservabilityConfig, ez as resolveResetPasswordTokenExpiry, eA as resolveSessionTokenExpiry, eB as shouldSampleRequest, eC as signCollectionToken, eD as verifyCollectionToken } from './app-Ti5woUOq.js';
3
3
  import * as hono from 'hono';
4
4
  import { Hono, Context, Next } from 'hono';
5
5
  import * as hono_utils_http_status from 'hono/utils/http-status';