Skip to content

Commit a0c8a4f

Browse files
committed
Site Map tree structure
1 parent 2edfe84 commit a0c8a4f

File tree

3 files changed

+201
-58
lines changed

3 files changed

+201
-58
lines changed

src/routes/(protected)/banks/[bankId]/+page.svelte

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,16 @@
300300
</svg>
301301
<span class="text-sm font-medium text-gray-900 group-hover:text-blue-700 dark:text-gray-100 dark:group-hover:text-blue-300">System Views</span>
302302
</a>
303+
304+
<a
305+
href="/account-access/account-directory?bank_id={bank.bank_id}"
306+
class="group flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-2.5 transition-colors hover:border-blue-300 hover:bg-blue-50 dark:border-gray-700 dark:hover:border-blue-700 dark:hover:bg-blue-900/20"
307+
>
308+
<svg class="h-4 w-4 flex-shrink-0 text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
309+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
310+
</svg>
311+
<span class="text-sm font-medium text-gray-900 group-hover:text-blue-700 dark:text-gray-100 dark:group-hover:text-blue-300">Account Directory</span>
312+
</a>
303313
</div>
304314
</div>
305315

src/routes/(protected)/user/site-map/+page.svelte

Lines changed: 190 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
<script lang="ts">
22
import { SITE_MAP, checkRoles } from "$lib/utils/roleChecker";
33
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";
55
import MissingRoleAlert from "$lib/components/MissingRoleAlert.svelte";
6+
import { currentBank } from "$lib/stores/currentBank.svelte";
67
78
const { data } = $props();
89
@@ -31,6 +32,7 @@
3132
function hasRole(role: RoleRequirement): boolean {
3233
return userEntitlements.some((e) => {
3334
if (e.role_name !== role.role) return false;
35+
if (role.bankScoped) return currentBank.bankId ? e.bank_id === currentBank.bankId : false;
3436
if (role.bankId) return e.bank_id === role.bankId;
3537
return true;
3638
});
@@ -43,9 +45,17 @@
4345
accessible: boolean;
4446
}
4547
48+
// Tree node structure
49+
interface TreeNode {
50+
segment: string;
51+
fullPath: string;
52+
entry: PageEntry | null;
53+
children: TreeNode[];
54+
}
55+
4656
let allPages: PageEntry[] = $derived(
4757
Object.entries(SITE_MAP).map(([route, config]) => {
48-
const result = checkRoles(userEntitlements, config.required);
58+
const result = checkRoles(userEntitlements, config.required, currentBank.bankId);
4959
return {
5060
route,
5161
required: config.required,
@@ -67,6 +77,53 @@
6777
})
6878
);
6979
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+
70127
let groupedPages = $derived.by(() => {
71128
const groups: Record<string, PageEntry[]> = {};
72129
for (const p of filteredPages) {
@@ -77,7 +134,15 @@
77134
return groups;
78135
});
79136
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+
81146
let orderedSections = $derived(
82147
[...SECTION_ORDER.map((s) => s.key), "other"].filter((k) => groupedPages[k]?.length)
83148
);
@@ -87,7 +152,6 @@
87152
return found ? found.label : "Other";
88153
}
89154
90-
// Collect missing role names for a page entry (both required and optional)
91155
function getMissingRoles(entry: PageEntry): string[] {
92156
const missing: string[] = [];
93157
for (const r of entry.required) {
@@ -131,47 +195,69 @@
131195
/>
132196
</div>
133197

134-
<!-- Grouped pages -->
198+
<!-- Grouped pages as trees -->
135199
{#each orderedSections as sectionKey}
136200
<div class="section">
137201
<div class="section-header">
138202
<h3 class="section-title">{sectionLabel(sectionKey)}</h3>
139203
<span class="section-count">{groupedPages[sectionKey].length}</span>
140204
</div>
141205

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}
170247
</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>
173252
{/if}
174253
</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)}
175261
{/each}
176262
</div>
177263
</div>
@@ -334,56 +420,95 @@
334420
color: var(--color-surface-300);
335421
}
336422
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;
341427
}
342428
343-
.page-entry {
344-
padding: 0.625rem 1rem;
345-
border-bottom: 1px solid #f3f4f6;
429+
.tree-item {
346430
display: flex;
347431
flex-direction: column;
348-
gap: 0.375rem;
349432
}
350433
351-
.page-entry:last-child {
352-
border-bottom: none;
434+
.tree-item.blocked {
435+
background: #fef2f2;
436+
border-radius: 4px;
353437
}
354438
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);
357441
}
358442
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;
361449
}
362450
363-
:global([data-mode="dark"]) .page-entry.blocked {
364-
background: rgba(220, 38, 38, 0.05);
451+
.tree-indent {
452+
flex-shrink: 0;
365453
}
366454
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 {
368468
font-size: 0.8rem;
369469
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 {
371481
text-decoration: none;
482+
color: #2563eb;
372483
}
373484
374-
.route-link:hover {
485+
.tree-link:hover {
375486
text-decoration: underline;
376487
}
377488
378-
:global([data-mode="dark"]) .route-link {
489+
.tree-link.blocked {
490+
color: #dc2626;
491+
}
492+
493+
:global([data-mode="dark"]) .tree-link {
379494
color: rgb(var(--color-primary-400));
380495
}
381496
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+
382505
/* Roles */
383506
.roles-list {
384507
display: flex;
385508
flex-wrap: wrap;
386509
gap: 0.375rem;
510+
margin-left: 0.75rem;
511+
font-family: system-ui, -apple-system, sans-serif;
387512
}
388513
389514
.role-badge {
@@ -392,7 +517,7 @@
392517
gap: 0.25rem;
393518
padding: 0.125rem 0.5rem;
394519
border-radius: 9999px;
395-
font-size: 0.7rem;
520+
font-size: 0.65rem;
396521
font-weight: 500;
397522
}
398523
@@ -422,11 +547,19 @@
422547
}
423548
424549
.optional-label {
425-
font-size: 0.6rem;
550+
font-size: 0.55rem;
426551
opacity: 0.7;
427552
font-style: italic;
428553
}
429554
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+
430563
.empty-text {
431564
padding: 2rem;
432565
text-align: center;

src/routes/+layout.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,7 +554,7 @@
554554
<option value="">-- Select a bank --</option>
555555
{#each currentBank.banks as bank}
556556
<option value={bank.bank_id}>
557-
{bank.short_name} — {bank.full_name}
557+
{bank.bank_id} — {bank.bank_code} — {bank.full_name}
558558
</option>
559559
{/each}
560560
</select>

0 commit comments

Comments
 (0)