@classic-homes/theme-svelte 0.1.56 → 1.0.1
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/__tests__/nav-access.test.js +145 -0
- package/dist/__tests__/sidebar-expansion.test.js +59 -0
- package/dist/lib/components/DataPanel.svelte +383 -114
- package/dist/lib/components/DataPanelHeader.svelte +4 -4
- package/dist/lib/components/DataPanelTab.svelte +212 -44
- package/dist/lib/components/DataPanelTab.svelte.d.ts +10 -1
- package/dist/lib/components/layout/DashboardLayout.svelte +1 -0
- package/dist/lib/components/layout/Sidebar.svelte +22 -17
- package/dist/lib/components/layout/Sidebar.svelte.d.ts +10 -1
- package/dist/lib/schemas/auth.d.ts +18 -8
- package/dist/lib/schemas/auth.js +4 -1
- package/dist/lib/schemas/index.d.ts +1 -1
- package/dist/lib/stores/dataPanel.svelte.d.ts +2 -0
- package/dist/lib/stores/dataPanel.svelte.js +14 -0
- package/dist/lib/stores/sidebar.svelte.d.ts +7 -2
- package/dist/lib/stores/sidebar.svelte.js +9 -3
- package/dist/lib/types/layout.d.ts +52 -4
- package/dist/lib/utils/nav-access.d.ts +61 -0
- package/dist/lib/utils/nav-access.js +74 -0
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (
|
|
|
17
17
|
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
18
18
|
};
|
|
19
19
|
var _SidebarStore_instances, _SidebarStore_initialized, _SidebarStore_loadedFromStorage, _SidebarStore_loadedExpansionFromStorage, _SidebarStore_loadedPinnedFromStorage, _SidebarStore_onPinnedChange, _SidebarStore_appId, _SidebarStore_getStorageKey, _SidebarStore_persist, _SidebarStore_persistExpansion, _SidebarStore_persistPinned;
|
|
20
|
+
import { matchesAccess } from '../utils/nav-access.js';
|
|
20
21
|
const STORAGE_PREFIX = 'classic-theme-sidebar';
|
|
21
22
|
class SidebarStore {
|
|
22
23
|
constructor() {
|
|
@@ -281,15 +282,20 @@ class SidebarStore {
|
|
|
281
282
|
// ============================================
|
|
282
283
|
/**
|
|
283
284
|
* Compute which sections should be initially expanded based on user roles.
|
|
284
|
-
*
|
|
285
|
-
*
|
|
285
|
+
*
|
|
286
|
+
* A section listing `expandForRoles` expands for a user holding ANY of them.
|
|
287
|
+
* Sections without it fall back to the default: expanded unless explicitly
|
|
288
|
+
* collapsed.
|
|
289
|
+
*
|
|
290
|
+
* This is a UX affordance, not access control — it decides what starts open,
|
|
291
|
+
* not what is visible. Filtering is `filterNavItems`.
|
|
286
292
|
*/
|
|
287
293
|
computeInitialExpansions(sections, userRoles) {
|
|
288
294
|
return sections
|
|
289
295
|
.filter((section) => {
|
|
290
296
|
// Check role-based expansion first
|
|
291
297
|
if (section.expandForRoles?.length) {
|
|
292
|
-
return section.expandForRoles
|
|
298
|
+
return matchesAccess(section.expandForRoles, userRoles);
|
|
293
299
|
}
|
|
294
300
|
// Fall back to default behavior: expand unless explicitly collapsed
|
|
295
301
|
return !section.collapsible || section.expanded !== false;
|
|
@@ -78,8 +78,19 @@ export interface NavItem {
|
|
|
78
78
|
icon?: string;
|
|
79
79
|
/** Optional badge (notification count, etc.) */
|
|
80
80
|
badge?: string | number;
|
|
81
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Roles that can see this item. The user needs ANY of them by default; set
|
|
83
|
+
* `requireAllRoles` on the Sidebar for ALL. Omit to show the item to everyone.
|
|
84
|
+
*/
|
|
82
85
|
roles?: string[];
|
|
86
|
+
/**
|
|
87
|
+
* Permissions that can see this item. The user needs ANY of them by default;
|
|
88
|
+
* set `requireAllPermissions` on the Sidebar for ALL.
|
|
89
|
+
*
|
|
90
|
+
* Combined with `roles` as AND: an item listing both is shown only to a user
|
|
91
|
+
* who satisfies each.
|
|
92
|
+
*/
|
|
93
|
+
permissions?: string[];
|
|
83
94
|
/** Opens in new tab */
|
|
84
95
|
external?: boolean;
|
|
85
96
|
/** Whether this item is currently active */
|
|
@@ -125,7 +136,27 @@ export interface SidebarPreferences {
|
|
|
125
136
|
expandedItems: string[];
|
|
126
137
|
}
|
|
127
138
|
/**
|
|
128
|
-
* User data for layout components
|
|
139
|
+
* User data for layout components.
|
|
140
|
+
*
|
|
141
|
+
* A display view-model, deliberately not an identity: the layout only needs
|
|
142
|
+
* enough to render an avatar and decide what navigation to show, so this stays
|
|
143
|
+
* independent of any auth package. Apps map their own user into it.
|
|
144
|
+
*
|
|
145
|
+
* With `@classic-homes/auth`, derive the display fields rather than reaching for
|
|
146
|
+
* a matching shape — its `User` has no `name` or `initials`:
|
|
147
|
+
*
|
|
148
|
+
* ```typescript
|
|
149
|
+
* import { getDisplayName, getUserInitials } from '@classic-homes/auth/core';
|
|
150
|
+
*
|
|
151
|
+
* const layoutUser = {
|
|
152
|
+
* id: user.id,
|
|
153
|
+
* name: getDisplayName(user),
|
|
154
|
+
* initials: getUserInitials(user),
|
|
155
|
+
* email: user.email,
|
|
156
|
+
* roles: user.roles,
|
|
157
|
+
* permissions: user.permissions,
|
|
158
|
+
* };
|
|
159
|
+
* ```
|
|
129
160
|
*/
|
|
130
161
|
export interface User {
|
|
131
162
|
/** Unique identifier */
|
|
@@ -136,8 +167,16 @@ export interface User {
|
|
|
136
167
|
email?: string;
|
|
137
168
|
/** Avatar URL */
|
|
138
169
|
avatar?: string;
|
|
139
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* Every role the user holds, used to filter navigation.
|
|
172
|
+
*
|
|
173
|
+
* A user may hold several; items are matched against the whole set, so a
|
|
174
|
+
* manager who is also an agent sees both. Not an access control boundary —
|
|
175
|
+
* this only decides what is rendered.
|
|
176
|
+
*/
|
|
140
177
|
roles?: string[];
|
|
178
|
+
/** Every permission the user holds, used to filter navigation. */
|
|
179
|
+
permissions?: string[];
|
|
141
180
|
/** User initials (fallback for avatar) */
|
|
142
181
|
initials?: string;
|
|
143
182
|
}
|
|
@@ -286,8 +325,17 @@ export interface PublicLayoutProps {
|
|
|
286
325
|
export interface SidebarProps {
|
|
287
326
|
/** Navigation sections */
|
|
288
327
|
navigation: NavSection[];
|
|
289
|
-
/**
|
|
328
|
+
/**
|
|
329
|
+
* Every role the current user holds. Items are matched against the whole set,
|
|
330
|
+
* so a user with several roles sees the union of what they allow.
|
|
331
|
+
*/
|
|
290
332
|
userRoles?: string[];
|
|
333
|
+
/** Every permission the current user holds, for permission-gated items. */
|
|
334
|
+
userPermissions?: string[];
|
|
335
|
+
/** Require ALL of an item's `roles` rather than any (default: any). */
|
|
336
|
+
requireAllRoles?: boolean;
|
|
337
|
+
/** Require ALL of an item's `permissions` rather than any (default: any). */
|
|
338
|
+
requireAllPermissions?: boolean;
|
|
291
339
|
/** Visual variant - light (default) or dark */
|
|
292
340
|
variant?: 'light' | 'dark';
|
|
293
341
|
/** Whether sidebar is collapsed */
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation Access Filtering
|
|
3
|
+
*
|
|
4
|
+
* Decides which navigation items a user should see, based on the roles and
|
|
5
|
+
* permissions they hold.
|
|
6
|
+
*
|
|
7
|
+
* A user holds a *set* of roles and a *set* of permissions, and an item is
|
|
8
|
+
* matched against the whole set — someone who is both a manager and an agent
|
|
9
|
+
* sees the union of what each allows.
|
|
10
|
+
*
|
|
11
|
+
* This decides what is **rendered**. It is not an access control boundary:
|
|
12
|
+
* the server enforces access, and hiding a link is a courtesy to the user, not
|
|
13
|
+
* a security measure.
|
|
14
|
+
*/
|
|
15
|
+
import type { NavItem } from '../types/layout.js';
|
|
16
|
+
/**
|
|
17
|
+
* Who is looking, and how strictly to match.
|
|
18
|
+
*/
|
|
19
|
+
export interface NavAccessContext {
|
|
20
|
+
/** Every role the user holds. */
|
|
21
|
+
userRoles?: string[];
|
|
22
|
+
/** Every permission the user holds. */
|
|
23
|
+
userPermissions?: string[];
|
|
24
|
+
/** Require ALL of an item's `roles` rather than any (default: any). */
|
|
25
|
+
requireAllRoles?: boolean;
|
|
26
|
+
/** Require ALL of an item's `permissions` rather than any (default: any). */
|
|
27
|
+
requireAllPermissions?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Whether `held` satisfies `required`.
|
|
31
|
+
*
|
|
32
|
+
* An empty or absent `required` is no restriction at all, so it matches. An
|
|
33
|
+
* empty `held` against a real restriction never matches — a user with no roles
|
|
34
|
+
* cannot satisfy a role requirement.
|
|
35
|
+
*/
|
|
36
|
+
export declare function matchesAccess(required: string[] | undefined, held: string[] | undefined, requireAll?: boolean): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Whether a single item should be shown.
|
|
39
|
+
*
|
|
40
|
+
* `roles` and `permissions` combine as AND: an item declaring both is shown
|
|
41
|
+
* only to a user who satisfies each.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isNavItemVisible(item: NavItem, context?: NavAccessContext): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Filter a navigation tree.
|
|
46
|
+
*
|
|
47
|
+
* Recurses into `children`: a restricted child under an unrestricted parent is
|
|
48
|
+
* still checked, rather than inheriting the parent's visibility.
|
|
49
|
+
*
|
|
50
|
+
* A parent left with no children is dropped, since it would render as an empty
|
|
51
|
+
* expandable — unless it has its own `href`, which makes it a destination in
|
|
52
|
+
* its own right.
|
|
53
|
+
*
|
|
54
|
+
* Does not mutate `items`.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```typescript
|
|
58
|
+
* filterNavItems(items, { userRoles: ['manager', 'agent'] });
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export declare function filterNavItems(items: NavItem[], context?: NavAccessContext): NavItem[];
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation Access Filtering
|
|
3
|
+
*
|
|
4
|
+
* Decides which navigation items a user should see, based on the roles and
|
|
5
|
+
* permissions they hold.
|
|
6
|
+
*
|
|
7
|
+
* A user holds a *set* of roles and a *set* of permissions, and an item is
|
|
8
|
+
* matched against the whole set — someone who is both a manager and an agent
|
|
9
|
+
* sees the union of what each allows.
|
|
10
|
+
*
|
|
11
|
+
* This decides what is **rendered**. It is not an access control boundary:
|
|
12
|
+
* the server enforces access, and hiding a link is a courtesy to the user, not
|
|
13
|
+
* a security measure.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Whether `held` satisfies `required`.
|
|
17
|
+
*
|
|
18
|
+
* An empty or absent `required` is no restriction at all, so it matches. An
|
|
19
|
+
* empty `held` against a real restriction never matches — a user with no roles
|
|
20
|
+
* cannot satisfy a role requirement.
|
|
21
|
+
*/
|
|
22
|
+
export function matchesAccess(required, held, requireAll = false) {
|
|
23
|
+
if (!required || required.length === 0)
|
|
24
|
+
return true;
|
|
25
|
+
if (!held || held.length === 0)
|
|
26
|
+
return false;
|
|
27
|
+
return requireAll
|
|
28
|
+
? required.every((value) => held.includes(value))
|
|
29
|
+
: required.some((value) => held.includes(value));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Whether a single item should be shown.
|
|
33
|
+
*
|
|
34
|
+
* `roles` and `permissions` combine as AND: an item declaring both is shown
|
|
35
|
+
* only to a user who satisfies each.
|
|
36
|
+
*/
|
|
37
|
+
export function isNavItemVisible(item, context = {}) {
|
|
38
|
+
return (matchesAccess(item.roles, context.userRoles, context.requireAllRoles) &&
|
|
39
|
+
matchesAccess(item.permissions, context.userPermissions, context.requireAllPermissions));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Filter a navigation tree.
|
|
43
|
+
*
|
|
44
|
+
* Recurses into `children`: a restricted child under an unrestricted parent is
|
|
45
|
+
* still checked, rather than inheriting the parent's visibility.
|
|
46
|
+
*
|
|
47
|
+
* A parent left with no children is dropped, since it would render as an empty
|
|
48
|
+
* expandable — unless it has its own `href`, which makes it a destination in
|
|
49
|
+
* its own right.
|
|
50
|
+
*
|
|
51
|
+
* Does not mutate `items`.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```typescript
|
|
55
|
+
* filterNavItems(items, { userRoles: ['manager', 'agent'] });
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export function filterNavItems(items, context = {}) {
|
|
59
|
+
const result = [];
|
|
60
|
+
for (const item of items) {
|
|
61
|
+
if (!isNavItemVisible(item, context))
|
|
62
|
+
continue;
|
|
63
|
+
if (!item.children) {
|
|
64
|
+
result.push(item);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const children = filterNavItems(item.children, context);
|
|
68
|
+
if (item.children.length > 0 && children.length === 0 && !item.href) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
result.push({ ...item, children });
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|