@kyro-cms/admin 0.12.20 → 0.12.21

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.
Files changed (56) hide show
  1. package/dist/index.cjs +24 -24
  2. package/dist/index.d.cts +2 -1
  3. package/dist/index.d.ts +2 -1
  4. package/dist/index.js +19 -19
  5. package/package.json +6 -6
  6. package/src/components/ApiKeysManager.tsx +177 -227
  7. package/src/components/AuditLogsPage.tsx +7 -7
  8. package/src/components/AuthorDashboard.tsx +235 -0
  9. package/src/components/AutoForm.tsx +14 -4
  10. package/src/components/CustomerDashboard.tsx +213 -0
  11. package/src/components/Dashboard.tsx +1 -1
  12. package/src/components/DashboardMetrics.tsx +74 -9
  13. package/src/components/DashboardQuickSections.tsx +237 -0
  14. package/src/components/ListView.tsx +3 -3
  15. package/src/components/MediaGallery.tsx +2 -2
  16. package/src/components/PluginsManager.tsx +1 -1
  17. package/src/components/SessionsManager.tsx +15 -15
  18. package/src/components/Sidebar.astro +564 -116
  19. package/src/components/UserManagement.tsx +43 -9
  20. package/src/components/UserMenu.tsx +123 -61
  21. package/src/components/autoform/AutoFormEditView.tsx +4 -4
  22. package/src/components/autoform/AutoFormHeader.tsx +2 -2
  23. package/src/components/ui/CommandPalette.tsx +1 -1
  24. package/src/components/ui/Dropdown.tsx +71 -16
  25. package/src/components/ui/PageHeader.tsx +1 -1
  26. package/src/components/ui/Pagination.tsx +1 -1
  27. package/src/components/ui/Toast.tsx +3 -2
  28. package/src/components/ui/Toaster.tsx +1 -1
  29. package/src/components/users/UserDetail.tsx +74 -29
  30. package/src/components/users/UserForm.tsx +1 -1
  31. package/src/components/users/UsersList.tsx +2 -2
  32. package/src/integration.ts +4 -0
  33. package/src/layouts/AdminLayout.astro +26 -34
  34. package/src/layouts/AuthLayout.astro +18 -9
  35. package/src/lib/auth-server.ts +99 -0
  36. package/src/pages/403.astro +54 -35
  37. package/src/pages/[collection]/[id].astro +8 -0
  38. package/src/pages/[collection]/index.astro +7 -0
  39. package/src/pages/audit/index.astro +4 -1
  40. package/src/pages/auth/check-email.astro +146 -0
  41. package/src/pages/auth/forgot-password.astro +95 -0
  42. package/src/pages/auth/login.astro +29 -12
  43. package/src/pages/auth/register.astro +38 -12
  44. package/src/pages/auth/reset-password.astro +125 -0
  45. package/src/pages/auth/verify-email.astro +87 -0
  46. package/src/pages/index.astro +220 -121
  47. package/src/pages/keys.astro +3 -1
  48. package/src/pages/media.astro +3 -1
  49. package/src/pages/preview/[collection]/[id].astro +23 -73
  50. package/src/pages/roles/index.astro +134 -101
  51. package/src/pages/settings/[slug].astro +43 -3
  52. package/src/pages/users/[id].astro +5 -1
  53. package/src/pages/users/index.astro +4 -0
  54. package/src/pages/users/new.astro +4 -0
  55. package/src/pages/webhooks.astro +3 -1
  56. package/src/routes.ts +16 -0
@@ -42,9 +42,9 @@ nonAuthCollections
42
42
  href: `${adminPath}/${col.slug}`,
43
43
  label: col.label || col.slug,
44
44
  icon: (col.admin?.icon as string) || "collection",
45
- order: typeof col.admin?.order === 'number' ? col.admin.order : 999,
45
+ order: typeof col.admin?.order === "number" ? col.admin.order : 999,
46
46
  };
47
-
47
+
48
48
  const groupName = (col.admin?.group as string) || "Content";
