|
1 | 1 | <script lang="ts"> |
2 | 2 | import { SITE_MAP, checkRoles } from "$lib/utils/roleChecker"; |
3 | 3 | import type { UserEntitlement, RoleRequirement } from "$lib/utils/roleChecker"; |
4 | | - import { Check, X, Search } from "@lucide/svelte"; |
| 4 | + import { Check, X, Search, ChevronRight } from "@lucide/svelte"; |
5 | 5 | import MissingRoleAlert from "$lib/components/MissingRoleAlert.svelte"; |
| 6 | + import { currentBank } from "$lib/stores/currentBank.svelte"; |
6 | 7 |
|
7 | 8 | const { data } = $props(); |
8 | 9 |
|
|
31 | 32 | function hasRole(role: RoleRequirement): boolean { |
32 | 33 | return userEntitlements.some((e) => { |
33 | 34 | if (e.role_name !== role.role) return false; |
| 35 | + if (role.bankScoped) return currentBank.bankId ? e.bank_id === currentBank.bankId : false; |
34 | 36 | if (role.bankId) return e.bank_id === role.bankId; |
35 | 37 | return true; |
36 | 38 | }); |
|
43 | 45 | accessible: boolean; |
44 | 46 | } |
45 | 47 |
|
| 48 | + // Tree node structure |
| 49 | + interface TreeNode { |
| 50 | + segment: string; |
| 51 | + fullPath: string; |
| 52 | + entry: PageEntry | null; |
| 53 | + children: TreeNode[]; |
| 54 | + } |
| 55 | +
|
46 | 56 | let allPages: PageEntry[] = $derived( |
47 | 57 | Object.entries(SITE_MAP).map(([route, config]) => { |
48 | | - const result = checkRoles(userEntitlements, config.required); |
| 58 | + const result = checkRoles(userEntitlements, config.required, currentBank.bankId); |
49 | 59 | return { |
50 | 60 | route, |
51 | 61 | required: config.required, |
|
67 | 77 | }) |
68 | 78 | ); |
69 | 79 |
|
| 80 | + // Build a tree from routes within a section |
| 81 | + function buildTree(pages: PageEntry[], sectionPrefix: string): TreeNode[] { |
| 82 | + const root: TreeNode[] = []; |
| 83 | +
|
| 84 | + for (const page of pages) { |
| 85 | + // Strip the section prefix to get the relative path |
| 86 | + let relative = page.route; |
| 87 | + for (const s of SECTION_ORDER) { |
| 88 | + if (relative.startsWith(s.prefix)) { |
| 89 | + relative = relative.slice(s.prefix.length); |
| 90 | + break; |
| 91 | + } |
| 92 | + } |
| 93 | + // For routes like /consumers or /connector-metrics that match at the prefix level |
| 94 | + if (relative === page.route) { |
| 95 | + // Use the full route minus leading slash |
| 96 | + relative = page.route.replace(/^\//, ""); |
| 97 | + } |
| 98 | +
|
| 99 | + const segments = relative.split("/").filter(Boolean); |
| 100 | + let currentLevel = root; |
| 101 | +
|
| 102 | + for (let i = 0; i < segments.length; i++) { |
| 103 | + const segment = segments[i]; |
| 104 | + const isLast = i === segments.length - 1; |
| 105 | + let existing = currentLevel.find((n) => n.segment === segment); |
| 106 | +
|
| 107 | + if (!existing) { |
| 108 | + existing = { |
| 109 | + segment, |
| 110 | + fullPath: page.route, |
| 111 | + entry: isLast ? page : null, |
| 112 | + children: [], |
| 113 | + }; |
| 114 | + currentLevel.push(existing); |
| 115 | + } else if (isLast) { |
| 116 | + existing.entry = page; |
| 117 | + existing.fullPath = page.route; |
| 118 | + } |
| 119 | +
|
| 120 | + currentLevel = existing.children; |
| 121 | + } |
| 122 | + } |
| 123 | +
|
| 124 | + return root; |
| 125 | + } |
| 126 | +
|
70 | 127 | let groupedPages = $derived.by(() => { |
71 | 128 | const groups: Record<string, PageEntry[]> = {}; |
72 | 129 | for (const p of filteredPages) { |
|
77 | 134 | return groups; |
78 | 135 | }); |
79 | 136 |
|
80 | | - // Ordered section keys for display |
| 137 | + let sectionTrees = $derived.by(() => { |
| 138 | + const trees: Record<string, TreeNode[]> = {}; |
| 139 | + for (const [key, pages] of Object.entries(groupedPages)) { |
| 140 | + const section = SECTION_ORDER.find((s) => s.key === key); |
| 141 | + trees[key] = buildTree(pages, section?.prefix || "/"); |
| 142 | + } |
| 143 | + return trees; |
| 144 | + }); |
| 145 | +
|
81 | 146 | let orderedSections = $derived( |
82 | 147 | [...SECTION_ORDER.map((s) => s.key), "other"].filter((k) => groupedPages[k]?.length) |
83 | 148 | ); |
|
87 | 152 | return found ? found.label : "Other"; |
88 | 153 | } |
89 | 154 |
|
90 | | - // Collect missing role names for a page entry (both required and optional) |
91 | 155 | function getMissingRoles(entry: PageEntry): string[] { |
92 | 156 | const missing: string[] = []; |
93 | 157 | for (const r of entry.required) { |
|
131 | 195 | /> |
132 | 196 | </div> |
133 | 197 |
|
134 | | -<!-- Grouped pages --> |
| 198 | +<!-- Grouped pages as trees --> |
135 | 199 | {#each orderedSections as sectionKey} |
136 | 200 | <div class="section"> |
137 | 201 | <div class="section-header"> |
138 | 202 | <h3 class="section-title">{sectionLabel(sectionKey)}</h3> |
139 | 203 | <span class="section-count">{groupedPages[sectionKey].length}</span> |
140 | 204 | </div> |
141 | 205 |
|
142 | | - <div class="page-list"> |
143 | | - {#each groupedPages[sectionKey] as entry} |
144 | | - <div class="page-entry" class:blocked={!entry.accessible}> |
145 | | - <a href={entry.route} class="route-link"> |
146 | | - {entry.route} |
147 | | - </a> |
148 | | - <div class="roles-list"> |
149 | | - {#each entry.required as role} |
150 | | - <span class="role-badge" class:has={hasRole(role)} class:missing={!hasRole(role)}> |
151 | | - {#if hasRole(role)} |
152 | | - <Check size={12} /> |
153 | | - {:else} |
154 | | - <X size={12} /> |
155 | | - {/if} |
156 | | - {role.role} |
157 | | - </span> |
158 | | - {/each} |
159 | | - {#each entry.optional as role} |
160 | | - <span class="role-badge optional" class:has={hasRole(role)} class:missing={!hasRole(role)}> |
161 | | - {#if hasRole(role)} |
162 | | - <Check size={12} /> |
163 | | - {:else} |
164 | | - <X size={12} /> |
165 | | - {/if} |
166 | | - {role.role} |
167 | | - <span class="optional-label">optional</span> |
168 | | - </span> |
169 | | - {/each} |
| 206 | + <div class="tree"> |
| 207 | + {#snippet renderNode(node: TreeNode, isLast: boolean, depth: number)} |
| 208 | + <div class="tree-item" class:blocked={node.entry && !node.entry.accessible}> |
| 209 | + <div class="tree-row"> |
| 210 | + <div class="tree-indent" style="width: {depth * 1.5}rem"></div> |
| 211 | + <span class="tree-connector">{isLast ? "└── " : "├── "}</span> |
| 212 | + {#if node.entry} |
| 213 | + <a href={node.entry.route} class="tree-link" class:blocked={!node.entry.accessible}> |
| 214 | + <span class="tree-segment">{node.segment}</span> |
| 215 | + </a> |
| 216 | + {:else} |
| 217 | + <span class="tree-segment tree-folder">{node.segment}</span> |
| 218 | + {/if} |
| 219 | + {#if node.entry} |
| 220 | + <div class="roles-list"> |
| 221 | + {#each node.entry.required as role} |
| 222 | + <span class="role-badge" class:has={hasRole(role)} class:missing={!hasRole(role)}> |
| 223 | + {#if hasRole(role)} |
| 224 | + <Check size={11} /> |
| 225 | + {:else} |
| 226 | + <X size={11} /> |
| 227 | + {/if} |
| 228 | + {role.role} |
| 229 | + {#if role.bankScoped} |
| 230 | + <span class="scope-label">bank</span> |
| 231 | + {/if} |
| 232 | + </span> |
| 233 | + {/each} |
| 234 | + {#each node.entry.optional as role} |
| 235 | + <span class="role-badge optional" class:has={hasRole(role)} class:missing={!hasRole(role)}> |
| 236 | + {#if hasRole(role)} |
| 237 | + <Check size={11} /> |
| 238 | + {:else} |
| 239 | + <X size={11} /> |
| 240 | + {/if} |
| 241 | + {role.role} |
| 242 | + <span class="optional-label">optional</span> |
| 243 | + </span> |
| 244 | + {/each} |
| 245 | + </div> |
| 246 | + {/if} |
170 | 247 | </div> |
171 | | - {#if getMissingRoles(entry).length > 0} |
172 | | - <MissingRoleAlert roles={getMissingRoles(entry)} /> |
| 248 | + {#if node.entry && getMissingRoles(node.entry).length > 0} |
| 249 | + <div class="tree-alert" style="margin-left: {(depth + 1) * 1.5 + 2.5}rem"> |
| 250 | + <MissingRoleAlert roles={getMissingRoles(node.entry)} /> |
| 251 | + </div> |
173 | 252 | {/if} |
174 | 253 | </div> |
| 254 | + {#each node.children as child, i} |
| 255 | + {@render renderNode(child, i === node.children.length - 1, depth + 1)} |
| 256 | + {/each} |
| 257 | + {/snippet} |
| 258 | + |
| 259 | + {#each sectionTrees[sectionKey] as node, i} |
| 260 | + {@render renderNode(node, i === sectionTrees[sectionKey].length - 1, 0)} |
175 | 261 | {/each} |
176 | 262 | </div> |
177 | 263 | </div> |
|
334 | 420 | color: var(--color-surface-300); |
335 | 421 | } |
336 | 422 |
|
337 | | - /* Page entries */ |
338 | | - .page-list { |
339 | | - display: flex; |
340 | | - flex-direction: column; |
| 423 | + /* Tree */ |
| 424 | + .tree { |
| 425 | + padding: 0.5rem 1rem 0.75rem; |
| 426 | + font-family: monospace; |
341 | 427 | } |
342 | 428 |
|
343 | | - .page-entry { |
344 | | - padding: 0.625rem 1rem; |
345 | | - border-bottom: 1px solid #f3f4f6; |
| 429 | + .tree-item { |
346 | 430 | display: flex; |
347 | 431 | flex-direction: column; |
348 | | - gap: 0.375rem; |
349 | 432 | } |
350 | 433 |
|
351 | | - .page-entry:last-child { |
352 | | - border-bottom: none; |
| 434 | + .tree-item.blocked { |
| 435 | + background: #fef2f2; |
| 436 | + border-radius: 4px; |
353 | 437 | } |
354 | 438 |
|
355 | | - :global([data-mode="dark"]) .page-entry { |
356 | | - border-bottom-color: rgb(var(--color-surface-700)); |
| 439 | + :global([data-mode="dark"]) .tree-item.blocked { |
| 440 | + background: rgba(220, 38, 38, 0.05); |
357 | 441 | } |
358 | 442 |
|
359 | | - .page-entry.blocked { |
360 | | - background: #fef2f2; |
| 443 | + .tree-row { |
| 444 | + display: flex; |
| 445 | + align-items: center; |
| 446 | + gap: 0; |
| 447 | + padding: 0.3rem 0; |
| 448 | + min-height: 1.75rem; |
361 | 449 | } |
362 | 450 |
|
363 | | - :global([data-mode="dark"]) .page-entry.blocked { |
364 | | - background: rgba(220, 38, 38, 0.05); |
| 451 | + .tree-indent { |
| 452 | + flex-shrink: 0; |
365 | 453 | } |
366 | 454 |
|
367 | | - .route-link { |
| 455 | + .tree-connector { |
| 456 | + flex-shrink: 0; |
| 457 | + color: #d1d5db; |
| 458 | + font-size: 0.8rem; |
| 459 | + white-space: pre; |
| 460 | + user-select: none; |
| 461 | + } |
| 462 | +
|
| 463 | + :global([data-mode="dark"]) .tree-connector { |
| 464 | + color: var(--color-surface-600); |
| 465 | + } |
| 466 | +
|
| 467 | + .tree-segment { |
368 | 468 | font-size: 0.8rem; |
369 | 469 | font-weight: 500; |
370 | | - color: #2563eb; |
| 470 | + } |
| 471 | +
|
| 472 | + .tree-folder { |
| 473 | + color: #6b7280; |
| 474 | + } |
| 475 | +
|
| 476 | + :global([data-mode="dark"]) .tree-folder { |
| 477 | + color: var(--color-surface-400); |
| 478 | + } |
| 479 | +
|
| 480 | + .tree-link { |
371 | 481 | text-decoration: none; |
| 482 | + color: #2563eb; |
372 | 483 | } |
373 | 484 |
|
374 | | - .route-link:hover { |
| 485 | + .tree-link:hover { |
375 | 486 | text-decoration: underline; |
376 | 487 | } |
377 | 488 |
|
378 | | - :global([data-mode="dark"]) .route-link { |
| 489 | + .tree-link.blocked { |
| 490 | + color: #dc2626; |
| 491 | + } |
| 492 | +
|
| 493 | + :global([data-mode="dark"]) .tree-link { |
379 | 494 | color: rgb(var(--color-primary-400)); |
380 | 495 | } |
381 | 496 |
|
| 497 | + :global([data-mode="dark"]) .tree-link.blocked { |
| 498 | + color: rgb(var(--color-error-400)); |
| 499 | + } |
| 500 | +
|
| 501 | + .tree-alert { |
| 502 | + padding-bottom: 0.25rem; |
| 503 | + } |
| 504 | +
|
382 | 505 | /* Roles */ |
383 | 506 | .roles-list { |
384 | 507 | display: flex; |
385 | 508 | flex-wrap: wrap; |
386 | 509 | gap: 0.375rem; |
| 510 | + margin-left: 0.75rem; |
| 511 | + font-family: system-ui, -apple-system, sans-serif; |
387 | 512 | } |
388 | 513 |
|
389 | 514 | .role-badge { |
|
392 | 517 | gap: 0.25rem; |
393 | 518 | padding: 0.125rem 0.5rem; |
394 | 519 | border-radius: 9999px; |
395 | | - font-size: 0.7rem; |
| 520 | + font-size: 0.65rem; |
396 | 521 | font-weight: 500; |
397 | 522 | } |
398 | 523 |
|
|
422 | 547 | } |
423 | 548 |
|
424 | 549 | .optional-label { |
425 | | - font-size: 0.6rem; |
| 550 | + font-size: 0.55rem; |
426 | 551 | opacity: 0.7; |
427 | 552 | font-style: italic; |
428 | 553 | } |
429 | 554 |
|
| 555 | + .scope-label { |
| 556 | + font-size: 0.55rem; |
| 557 | + opacity: 0.7; |
| 558 | + padding: 0 0.2rem; |
| 559 | + border: 1px solid currentColor; |
| 560 | + border-radius: 3px; |
| 561 | + } |
| 562 | +
|
430 | 563 | .empty-text { |
431 | 564 | padding: 2rem; |
432 | 565 | text-align: center; |
|
0 commit comments