@dyrected/core 2.24.3 → 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.js CHANGED
@@ -1959,10 +1959,11 @@ var init_readonly_fix = __esm({
1959
1959
  });
1960
1960
 
1961
1961
  // src/types/navigation.ts
1962
- var CURRENT_NAV_PREFERENCES_VERSION, DEFAULT_USER_NAV_PREFERENCES;
1962
+ var NAVIGATION_POLICY_KEY, CURRENT_NAV_PREFERENCES_VERSION, DEFAULT_USER_NAV_PREFERENCES;
1963
1963
  var init_navigation = __esm({
1964
1964
  "src/types/navigation.ts"() {
1965
1965
  "use strict";
1966
+ NAVIGATION_POLICY_KEY = "admin:navigation-policy";
1966
1967
  CURRENT_NAV_PREFERENCES_VERSION = 1;
1967
1968
  DEFAULT_USER_NAV_PREFERENCES = {
1968
1969
  _version: CURRENT_NAV_PREFERENCES_VERSION,
@@ -7113,6 +7114,7 @@ function reconcileNavigation(baseTree, prefs, schemas, options) {
7113
7114
  };
7114
7115
  }
7115
7116
  const includeHidden = options?.includeHidden ?? false;
7117
+ const allowUserItems = options?.allowUserItems ?? true;
7116
7118
  const effectivePrefs = prefs || DEFAULT_USER_NAV_PREFERENCES;
7117
7119
  const hiddenSet = new Set(effectivePrefs.hidden || []);
7118
7120
  const knownCollectionSlugs = new Set((schemas?.collections || []).map((c) => c.slug));
@@ -7144,8 +7146,14 @@ function reconcileNavigation(baseTree, prefs, schemas, options) {
7144
7146
  });
7145
7147
  }
7146
7148
  }