49
49
  if (!groupedCollections.has(groupName)) {
50
50
  groupedCollections.set(groupName, []);
@@ -57,7 +57,14 @@ const dynamicSections = Array.from(groupedCollections.entries()).map(([group, it
57
57
  items: items.sort((a, b) => (a.order || 999) - (b.order || 999)),
58
58
  }));
59
59
 
60
- const navSections: { label: string; items: NavItem[] }[] = [
60
+ import { getAuthServer } from "../lib/auth-server";
61
+
62
+ const { user: serverUser, permissions: serverPermissions } = await getAuthServer(Astro.request);
63
+ const serverRole = serverUser?.role || "";
64
+ const isServerSuperAdmin = serverRole === "super_admin";
65
+ const isServerAdmin = serverRole === "admin" || isServerSuperAdmin;
66
+
67
+ const navSectionsRaw: { label: string; items: NavItem[] }[] = [
61
68
  {
62
69
  label: "Dashboard",
63
70
  items: [
@@ -74,7 +81,6 @@ const navSections: { label: string; items: NavItem[] }[] = [
74
81
  label: "Settings",
75
82
  items: [
76
83
  { href: adminPath + "/users", label: "nav.users", icon: "users" },
77
- { href: adminPath + "/plugins", label: "nav.plugins", icon: "plugins" },
78
84
  {
79
85
  href: adminPath + "/settings",
80
86
  label: "nav.settings",
@@ -84,19 +90,63 @@ const navSections: { label: string; items: NavItem[] }[] = [
84
90
  },
85
91
  ];
86
92
 
93
+ const isServerEditor = serverRole === "editor";
94
+ const isServerAuthor = serverRole === "author";
95
+ const isServerCustomer = serverRole === "customer";
96
+
97
+ const navSections = navSectionsRaw
98
+ .map((section) => {
99
+ if (!serverUser) return section;
100
+ const items = section.items.filter((item) => {
101
+ const slug = item.href.split("/").pop() || "";
102
+ if (item.href === adminPath) return true;
103
+ if (slug === "roles") return isServerSuperAdmin;
104
+ if (slug === "plugins" || slug === "keys" || slug === "webhooks" || slug === "settings") return isServerAdmin;
105
+ if (slug === "users") return isServerAdmin || serverPermissions?.collections?.users?.read === true;
106
+ if (slug === "audit" || slug === "audit_logs")
107
+ return isServerAdmin || serverPermissions?.collections?.audit_logs?.read === true;
108
+ if (slug === "media")
109
+ return (
110
+ !isServerCustomer &&
111
+ (isServerAdmin || isServerEditor || isServerAuthor || serverPermissions?.collections?.media?.read === true)
112
+ );
113
+
114
+ if (isServerAdmin) return true;
115
+ if (serverPermissions?.collections?.[slug]) {
116
+ return serverPermissions.collections[slug].read === true;
117
+ }
118
+
119
+ if (isServerEditor) {
120
+ return !["users", "audit_logs", "roles", "plugins", "keys", "webhooks", "settings"].includes(slug);
121
+ }
122
+ if (isServerAuthor) {
123
+ return ["posts", "categories", "orders"].includes(slug);
124
+ }
125
+ if (isServerCustomer) {
126
+ return ["orders"].includes(slug);
127
+ }
128
+ return false;
129
+ });
130
+ return { ...section, items };
131
+ })
132
+ .filter((section) => section.items.length > 0);
133
+
87
134
  const iconAliases: Record<string, string> = {
88
135
  collection: "Dot",
89
136
  media: "Grid",
90
137
  home: "Home",
91
138
  users: "Users",
139
+ shield: "Shield",
92
140
  plugins: "Blocks",
141
+ key: "Key",
142
+ webhook: "Webhook",
143
+ audit: "FileText",
93
144
  settings: "Settings",
94
145
  };
95
146
 
96
147
  function isActive(item: NavItem): boolean {
97
148
  if (item.href === adminPath) return title === "Dashboard";
98
- if (item.href.startsWith(adminPath + "/settings"))
99
- return currentPath.startsWith(adminPath + "/settings");
149
+ if (item.href.startsWith(adminPath + "/settings")) return currentPath.startsWith(adminPath + "/settings");
100
150
  if (item.href === adminPath + "/users") return title === "Users";
101
151
  if (item.href === adminPath + "/roles") return title === "Permissions";
102
152
  if (item.href === adminPath + "/audit") return title === "Audit Logs";
@@ -105,8 +155,7 @@ function isActive(item: NavItem): boolean {
105
155
  if (item.href === adminPath + "/plugins") return title === "Plugins";
106
156
  if (item.href === adminPath + "/marketplace") return title === "Marketplace";
107
157
  // Collections: match /collection-name and /collection-name/* paths
108
- if (currentPath === item.href || currentPath.startsWith(item.href + "/"))
109
- return true;
158
+ if (currentPath === item.href || currentPath.startsWith(item.href + "/")) return true;
110
159
  return false;
111
160
  }
112
161
  ---
@@ -115,66 +164,112 @@ function isActive(item: NavItem): boolean {
115
164
  id="kyro-sidebar"
116
165
  class="surface-tile w-[280px] md:w-[320px] flex flex-col flex-shrink-0 overflow-hidden fixed md:static inset-y-0 left-0 z-50 md:z-auto transition-transform duration-300 -translate-x-full md:translate-x-0 h-[100dvh] md:h-auto border-r md:border-r-0 border-[var(--kyro-border)] rounded-r-2xl md:rounded-3xl rounded-l-none md:rounded-l-3xl shadow-2xl md:shadow-none"
117
166
  >
118
- <div class="px-6 md:px-8 py-6 md:py-8 flex items-center justify-between gap-3">
119
- {
120
- siteLogo || darkLogo ? (
121
- <div class="flex items-center gap-3">
122
- <div class="flex items-center">
123
- {siteLogo && (
124
- <img
125
- src={siteLogo.url}
126
- alt={logoAlt}
127
- style={{
128
- width: logoWidth ? `${logoWidth}px` : "auto",
129
- height: logoHeight ? `${logoHeight}px` : "32px",
130
- objectFit: "contain",
131
- }}
132
- class={`rounded-lg ${darkLogo ? "block dark:hidden" : ""}`}
133
- />
134
- )}
135
- {darkLogo && (
136
- <img
137
- src={darkLogo.url}
138
- alt={logoAlt}
139
- style={{
140
- width: logoWidth ? `${logoWidth}px` : "auto",
141
- height: logoHeight ? `${logoHeight}px` : "32px",
142
- objectFit: "contain",
143
- }}
144
- class={`rounded-lg ${siteLogo ? "hidden dark:block" : ""}`}
145
- />
167
+ <div
168
+ class="sidebar-header px-6 md:px-6 py-6 flex items-center justify-between gap-2 border-b border-[var(--kyro-border)]/50 shrink-0 transition-all duration-300"
169
+ >
170
+ <div class="sidebar-brand flex items-center gap-3 overflow-hidden min-w-0 transition-all duration-300">
171
+ {
172
+ siteLogo || darkLogo ? (
173
+ <div class="flex items-center gap-3">
174
+ <div class="flex items-center flex-shrink-0">
175
+ {siteLogo && (
176
+ <img
177
+ src={siteLogo.url}
178
+ alt={logoAlt}
179
+ style={{
180
+ width: logoWidth ? `${logoWidth}px` : "auto",
181
+ height: logoHeight ? `${logoHeight}px` : "32px",
182
+ objectFit: "contain",
183
+ }}
184
+ class={`rounded-lg ${darkLogo ? "block dark:hidden" : ""}`}
185
+ />
186
+ )}
187
+ {darkLogo && (
188
+ <img
189
+ src={darkLogo.url}
190
+ alt={logoAlt}
191
+ style={{
192
+ width: logoWidth ? `${logoWidth}px` : "auto",
193
+ height: logoHeight ? `${logoHeight}px` : "32px",
194
+ objectFit: "contain",
195
+ }}
196
+ class={`rounded-lg ${siteLogo ? "hidden dark:block" : ""}`}
197
+ />
198
+ )}
199
+ </div>
200
+ {showSiteName && (
201
+ <span class="sidebar-text text-xl font-bold tracking-tight text-[var(--kyro-text-primary)] truncate transition-opacity duration-200">
202
+ {siteName}
203
+ </span>
146
204
  )}
147
205
  </div>
148
- {showSiteName && (
149
- <span class="text-xl font-bold tracking-tight text-[var(--kyro-text-primary)]">
206
+ ) : (
207
+ <div class="flex items-center gap-3 overflow-hidden">
208
+ <img src="/logo-white.svg" alt="Kyro CMS" class="w-7 h-7 hidden dark:block flex-shrink-0" />
209
+ <img src="/logo.svg" alt="Kyro CMS" class="w-7 h-7 block dark:hidden flex-shrink-0" />
210
+ <span class="sidebar-text text-2xl font-black tracking-tighter text-[var(--kyro-text-primary)] truncate transition-opacity duration-200">
150
211
  {siteName}
151
212
  </span>
152
- )}
153
- </div>
154
- ) : (
155
- <div class="flex items-center gap-3">
156
- <img src="/logo-white.svg" alt="Kyro CMS" class="w-7 h-7 hidden dark:block" />
157
- <img src="/logo.svg" alt="Kyro CMS" class="w-7 h-7 block dark:hidden" />
158
- <span class="text-2xl font-black tracking-tighter text-[var(--kyro-text-primary)]">
159
- {siteName}
160
- </span>
161
- </div>
162
- )
163
- }
164
- <button id="mobile-close-btn" class="md:hidden p-2 -mr-2 rounded-lg text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] transition-colors">
165
- <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
213
+ </div>
214
+ )
215
+ }
216
+ </div>
217
+
218
+ {/* Desktop Panel Toggle Button */}
219
+ <button
220
+ id="sidebar-panel-toggle"
221
+ type="button"
222
+ title="Minimize Sidebar"
223
+ aria-label="Toggle Sidebar"
224
+ class="hidden md:flex items-center justify-center w-7 h-7 rounded-lg border border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface)] transition-all flex-shrink-0 cursor-pointer shadow-sm active:scale-95"
225
+ >
226
+ <svg
227
+ id="sidebar-toggle-icon"
228
+ class="w-4 h-4 transition-transform duration-300"
229
+ fill="none"
230
+ stroke="currentColor"
231
+ viewBox="0 0 24 24"
232
+ >
233
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7"></path>
234
+ </svg>
235
+ </button>
236
+
237
+ {/* Mobile Close Button */}
238
+ <button
239
+ id="mobile-close-btn"
240
+ class="md:hidden p-2 -mr-2 rounded-lg text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] transition-colors"
241
+ >
242
+ <svg
243
+ xmlns="http://www.w3.org/2000/svg"
244
+ width="20"
245
+ height="20"
246
+ viewBox="0 0 24 24"
247
+ fill="none"
248
+ stroke="currentColor"
249
+ stroke-width="2"
250
+ stroke-linecap="round"
251
+ stroke-linejoin="round"
252
+ class="lucide lucide-x"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg
253
+ >
166
254
  </button>
167
255
  </div>
168
256
 
169
- <nav class="flex-1 px-4 overflow-y-auto" id="sidebar-nav">
257
+ <nav class="flex-1 px-4 overflow-y-auto overflow-x-hidden" id="sidebar-nav">
170
258
  <div class="space-y-4">
171
259
  {
172
260
  navSections.map((section) => (
173
261
  <div class="space-y-2 group/section" data-section={section.label}>
174
262
  <div class="pt-4 pb-2">
175
- <button type="button" class="w-full flex items-center justify-between px-6 focus:outline-none sidebar-section-toggle transition-opacity hover:opacity-80">
263
+ <button
264
+ type="button"
265
+ class="w-full flex items-center justify-between px-6 focus:outline-none sidebar-section-toggle transition-opacity hover:opacity-80"
266
+ >
176
267
  <p class="text-[10px] font-medium text-[var(--kyro-text-secondary)] tracking-[0.2em] opacity-40 uppercase">
177
- {i18next.t(section.label, { defaultValue: section.label.split('.').pop()?.toUpperCase() || section.label }) || (section.label.split('.').pop()?.toUpperCase() || section.label)}
268
+ {i18next.t(section.label, {
269
+ defaultValue: section.label.split(".").pop()?.toUpperCase() || section.label,
270
+ }) ||
271
+ section.label.split(".").pop()?.toUpperCase() ||
272
+ section.label}
178
273
  </p>
179
274
  <Icons.ChevronDown className="w-3.5 h-3.5 text-[var(--kyro-text-secondary)] opacity-40 transition-transform duration-200 group-data-[collapsed=true]/section:-rotate-90" />
180
275
  </button>
@@ -182,38 +277,36 @@ function isActive(item: NavItem): boolean {
182
277
  <div class="section-items space-y-1">
183
278
  {section.items.map((item) => {
184
279
  const slug = item.href.split("/").pop();
185
- const type = item.href.includes("/settings")
186
- ? "global"
187
- : "collection";
280
+ const type = item.href.includes("/settings") ? "global" : "collection";
281
+ const fallback = item.label.split(".").pop() || item.label;
282
+ const capitalized = fallback.charAt(0).toUpperCase() + fallback.slice(1);
283
+ const itemText = i18next.t(item.label, { defaultValue: capitalized }) || capitalized;
188
284
  return (
189
285
  <a
190
286
  href={item.href}
287
+ title={itemText}
191
288
  data-nav-item
192
289
  data-slug={slug}
193
290
  data-type={type}
194
- class={`flex items-center gap-4 px-6 py-2 rounded-2xl transition-all font-medium text-[13px] ${
291
+ data-is-dashboard={item.href === adminPath ? "true" : "false"}
292
+ class={`flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all font-medium text-sm ${
195
293
  item.icon === "collection"
196
- ? currentPath === item.href ||
197
- currentPath.startsWith(item.href + "/")
294
+ ? currentPath === item.href || currentPath.startsWith(item.href + "/")
198
295
  ? "bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-primary)] active-nav-item"
199
296
  : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] hover:text-[var(--kyro-text-primary)]"
200
297
  : isActive(item)
201
- ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] shadow-lg active-nav-item"
298
+ ? "bg-[var(--kyro-sidebar-active)] text-[var(--kyro-sidebar-text-active)] shadow-md active-nav-item"
202
299
  : "text-[var(--kyro-text-secondary)] hover:bg-[var(--kyro-surface-accent)] hover:text-[var(--kyro-text-primary)]"
203
300
  }`}
204
301
  >
205
- {(() => {
206
- const iconName = iconAliases[item.icon] || item.icon;
207
- const Icon = (Icons as any)[iconName] || Icons.Dot;
208
- return <Icon className="w-4 h-4" strokeWidth={2.5} />;
209
- })()}
210
- <span>
302
+ <div class="nav-icon-wrapper flex items-center justify-center flex-shrink-0">
211
303
  {(() => {
212
- const fallback = item.label.split('.').pop() || item.label;
213
- const capitalized = fallback.charAt(0).toUpperCase() + fallback.slice(1);
214
- return i18next.t(item.label, { defaultValue: capitalized }) || capitalized;
304
+ const iconName = iconAliases[item.icon] || item.icon;
305
+ const Icon = (Icons as any)[iconName] || Icons.Dot;
306
+ return <Icon className="w-4 h-4 flex-shrink-0" strokeWidth={2.5} />;
215
307
  })()}
216
- </span>
308
+ </div>
309
+ <span class="sidebar-text truncate">{itemText}</span>
217
310
  </a>
218
311
  );
219
312
  })}
@@ -225,96 +318,451 @@ function isActive(item: NavItem): boolean {
225
318
  </nav>
226
319
 
227
320
  <script>
321
+ import { adminPath } from "../lib/paths";
322
+
228
323
  function initSidebar() {
229
324
  const sections = document.querySelectorAll("[data-section]");
230
325
  sections.forEach((section) => {
231
326
  const label = section.getAttribute("data-section");
232
- const toggleBtn = section.querySelector('.sidebar-section-toggle');
233
- const itemsContainer = section.querySelector('.section-items');
234
-
235
- const isCollapsed = localStorage.getItem(`kyro-sidebar-collapsed-${label}`) === 'true';
327
+ const toggleBtn = section.querySelector(".sidebar-section-toggle");
328
+ const itemsContainer = section.querySelector(".section-items");
329
+
330
+ const isCollapsed = localStorage.getItem(`kyro-sidebar-collapsed-${label}`) === "true";
236
331
  if (isCollapsed) {
237
- section.setAttribute('data-collapsed', 'true');
238
- if (itemsContainer) itemsContainer.classList.add('hidden');
332
+ section.setAttribute("data-collapsed", "true");
333
+ if (itemsContainer) itemsContainer.classList.add("hidden");
239
334
  }
240
335
 
241
- const activeItem = section.querySelector('.active-nav-item');
336
+ const activeItem = section.querySelector(".active-nav-item");
242
337
  if (activeItem && isCollapsed) {
243
- section.removeAttribute('data-collapsed');
244
- if (itemsContainer) itemsContainer.classList.remove('hidden');
245
- localStorage.setItem(`kyro-sidebar-collapsed-${label}`, 'false');
338
+ section.removeAttribute("data-collapsed");
339
+ if (itemsContainer) itemsContainer.classList.remove("hidden");
340
+ localStorage.setItem(`kyro-sidebar-collapsed-${label}`, "false");
246
341
  }
247
-
342
+
248
343
  // Remove old listeners to prevent duplicates
249
344
  const newToggleBtn = toggleBtn?.cloneNode(true);
250
345
  if (toggleBtn && newToggleBtn && toggleBtn.parentNode) {
251
346
  toggleBtn.parentNode.replaceChild(newToggleBtn, toggleBtn);
252
- newToggleBtn.addEventListener('click', () => {
253
- const currentlyCollapsed = section.getAttribute('data-collapsed') === 'true';
254
- if (currentlyCollapsed) {
255
- section.removeAttribute('data-collapsed');
256
- if (itemsContainer) itemsContainer.classList.remove('hidden');
257
- localStorage.setItem(`kyro-sidebar-collapsed-${label}`, 'false');
258
- } else {
259
- section.setAttribute('data-collapsed', 'true');
260
- if (itemsContainer) itemsContainer.classList.add('hidden');
261
- localStorage.setItem(`kyro-sidebar-collapsed-${label}`, 'true');
262
- }
347
+ newToggleBtn.addEventListener("click", () => {
348
+ const currentlyCollapsed = section.getAttribute("data-collapsed") === "true";
349
+ if (currentlyCollapsed) {
350
+ section.removeAttribute("data-collapsed");
351
+ if (itemsContainer) itemsContainer.classList.remove("hidden");
352
+ localStorage.setItem(`kyro-sidebar-collapsed-${label}`, "false");
353
+ } else {
354
+ section.setAttribute("data-collapsed", "true");
355
+ if (itemsContainer) itemsContainer.classList.add("hidden");
356
+ localStorage.setItem(`kyro-sidebar-collapsed-${label}`, "true");
357
+ }
263
358
  });
264
359
  }
265
360
  });
266
361
  }
267
362
 
268
- // Run on load and view transitions
269
- initSidebar();
270
- document.addEventListener('astro:page-load', initSidebar);
363
+ function pruneSidebar(user?: any, permissions?: any) {
364
+ if (!user) {
365
+ // @ts-ignore
366
+ user = window.__kyroAuth?.user;
367
+ // @ts-ignore
368
+ permissions = window.__kyroAuth?.permissions;
369
+ }
370
+ if (!user) return;
271
371
 
272
- // Handle frontend RBAC pruning in the sidebar
273
- window.addEventListener("kyro:auth-ready", (event) => {
274
- // @ts-ignore
275
- const { permissions } = event.detail;
276
- if (!permissions) return;
372
+ const userRole = user.role || "";
373
+ const isSuperAdmin = userRole === "super_admin";
374
+ const isAdmin = userRole === "admin" || isSuperAdmin;
375
+ const isEditor = userRole === "editor";
376
+ const isAuthor = userRole === "author";
377
+ const isCustomer = userRole === "customer";
277
378
 
278
379
  const navItems = document.querySelectorAll("[data-nav-item]");
279
380
  navItems.forEach((item) => {
280
- const slug = item.getAttribute("data-slug");
281
- const type = item.getAttribute("data-type");
381
+ const slug = item.getAttribute("data-slug") || "";
382
+ const type = item.getAttribute("data-type") || "";
282
383
 
283
384
  let hasAccess = true;
284
- if (type === "collection" && permissions.collections) {
285
- const colPerms = permissions.collections[slug];
286
- if (colPerms && colPerms.read === false) hasAccess = false;
287
- } else if (type === "global" && permissions.globals) {
288
- const globalPerms = permissions.globals[slug];
289
- if (globalPerms && globalPerms.read === false) hasAccess = false;
385
+
386
+ const isDashboard =
387
+ item.getAttribute("data-is-dashboard") === "true" || item.getAttribute("href") === adminPath;
388
+ if (isDashboard) {
389
+ hasAccess = true;
390
+ } else if (slug === "roles") {
391
+ hasAccess = isSuperAdmin;
392
+ } else if (slug === "plugins" || slug === "keys" || slug === "webhooks" || slug === "settings") {
393
+ hasAccess = isAdmin;
394
+ } else if (slug === "users") {
395
+ hasAccess = isAdmin || permissions?.collections?.users?.read === true;
396
+ } else if (slug === "audit" || slug === "audit_logs") {
397
+ hasAccess = isAdmin || permissions?.collections?.audit_logs?.read === true;
398
+ } else if (slug === "media") {
399
+ hasAccess =
400
+ !isCustomer && (isAdmin || isEditor || isAuthor || permissions?.collections?.media?.read === true);
401
+ } else if (type === "collection") {
402
+ if (isAdmin) {
403
+ hasAccess = true;
404
+ } else if (permissions?.collections?.[slug]) {
405
+ hasAccess = permissions.collections[slug].read === true;
406
+ } else if (isEditor) {
407
+ hasAccess = !["users", "audit_logs", "roles", "plugins", "keys", "webhooks", "settings"].includes(slug);
408
+ } else if (isAuthor) {
409
+ hasAccess = ["posts", "categories", "orders"].includes(slug);
410
+ } else if (isCustomer) {
411
+ hasAccess = ["orders"].includes(slug);
412
+ } else {
413
+ hasAccess = false;
414
+ }
415
+ } else if (type === "global") {
416
+ if (!isAdmin) {
417
+ hasAccess = permissions?.globals?.[slug]?.read === true;
418
+ }
290
419
  }
291
420
 
292
421
  if (!hasAccess) {
293
422
  // @ts-ignore
294
423
  item.style.display = "none";
424
+ } else {
425
+ // @ts-ignore
426
+ item.style.display = "";
295
427
  }
296
428
  });
297
429
 
298
430
  // Hide empty sections after RBAC
299
431
  const sections = document.querySelectorAll("[data-section]");
300
432
  sections.forEach((section) => {
301
- const visibleItems = section.querySelectorAll(
302
- '[data-nav-item]:not([style*="display: none"])'
303
- );
433
+ const visibleItems = section.querySelectorAll('[data-nav-item]:not([style*="display: none"])');
304
434
  if (visibleItems.length === 0) {
305
435
  // @ts-ignore
306
436
  section.style.display = "none";
437
+ } else {
438
+ // @ts-ignore
439
+ section.style.display = "";
307
440
  }
308
441
  });
442
+ }
443
+
444
+ const runPruning = () => {
445
+ initSidebar();
446
+ pruneSidebar();
447
+ initPanelToggle();
448
+ initTooltips();
449
+ };
450
+
451
+ function initTooltips() {
452
+ const sidebar = document.getElementById("kyro-sidebar");
453
+ if (!sidebar) return;
454
+
455
+ let tooltipBadge = document.getElementById("sidebar-tooltip-badge");
456
+ if (!tooltipBadge) {
457
+ tooltipBadge = document.createElement("div");
458
+ tooltipBadge.id = "sidebar-tooltip-badge";
459
+ tooltipBadge.className =
460
+ "fixed z-[9999] pointer-events-none opacity-0 transition-opacity duration-150 ease-out px-2.5 py-1.5 text-xs font-semibold rounded-sm bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-primary)] border border-[var(--kyro-border)] shadow-md whitespace-nowrap hidden";
461
+ document.body.appendChild(tooltipBadge);
462
+ }
463
+
464
+ const navItems = document.querySelectorAll("[data-nav-item]");
465
+ navItems.forEach((item) => {
466
+ const handleMouseEnter = () => {
467
+ if (sidebar.getAttribute("data-minimized") !== "true") return;
468
+ const textSpan = item.querySelector(".sidebar-text");
469
+ const label = textSpan?.textContent?.trim() || item.getAttribute("title") || "";
470
+ if (!label || !tooltipBadge) return;
471
+
472
+ tooltipBadge.textContent = label;
473
+ tooltipBadge.classList.remove("hidden");
474
+
475
+ const rect = item.getBoundingClientRect();
476
+ const top = rect.top + rect.height / 2;
477
+ const left = rect.right + 10;
478
+
479
+ tooltipBadge.style.top = `${top}px`;
480
+ tooltipBadge.style.left = `${left}px`;
481
+ tooltipBadge.style.transform = "translateY(-50%)";
482
+
483
+ requestAnimationFrame(() => {
484
+ tooltipBadge?.classList.remove("opacity-0");
485
+ tooltipBadge?.classList.add("opacity-100");
486
+ });
487
+ };
488
+
489
+ const handleMouseLeave = () => {
490
+ if (!tooltipBadge) return;
491
+ tooltipBadge.classList.remove("opacity-100");
492
+ tooltipBadge.classList.add("opacity-0");
493
+ setTimeout(() => {
494
+ if (tooltipBadge?.classList.contains("opacity-0")) {
495
+ tooltipBadge.classList.add("hidden");
496
+ }
497
+ }, 150);
498
+ };
499
+
500
+ item.removeEventListener("mouseenter", (item as any)._tpEnter);
501
+ item.removeEventListener("mouseleave", (item as any)._tpLeave);
502
+ (item as any)._tpEnter = handleMouseEnter;
503
+ (item as any)._tpLeave = handleMouseLeave;
504
+ item.addEventListener("mouseenter", handleMouseEnter);
505
+ item.addEventListener("mouseleave", handleMouseLeave);
506
+ });
507
+
508
+ const navContainer = document.getElementById("sidebar-nav");
509
+ if (navContainer) {
510
+ navContainer.addEventListener(
511
+ "scroll",
512
+ () => {
513
+ if (tooltipBadge) {
514
+ tooltipBadge.classList.remove("opacity-100");
515
+ tooltipBadge.classList.add("opacity-0", "hidden");
516
+ }
517
+ },
518
+ { passive: true },
519
+ );
520
+ }
521
+ }
522
+
523
+ function initPanelToggle() {
524
+ const sidebar = document.getElementById("kyro-sidebar");
525
+ const toggleBtn = document.getElementById("sidebar-panel-toggle");
526
+ if (!sidebar || !toggleBtn) return;
527
+
528
+ const updateMinimizedUI = (isMin: boolean) => {
529
+ if (isMin) {
530
+ sidebar.setAttribute("data-minimized", "true");
531
+ sidebar.classList.remove("md:w-[280px]", "md:w-[320px]");
532
+ sidebar.classList.add("md:w-[76px]");
533
+ toggleBtn.setAttribute("title", "Expand Sidebar");
534
+ } else {
535
+ sidebar.removeAttribute("data-minimized");
536
+ sidebar.classList.remove("md:w-[76px]");
537
+ sidebar.classList.add("md:w-[280px]");
538
+ toggleBtn.setAttribute("title", "Minimize Sidebar");
539
+ }
540
+ };
541
+
542
+ const savedMin = localStorage.getItem("kyro-sidebar-minimized") === "true";
543
+ updateMinimizedUI(savedMin);
544
+
545
+ const newToggleBtn = toggleBtn.cloneNode(true);
546
+ if (toggleBtn.parentNode) {
547
+ toggleBtn.parentNode.replaceChild(newToggleBtn, toggleBtn);
548
+ newToggleBtn.addEventListener("click", () => {
549
+ const isNowMin = sidebar.getAttribute("data-minimized") !== "true";
550
+ localStorage.setItem("kyro-sidebar-minimized", isNowMin ? "true" : "false");
551
+ updateMinimizedUI(isNowMin);
552
+ });
553
+ }
554
+ }
555
+
556
+ // Run on load and view transitions
557
+ runPruning();
558
+ document.addEventListener("astro:page-load", runPruning);
559
+
560
+ // Handle frontend RBAC pruning in the sidebar
561
+ window.addEventListener("kyro:auth-ready", (event) => {
562
+ // @ts-ignore
563
+ const { user, permissions } = event.detail || {};
564
+ pruneSidebar(user, permissions);
309
565
  });
566
+
567
+ // Fallback interval check to ensure pruning runs after async auth resolves
568
+ const authCheckInterval = setInterval(() => {
569
+ // @ts-ignore
570
+ if (window.__kyroAuth?.verified) {
571
+ runPruning();
572
+ clearInterval(authCheckInterval);
573
+ }
574
+ }, 100);
575
+ setTimeout(() => clearInterval(authCheckInterval), 5000);
310
576
  </script>
311
577
 
312
- <div class="px-6 py-4 mt-auto">
578
+ <style is:global>
579
+ #kyro-sidebar {
580
+ transition:
581
+ width 300ms cubic-bezier(0.4, 0, 0.2, 1),
582
+ transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
583
+ overflow-x: hidden !important;
584
+ }
585
+
586
+ #kyro-sidebar #sidebar-nav {
587
+ overflow-x: hidden !important;
588
+ }
589
+
590
+ /* Collapsed Rail State */
591
+ #kyro-sidebar[data-minimized="true"] {
592
+ width: 76px !important;
593
+ min-width: 76px !important;
594
+ max-width: 76px !important;
595
+ overflow-x: hidden !important;
596
+ }
597
+ #kyro-sidebar[data-minimized="true"] #sidebar-nav,
598
+ #kyro-sidebar[data-minimized="true"] #sidebar-nav > div,
599
+ #kyro-sidebar[data-minimized="true"] #sidebar-nav .group\/section,
600
+ #kyro-sidebar[data-minimized="true"] #sidebar-nav .section-items {
601
+ padding-left: 0 !important;
602
+ padding-right: 0 !important;
603
+ margin-left: 0 !important;
604
+ margin-right: 0 !important;
605
+ width: 100% !important;
606
+ max-width: 100% !important;
607
+ min-width: 0 !important;
608
+ overflow-x: hidden !important;
609
+ box-sizing: border-box !important;
610
+ }
611
+ #kyro-sidebar[data-minimized="true"] #sidebar-nav::-webkit-scrollbar {
612
+ display: none !important;
613
+ width: 0 !important;
614
+ height: 0 !important;
615
+ }
616
+
617
+ #kyro-sidebar[data-minimized="true"] .sidebar-header {
618
+ padding-left: 0 !important;
619
+ padding-right: 0 !important;
620
+ padding-top: 1rem !important;
621
+ padding-bottom: 0.75rem !important;
622
+ flex-direction: column !important;
623
+ align-items: center !important;
624
+ justify-content: center !important;
625
+ gap: 0.5rem !important;
626
+ }
627
+ #kyro-sidebar[data-minimized="true"] .sidebar-brand {
628
+ justify-content: center !important;
629
+ width: 100% !important;
630
+ margin: 0 auto !important;
631
+ }
632
+ #kyro-sidebar[data-minimized="true"] .sidebar-brand > div {
633
+ justify-content: center !important;
634
+ width: 100% !important;
635
+ margin: 0 auto !important;
636
+ }
637
+ #kyro-sidebar[data-minimized="true"] .sidebar-brand img {
638
+ margin: 0 auto !important;
639
+ width: 28px !important;
640
+ height: 28px !important;
641
+ max-width: 28px !important;
642
+ max-height: 28px !important;
643
+ }
644
+
645
+ /* Hide text & section headers cleanly */
646
+ #kyro-sidebar[data-minimized="true"] .sidebar-text,
647
+ #kyro-sidebar[data-minimized="true"] .sidebar-section-toggle,
648
+ #kyro-sidebar[data-minimized="true"] [data-section] > div:first-child {
649
+ display: none !important;
650
+ width: 0 !important;
651
+ height: 0 !important;
652
+ opacity: 0 !important;
653
+ overflow: hidden !important;
654
+ visibility: hidden !important;
655
+ font-size: 0 !important;
656
+ line-height: 0 !important;
657
+ margin: 0 !important;
658
+ padding: 0 !important;
659
+ }
660
+
661
+ /* Center Nav Item Buttons and force Icons to be visible & perfectly centered */
662
+ #kyro-sidebar[data-minimized="true"] a[data-nav-item] {
663
+ display: flex !important;
664
+ align-items: center !important;
665
+ justify-content: center !important;
666
+ gap: 0 !important;
667
+ width: 28px !important;
668
+ height: 28px !important;
669
+ min-width: 28px !important;
670
+ max-width: 28px !important;
671
+ margin-left: auto !important;
672
+ margin-right: auto !important;
673
+ margin-top: 8px !important;
674
+ margin-bottom: 4px !important;
675
+ border-radius: 5px !important;
676
+ padding: 8px !important;
677
+ }
678
+
679
+ #kyro-sidebar[data-minimized="true"] .nav-icon-wrapper {
680
+ display: flex !important;
681
+ align-items: center !important;
682
+ justify-content: center !important;
683
+ width: 100% !important;
684
+ height: 100% !important;
685
+ margin: 0 !important;
686
+ padding: 0 !important;
687
+ }
688
+
689
+ /* #kyro-sidebar[data-minimized="true"] .nav-icon-wrapper svg {
690
+ display: block !important;
691
+ width: 20px !important;
692
+ height: 20px !important;
693
+ min-width: 20px !important;
694
+ min-height: 20px !important;
695
+ max-width: 20px !important;
696
+ max-height: 20px !important;
697
+ flex-shrink: 0 !important;
698
+ margin: 0 auto !important;
699
+ visibility: visible !important;
700
+ opacity: 1 !important;
701
+ } */
702
+
703
+ #kyro-sidebar[data-minimized="true"] #sidebar-toggle-icon {
704
+ transform: rotate(180deg);
705
+ }
706
+
707
+ /* Footer & User Menu Rail alignment for Collapsed State */
708
+ #kyro-sidebar[data-minimized="true"] .sidebar-footer {
709
+ padding: 0.75rem 0 !important;
710
+ margin-top: auto !important;
711
+ width: 100% !important;
712
+ }
713
+ #kyro-sidebar[data-minimized="true"] .sidebar-footer-inner {
714
+ display: flex !important;
715
+ flex-direction: column-reverse !important;
716
+ gap: 0.625rem !important;
717
+ padding: 0 !important;
718
+ align-items: center !important;
719
+ justify-content: center !important;
720
+ background: transparent !important;
721
+ border: none !important;
722
+ box-shadow: none !important;
723
+ width: 100% !important;
724
+ }
725
+ #kyro-sidebar[data-minimized="true"] .theme-toggle-box {
726
+ display: flex !important;
727
+ flex-direction: column !important;
728
+ align-items: center !important;
729
+ justify-content: center !important;
730
+ gap: 0.25rem !important;
731
+ padding: 0.25rem !important;
732
+ width: 32px !important;
733
+ margin: 0 auto !important;
734
+ border-radius: 8px !important;
735
+ }
736
+ #kyro-sidebar[data-minimized="true"] #theme-light-btn,
737
+ #kyro-sidebar[data-minimized="true"] #theme-dark-btn {
738
+ width: 24px !important;
739
+ height: 24px !important;
740
+ padding: 0 !important;
741
+ display: flex !important;
742
+ align-items: center !important;
743
+ justify-content: center !important;
744
+ border-radius: 6px !important;
745
+ }
746
+ #kyro-sidebar[data-minimized="true"] .sidebar-footer-inner > div.flex {
747
+ width: 100% !important;
748
+ justify-content: center !important;
749
+ gap: 0 !important;
750
+ }
751
+ #kyro-sidebar[data-minimized="true"] .sidebar-footer-inner [title*="Account"],
752
+ #kyro-sidebar[data-minimized="true"] .sidebar-footer-inner div[class*="items-center"][class*="justify-center"] {
753
+ width: 32px !important;
754
+ height: 32px !important;
755
+ margin: 0 auto !important;
756
+ border-radius: 8px !important;
757
+ }
758
+ </style>
759
+
760
+ <div class="sidebar-footer px-6 py-4 mt-auto transition-all duration-300">
313
761
  <div
314
- class="flex items-center justify-between p-2 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-2xl"
762
+ class="sidebar-footer-inner flex items-center justify-between p-2 bg-[var(--kyro-surface-accent)] border border-[var(--kyro-border)] rounded-2xl transition-all duration-300"
315
763
  >
316
764
  <div
317
- class="flex p-1 bg-[var(--kyro-bg)] rounded-xl border border-[var(--kyro-border)] shadow-inner"
765
+ class="theme-toggle-box flex p-1 bg-[var(--kyro-bg)] rounded-xl border border-[var(--kyro-border)] shadow-inner transition-all duration-300"
318
766
  >
319
767
  <button
320
768
  id="theme-light-btn"