7149
+ const ungroupedKeys = /* @__PURE__ */ new Set();
7150
+ for (const item of baseTree.ungrouped || []) {
7151
+ ungroupedKeys.add(item.id);
7152
+ if (item.slug) ungroupedKeys.add(item.slug);
7153
+ }
7147
7154
  for (const itemOpt of effectivePrefs.items || []) {
7148
7155
  const itemId = itemOpt.slug ? `workspace_${itemOpt.slug}` : itemOpt.collection ? `collection_${itemOpt.collection}` : `custom_${Math.random()}`;
7156
+ if (ungroupedKeys.has(itemId) || itemOpt.slug && ungroupedKeys.has(itemOpt.slug)) continue;
7149
7157
  let existingItem;
7150
7158
  let existingParentGroup;
7151
7159
  for (const g of groupsList) {
@@ -7156,6 +7164,7 @@ function reconcileNavigation(baseTree, prefs, schemas, options) {
7156
7164
  break;
7157
7165
  }
7158
7166
  }
7167
+ if (!existingItem && !allowUserItems) continue;
7159
7168
  const targetGroup = typeof itemOpt.group === "object" ? itemOpt.group?.name : itemOpt.group || existingParentGroup?.name || "Custom";
7160
7169
  const groupKey = targetGroup;
7161
7170
  let group = findGroup(groupKey);
@@ -7185,7 +7194,7 @@ function reconcileNavigation(baseTree, prefs, schemas, options) {
7185
7194
  const baseViews = existingItem?.views || [];
7186
7195
  const userViews = (itemOpt.views || []).map((uv) => {
7187
7196
  const bv = baseViews.find((b) => b.slug === uv.slug);
7188
- if (!bv) return uv;
7197
+ if (!bv) return { ...uv, access: void 0 };
7189
7198
  const metricsScope = uv.metricsScope ?? bv.metricsScope;
7190
7199
  let metrics = uv.metrics;
7191
7200
  if (Array.isArray(uv.metrics) && Array.isArray(bv.metrics)) {
@@ -7206,6 +7215,7 @@ function reconcileNavigation(baseTree, prefs, schemas, options) {
7206
7215
  return {
7207
7216
  ...bv,
7208
7217
  ...uv,
7218
+ access: bv.access,
7209
7219
  metricsScope,
7210
7220
  metrics
7211
7221
  };
@@ -7242,10 +7252,12 @@ function reconcileNavigation(baseTree, prefs, schemas, options) {
7242
7252
  group: group.name,
7243
7253
  order: itemOpt.order ?? existingItem?.order ?? 100,
7244
7254
  views: mergedViews,
7245
- collection: itemOpt.collection || existingItem?.collection,
7246
- global: itemOpt.global || existingItem?.global,
7247
- badge: itemOpt.badge || existingItem?.badge,
7248
- access: itemOpt.access || existingItem?.access
7255
+ collection: existingItem?.collection || itemOpt.collection,
7256
+ global: existingItem?.global || itemOpt.global,
7257
+ // Access and badges are policy/server-computed: only the server tree may set them.
7258
+ badge: existingItem?.badge,
7259
+ access: existingItem?.access,
7260
+ ...existingItem ? existingItem.userDefined ? { userDefined: true } : {} : { userDefined: true }
7249
7261
  };
7250
7262
  if (existingIndex >= 0) {
7251
7263
  group.items[existingIndex] = compiledItem;
@@ -7344,7 +7356,7 @@ function reconcileNavigation(baseTree, prefs, schemas, options) {
7344
7356
  label: pin.label || match.label,
7345
7357
  icon: pin.icon || match.icon
7346
7358
  });
7347
- } else {
7359
+ } else if (allowUserItems) {
7348
7360
  pinnedItems.push({
7349
7361
  id: `pin_${pin.slug}`,
7350
7362
  type: pin.type,
@@ -7363,6 +7375,62 @@ function reconcileNavigation(baseTree, prefs, schemas, options) {
7363
7375
  };
7364
7376
  }
7365
7377
 
7378
+ // src/utils/navigation-policy.ts
7379
+ init_navigation();
7380
+ var isStringArray = (value) => Array.isArray(value) && value.every((v) => typeof v === "string");
7381
+ var itemKeys = (item) => [item.id, ...item.slug ? [item.slug] : []];
7382
+ var allTreeItems = (tree) => [
7383
+ ...tree.groups.flatMap((g) => g.items),
7384
+ ...tree.ungrouped
7385
+ ];
7386
+ function sanitizeNavigationLayout(prefs, tree) {
7387
+ const known = new Set(allTreeItems(tree).flatMap(itemKeys));
7388
+ return {
7389
+ _version: CURRENT_NAV_PREFERENCES_VERSION,
7390
+ hidden: isStringArray(prefs?.hidden) ? prefs.hidden : [],
7391
+ groupOrder: isStringArray(prefs?.groupOrder) ? prefs.groupOrder : [],
7392
+ itemOrder: Object.fromEntries(
7393
+ Object.entries(prefs?.itemOrder ?? {}).filter(([, order]) => isStringArray(order))
7394
+ ),
7395
+ groups: Array.isArray(prefs?.groups) ? prefs.groups : [],
7396
+ items: (Array.isArray(prefs?.items) ? prefs.items : []).filter((item) => {
7397
+ if (item.slug) return known.has(item.slug) || known.has(`workspace_${item.slug}`);
7398
+ if (item.collection) return known.has(`collection_${item.collection}`);
7399
+ if (item.global) return known.has(`global_${item.global}`);
7400
+ return false;
7401
+ })
7402
+ };
7403
+ }
7404
+ function parseNavigationRolePolicy(value, tree) {
7405
+ if (!value || typeof value !== "object") return null;
7406
+ const raw = value;
7407
+ if (typeof raw.enforced !== "boolean" || !isStringArray(raw.allowed)) return null;
7408
+ const layout2 = sanitizeNavigationLayout(raw.layout, tree);
7409
+ return {
7410
+ _version: CURRENT_NAV_PREFERENCES_VERSION,
7411
+ enforced: raw.enforced,
7412
+ allowed: raw.allowed,
7413
+ layout: {
7414
+ _version: layout2._version,
7415
+ hidden: layout2.hidden,
7416
+ groupOrder: layout2.groupOrder,
7417
+ itemOrder: layout2.itemOrder,
7418
+ groups: layout2.groups,
7419
+ items: layout2.items
7420
+ }
7421
+ };
7422
+ }
7423
+ function applyNavigationRolePolicies(tree, policies) {
7424
+ const enforced = policies.filter((p) => !!p?.enforced);
7425
+ if (policies.length === 0 || enforced.length !== policies.length) return tree;
7426
+ const allowed = new Set(enforced.flatMap((p) => p.allowed));
7427
+ const isAllowed = (item) => itemKeys(item).some((key) => allowed.has(key));
7428
+ const groups = tree.groups.map((g) => ({ ...g, items: g.items.filter(isAllowed) })).filter((g) => g.items.length > 0);
7429
+ const filtered = { groups, ungrouped: tree.ungrouped.filter(isAllowed) };
7430
+ const reconciled = reconcileNavigation(filtered, enforced[0].layout, void 0, { allowUserItems: false });
7431
+ return { groups: reconciled.groups, ungrouped: reconciled.ungrouped, locked: true };
7432
+ }
7433
+
7366
7434
  // src/utils/navigation.ts
7367
7435
  var RESERVED_NAVIGATION_SLUGS = /* @__PURE__ */ new Set([
7368
7436
  "collections",
@@ -11982,7 +12050,7 @@ function layout({ preheader, title, content, footer, siteName }) {
11982
12050
  ${table(
11983
12051
  row(
11984
12052
  table(
11985
- 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(
12053
+ 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(
11986
12054
  `${divider()}${paragraph(resolvedFooter, "20px 0 6px")}${paragraph("Privacy: this message contains account-related information; please avoid forwarding it.", "10px 0px 0px 0px", "12px")}`,
11987
12055
  "padding:0 32px 28px"
11988
12056
  ),
@@ -11997,14 +12065,15 @@ function layout({ preheader, title, content, footer, siteName }) {
11997
12065
  </html>`;
11998
12066
  }
11999
12067
  function getDefaultEmailTemplate(purpose, options) {
12000
- const siteName = options?.siteName ?? "Dyrected";
12068
+ const siteName = options?.siteName?.trim() ?? "";
12069
+ const account = siteName ? `${siteName} account` : "account";
12001
12070
  switch (purpose) {
12002
12071
  case "invite":
12003
12072
  return {
12004
- subject: `You've been invited to ${siteName}`,
12073
+ subject: siteName ? `You've been invited to ${siteName}` : "You've been invited",
12005
12074
  rawHtml: layout({
12006
12075
  siteName,
12007
- preheader: `You've been invited to join ${siteName}.`,
12076
+ preheader: siteName ? `You've been invited to join ${siteName}.` : "You've been invited to join.",
12008
12077
  title: "You've been invited",
12009
12078
  content: [
12010
12079
  paragraph("Use the invitation link below to create your account. The link expires in 7 days."),
@@ -12021,7 +12090,7 @@ function getDefaultEmailTemplate(purpose, options) {
12021
12090
  subject: "Reset your password",
12022
12091
  rawHtml: layout({
12023
12092
  siteName,
12024
- preheader: `Reset your ${siteName} password.`,
12093
+ preheader: siteName ? `Reset your ${siteName} password.` : "Reset your password.",
12025
12094
  title: "Reset your password",
12026
12095
  content: [
12027
12096
  paragraph("We received a request to reset your password. The reset link expires in 1 hour."),
@@ -12035,10 +12104,10 @@ function getDefaultEmailTemplate(purpose, options) {
12035
12104
  };
12036
12105
  case "welcome":
12037
12106
  return {
12038
- subject: `Welcome to ${siteName} \u2014 your account is ready`,
12107
+ subject: siteName ? `Welcome to ${siteName} \u2014 your account is ready` : "Welcome \u2014 your account is ready",
12039
12108
  rawHtml: layout({
12040
12109
  siteName,
12041
- preheader: `Your ${siteName} account is ready.`,
12110
+ preheader: `Your ${account} is ready.`,
12042
12111
  title: "Welcome \u2014 your account is ready",
12043
12112
  content: [paragraph("Your account has been created. You can now log in with:"), detailBox("{{email}}")],
12044
12113
  footer: "If you didn't create this account, you can safely ignore this email."
@@ -12049,7 +12118,7 @@ function getDefaultEmailTemplate(purpose, options) {
12049
12118
  subject: "Your password has been changed",
12050
12119
  rawHtml: layout({
12051
12120
  siteName,
12052
- preheader: `Your ${siteName} password was changed.`,
12121
+ preheader: siteName ? `Your ${siteName} password was changed.` : "Your password was changed.",
12053
12122
  title: "Password changed",
12054
12123
  content: [
12055
12124
  paragraph("The password for your account has been changed."),
@@ -12060,18 +12129,33 @@ function getDefaultEmailTemplate(purpose, options) {
12060
12129
  };
12061
12130
  default:
12062
12131
  return {
12063
- subject: `Notification from ${siteName}`,
12132
+ subject: siteName ? `Notification from ${siteName}` : "Account notification",
12064
12133
  rawHtml: layout({
12065
12134
  siteName,
12066
- preheader: `Account notification from ${siteName}.`,
12135
+ preheader: siteName ? `Account notification from ${siteName}.` : "Account notification.",
12067
12136
  title: "Account Notification",
12068
- content: [paragraph(`This is an account notification from ${siteName}.`)],
12137
+ content: [paragraph(siteName ? `This is an account notification from ${siteName}.` : "This is an account notification.")],
12069
12138
  footer: "If you have any questions, please reach out to our team."
12070
12139
  })
12071
12140
  };
12072
12141
  }
12073
12142
  }
12074
12143
 
12144
+ // src/utils/app-name.ts
12145
+ var SUFFIX_SEPARATOR = /^[\s\-\u2013\u2014|\u00b7:]+/;
12146
+ function resolveAppName(admin) {
12147
+ const candidates = [
12148
+ admin?.branding?.appName,
12149
+ admin?.branding?.logoText,
12150
+ admin?.meta?.titleSuffix?.replace(SUFFIX_SEPARATOR, "")
12151
+ ];
12152
+ for (const candidate of candidates) {
12153
+ const value = typeof candidate === "string" ? candidate.trim() : "";
12154
+ if (value) return value;
12155
+ }
12156
+ return "";
12157
+ }
12158
+
12075
12159
  // src/services/email.service.ts
12076
12160
  var _devSend = null;
12077
12161
  var _devSendPromise = null;
@@ -12255,7 +12339,7 @@ function enrichArgs(config, collection, args) {
12255
12339
  collection: slug,
12256
12340
  collectionLabel: collection?.labels?.singular || collection?.labels?.plural || fallbackLabel,
12257
12341
  collectionLabels: collection?.labels,
12258
- siteName: config.admin?.branding?.logoText ?? "Dyrected"
12342
+ siteName: resolveAppName(config.admin)
12259
12343
  };
12260
12344
  }
12261
12345
  function toOutboundEmail(to, result, metadata) {
@@ -12291,7 +12375,7 @@ function buildWelcomeEmail(config, args, collection, dbOverride) {
12291
12375
  subject: "Welcome \u2014 your account is ready",
12292
12376
  html: layout({
12293
12377
  siteName: enriched.siteName,
12294
- preheader: `Your ${enriched.siteName} account is ready.`,
12378
+ preheader: enriched.siteName ? `Your ${enriched.siteName} account is ready.` : "Your account is ready.",
12295
12379
  title: "Welcome \u2014 your account is ready",
12296
12380
  content: [
12297
12381
  paragraph("Your account has been created. You can now log in with:"),
@@ -12316,7 +12400,7 @@ function buildInviteEmail(config, args, collection, dbOverride) {
12316
12400
  subject: "You've been invited",
12317
12401
  html: layout({
12318
12402
  siteName: enriched.siteName,
12319
- preheader: `You've been invited to join ${enriched.siteName}.`,
12403
+ preheader: enriched.siteName ? `You've been invited to join ${enriched.siteName}.` : "You've been invited to join.",
12320
12404
  title: "You've been invited",
12321
12405
  content: [
12322
12406
  args.invitedByEmail && paragraph(`You were invited by ${args.invitedByEmail}.`),
@@ -12351,7 +12435,7 @@ function buildResetPasswordEmail(config, args, collection, dbOverride) {
12351
12435
  subject: "Reset your password",
12352
12436
  html: layout({
12353
12437
  siteName: enriched.siteName,
12354
- preheader: `Reset your ${enriched.siteName} password.`,
12438
+ preheader: enriched.siteName ? `Reset your ${enriched.siteName} password.` : "Reset your password.",
12355
12439
  title: "Reset your password",
12356
12440
  content: [
12357
12441
  resetLink ? [
@@ -12383,7 +12467,7 @@ function buildPasswordChangedEmail(config, args, collection, dbOverride) {
12383
12467
  subject: "Your password has been changed",
12384
12468
  html: layout({
12385
12469
  siteName: enriched.siteName,
12386
- preheader: `Your ${enriched.siteName} password was changed.`,
12470
+ preheader: enriched.siteName ? `Your ${enriched.siteName} password was changed.` : "Your password was changed.",
12387
12471
  title: "Password changed",
12388
12472
  content: [
12389
12473
  paragraph("The password for this account was just changed:"),
@@ -14250,10 +14334,10 @@ function buildDyrectedSystemPrompt(context) {
14250
14334
  return summary;
14251
14335
  }).join("\n");
14252
14336
  return `
14253
- You are the Dyrected AI Assistant \u2014 an elite conversion copywriter, content strategist, and proactive editorial partner embedded natively inside Dyrected Headless CMS.
14337
+ 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.
14254
14338
 
14255
14339
  ### 1. CMS CONTEXT & PROJECT
14256
- - Project: "${project.name}" (ID: ${project.id})
14340
+ - Project: "${project.name || project.id}" (ID: ${project.id})
14257
14341
  - User: ${user.name || "Editor"} (Role: ${user.role || "editor"})
14258
14342
  ${globalPrompt ? `
14259
14343
  ### BRAND VOICE & DIRECTIVES
@@ -14348,7 +14432,7 @@ var AIAgent = class {
14348
14432
  prompt: g.ai?.prompt
14349
14433
  }));
14350
14434
  const project = {
14351
- name: this.config.admin?.meta?.titleSuffix?.replace(/^- /, "") || "Dyrected Project",
14435
+ name: resolveAppName(this.config.admin),
14352
14436
  id: this.projectId
14353
14437
  };
14354
14438
  this.contextCache = {
@@ -15512,12 +15596,13 @@ function getGlobalTag(global) {
15512
15596
  return `Global: ${global.label || global.slug}`;
15513
15597
  }
15514
15598
  function generateOpenApi(config) {
15599
+ const appName = resolveAppName(config.admin);
15515
15600
  const spec = {
15516
15601
  openapi: "3.0.0",
15517
15602
  info: {
15518
- title: "Dyrected API",
15603
+ title: appName ? `${appName} API` : "API",
15519
15604
  version: "1.0.0",
15520
- description: "Automatically generated OpenAPI specification for the Dyrected project."
15605
+ description: appName ? `Automatically generated OpenAPI specification for ${appName}.` : "Automatically generated OpenAPI specification for this project."
15521
15606
  },
15522
15607
  components: {
15523
15608
  schemas: {
@@ -15628,7 +15713,7 @@ function generateOpenApi(config) {
15628
15713
  spec.paths["/api/schemas"] = {
15629
15714
  get: {
15630
15715
  tags: ["System"],
15631
- summary: "Get the serialized Dyrected schema",
15716
+ summary: "Get the serialized schema",
15632
15717
  security: [],
15633
15718
  responses: {
15634
15719
  200: { description: "Collection and global schema definitions" }
@@ -16820,7 +16905,9 @@ function fieldToSchema(field) {
16820
16905
  }
16821
16906
 
16822
16907
  // src/utils/swagger.ts
16823
- function getSwaggerHtml(specUrl) {
16908
+ var escapeTitle = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
16909
+ function getSwaggerHtml(specUrl, options) {
16910
+ const title = [options?.appName?.trim(), "API Documentation"].filter(Boolean).join(" ");
16824
16911
  return `
16825
16912
  <!DOCTYPE html>
16826
16913
  <html lang="en">
@@ -16828,7 +16915,7 @@ function getSwaggerHtml(specUrl) {
16828
16915
  <meta charset="utf-8" />
16829
16916
  <meta name="viewport" content="width=device-width, initial-scale=1" />
16830
16917
  <meta name="description" content="SwaggerUI" />
16831
- <title>Dyrected API Documentation</title>
16918
+ <title>${escapeTitle(title)}</title>
16832
16919
  <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css" />
16833
16920
  </head>
16834
16921
  <body>
@@ -16874,6 +16961,7 @@ function getSwaggerHtml(specUrl) {
16874
16961
  }
16875
16962
 
16876
16963
  // src/router.ts
16964
+ init_navigation();
16877
16965
  init_access2();
16878
16966
  init_access_control();
16879
16967
  init_trash();
@@ -17428,7 +17516,7 @@ function registerRoutes(app, config) {
17428
17516
  return c.json(generateOpenApi(config));
17429
17517
  });
17430
17518
  app.get("/api/docs", (c) => {
17431
- return c.html(getSwaggerHtml());
17519
+ return c.html(getSwaggerHtml(void 0, { appName: resolveAppName(config.admin) }));
17432
17520
  });
17433
17521
  app.get("/api/preferences/:key", optionalAuth(config), async (c) => {
17434
17522
  const db = config.db;
@@ -17504,6 +17592,24 @@ function registerRoutes(app, config) {
17504
17592
  if (!user?.collection || !user.sub) return c.json({ error: true, message: "Authentication required." }, 401);
17505
17593
  if (!key) return c.json({ error: true, message: "Preference key is required." }, 400);
17506
17594
  const body = await c.req.json().catch(() => ({}));
17595
+ if (key === NAVIGATION_POLICY_KEY) {
17596
+ if (scope !== "role") {
17597
+ return c.json(
17598
+ { error: true, message: "Navigation policies are stored per role (scope=role)." },
17599
+ 400
17600
+ );
17601
+ }
17602
+ const policy = parseNavigationRolePolicy(body.value, compileNavigation(config));
17603
+ if (!policy) {
17604
+ return c.json(
17605
+ { error: true, message: "Invalid navigation policy: expected { enforced: boolean, allowed: string[] }." },
17606
+ 400
17607
+ );
17608
+ }
17609
+ body.value = policy;
17610
+ } else if (key === "admin:navigation" && (scope === "role" || scope === "global")) {
17611
+ body.value = sanitizeNavigationLayout(body.value, compileNavigation(config));
17612
+ }
17507
17613
  if (scope === "global") {
17508
17614
  const userCol = config.collections?.find((col) => col.slug === user?.collection);
17509
17615
  const isAdminUser = isUserAdmin(user, userCol);
@@ -17644,8 +17750,8 @@ function registerRoutes(app, config) {
17644
17750
  });
17645
17751
  return c.json({ success: true });
17646
17752
  });
17647
- const createNavAccessChecker = (c) => {
17648
- const user = c.get("user");
17753
+ const createNavAccessChecker = (c, userOverride) => {
17754
+ const user = userOverride ?? c.get("user");
17649
17755
  const roles = /* @__PURE__ */ new Set([...user?.role ? [user.role] : [], ...Array.isArray(user?.roles) ? user.roles : []]);
17650
17756
  const args = { user, req: toHookRequestContext(c.req) };
17651
17757
  const allows = async (rule) => {
@@ -17663,11 +17769,47 @@ function registerRoutes(app, config) {
17663
17769
  canAccessView: (view) => allows(view.access?.read)
17664
17770
  };
17665
17771
  };
17666
- app.get("/api/admin/navigation", optionalAuth(config), async (c) => {
17772
+ const loadNavigationPolicies = async (user, compiled) => {
17773
+ const db = config.db;
17774
+ const roles = [
17775
+ .../* @__PURE__ */ new Set([
17776
+ ...typeof user?.role === "string" ? [user.role] : [],
17777
+ ...Array.isArray(user?.roles) ? user.roles : []
17778
+ ])
17779
+ ];
17780
+ if (!db) return [];
17781
+ return Promise.all(
17782
+ roles.map(async (role) => {
17783
+ try {
17784
+ const doc = await db.findOne({
17785
+ collection: "__role_preferences",
17786
+ id: `${role}_${NAVIGATION_POLICY_KEY}`
17787
+ });
17788
+ return doc ? parseNavigationRolePolicy(doc.value, compiled) : null;
17789
+ } catch {
17790
+ return null;
17791
+ }
17792
+ })
17793
+ );
17794
+ };
17795
+ const resolveNavigationForUser = async (c, user, options) => {
17667
17796
  const compiled = compileNavigation(config);
17668
- const { canAccess, canAccessView } = createNavAccessChecker(c);
17797
+ const { canAccess, canAccessView } = createNavAccessChecker(c, user);
17669
17798
  const pruned = await pruneNavigationByAccess(compiled, canAccess, canAccessView);
17670
- return c.json(pruned);
17799
+ if (options?.ignorePolicy) return pruned;
17800
+ return applyNavigationRolePolicies(pruned, await loadNavigationPolicies(user ?? c.get("user"), compiled));
17801
+ };
17802
+ app.get("/api/admin/navigation", optionalAuth(config), async (c) => {
17803
+ const asRole = c.req.query("asRole");
17804
+ if (!asRole) return c.json(await resolveNavigationForUser(c));
17805
+ const user = c.get("user");
17806
+ if (!user) return c.json({ error: true, message: "Authentication required." }, 401);
17807
+ const userCol = config.collections?.find((col) => col.slug === user.collection);
17808
+ if (!isUserAdmin(user, userCol)) {
17809
+ return c.json({ error: true, message: "Only administrators can view navigation as another role." }, 403);
17810
+ }
17811
+ const asUser = { ...user, role: asRole, roles: [asRole] };
17812
+ return c.json(await resolveNavigationForUser(c, asUser, { ignorePolicy: c.req.query("raw") === "true" }));
17671
17813
  });
17672
17814
  app.on(["GET", "POST"], "/api/admin/navigation/badges", optionalAuth(config), async (c) => {
17673
17815
  const db = config.db;
@@ -17698,6 +17840,25 @@ function registerRoutes(app, config) {
17698
17840
  } catch {
17699
17841
  }
17700
17842
  }
17843
+ if (!userNavPrefs) {
17844
+ const userRoles = [
17845
+ ...typeof user?.role === "string" ? [user.role] : [],
17846
+ ...Array.isArray(user?.roles) ? user.roles : []
17847
+ ];
17848
+ for (const role of userRoles) {
17849
+ try {
17850
+ const roleDoc = await db.findOne({
17851
+ collection: "__role_preferences",
17852
+ id: `${role}_admin:navigation`
17853
+ });
17854
+ if (roleDoc?.value) {
17855
+ userNavPrefs = roleDoc.value;
17856
+ break;
17857
+ }
17858
+ } catch {
17859
+ }
17860
+ }
17861
+ }
17701
17862
  if (!userNavPrefs) {
17702
17863
  try {
17703
17864
  const globalDoc = await db.findOne({
@@ -17710,28 +17871,31 @@ function registerRoutes(app, config) {
17710
17871
  } catch {
17711
17872
  }
17712
17873
  }
17874
+ const baseTree = await resolveNavigationForUser(c);
17713
17875
  let allItems;
17714
17876
  if (userNavPrefs) {
17715
- const reconciled = reconcileNavigation(compiled, userNavPrefs, {
17716
- collections: config.collections,
17717
- globals: config.globals
17718
- });
17877
+ const reconciled = reconcileNavigation(
17878
+ baseTree,
17879
+ userNavPrefs,
17880
+ {
17881
+ collections: config.collections,
17882
+ globals: config.globals
17883
+ },
17884
+ { allowUserItems: !baseTree.locked }
17885
+ );
17719
17886
  allItems = [
17720
17887
  ...reconciled.ungrouped || [],
17721
17888
  ...reconciled.pinnedItems || [],
17722
17889
  ...(reconciled.groups || []).flatMap((g) => g.items || [])
17723
17890
  ];
17724
- } else {
17725
- const pruned = await pruneNavigationByAccess(compiled, createNavAccessChecker(c).canAccess);
17726
- allItems = [
17727
- ...pruned.ungrouped,
17728
- ...pruned.groups.flatMap((g) => g.items)
17729
- ];
17730
- }
17731
- if (userNavPrefs) {
17732
17891
  const { canAccess } = createNavAccessChecker(c);
17733
17892
  const allowed = await Promise.all(allItems.map((item) => canAccess(item)));
17734
17893
  allItems = allItems.filter((_, i) => allowed[i]);
17894
+ } else {
17895
+ allItems = [
17896
+ ...baseTree.ungrouped,
17897
+ ...baseTree.groups.flatMap((g) => g.items)
17898
+ ];
17735
17899
  }
17736
17900
  const badges = {};
17737
17901
  const requestsByCollection = /* @__PURE__ */ new Map();
@@ -18016,7 +18180,7 @@ function registerRoutes(app, config) {
18016
18180
  if (!to || typeof to !== "string" || !to.includes("@")) {
18017
18181
  return c.json({ error: true, message: "A valid recipient email address is required" }, 400);
18018
18182
  }
18019
- const siteName = config.admin?.branding?.logoText || "Dyrected";
18183
+ const siteName = resolveAppName(config.admin);
18020
18184
  let appUrl = config.admin?.siteUrl;
18021
18185
  if (!appUrl) {
18022
18186
  try {
@@ -18643,7 +18807,7 @@ var EMAIL_TEMPLATES_COLLECTION_CONFIG = {
18643
18807
  beforeChange: [
18644
18808
  async ({ data, req }) => {
18645
18809
  if (data && data.format === "html") {
18646
- const siteName = req?.config?.admin?.branding?.logoText || "Dyrected";
18810
+ const siteName = resolveAppName(req?.config?.admin);
18647
18811
  const defaults = getDefaultEmailTemplate(data.purpose || "invite", { siteName });
18648
18812
  if (!data.subject) {
18649
18813
  data.subject = defaults.subject;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dyrected/core",
3
- "version": "2.24.3",
3
+ "version": "2.25.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",