@classic-homes/theme-svelte 0.1.56 → 1.0.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.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Navigation Access Filtering Tests
3
+ *
4
+ * Covers which navigation items a user sees given the roles and permissions
5
+ * they hold — including the nested case, where a restricted child sits under
6
+ * an unrestricted parent.
7
+ */
8
+ import { describe, it, expect } from 'vitest';
9
+ import { filterNavItems, isNavItemVisible, matchesAccess } from '../lib/utils/nav-access.js';
10
+ function item(partial) {
11
+ return { name: partial.id, ...partial };
12
+ }
13
+ const ids = (items) => items.map((i) => i.id);
14
+ describe('matchesAccess', () => {
15
+ it('matches when nothing is required', () => {
16
+ expect(matchesAccess(undefined, [])).toBe(true);
17
+ expect(matchesAccess([], [])).toBe(true);
18
+ });
19
+ it('does not match a requirement when the user holds nothing', () => {
20
+ expect(matchesAccess(['admin'], undefined)).toBe(false);
21
+ expect(matchesAccess(['admin'], [])).toBe(false);
22
+ });
23
+ it('matches any by default', () => {
24
+ expect(matchesAccess(['admin', 'manager'], ['manager'])).toBe(true);
25
+ expect(matchesAccess(['admin', 'manager'], ['agent'])).toBe(false);
26
+ });
27
+ it('can require all', () => {
28
+ expect(matchesAccess(['admin', 'manager'], ['admin'], true)).toBe(false);
29
+ expect(matchesAccess(['admin', 'manager'], ['admin', 'manager'], true)).toBe(true);
30
+ });
31
+ });
32
+ describe('isNavItemVisible', () => {
33
+ it('shows unrestricted items to anyone', () => {
34
+ expect(isNavItemVisible(item({ id: 'a' }), {})).toBe(true);
35
+ });
36
+ it('matches against every role held, not just one', () => {
37
+ const nav = item({ id: 'a', roles: ['agent'] });
38
+ expect(isNavItemVisible(nav, { userRoles: ['manager', 'agent'] })).toBe(true);
39
+ });
40
+ it('does not imply roles from one another', () => {
41
+ // Mirrors the auth package: holding admin does not imply agent, so the UI
42
+ // cannot offer something the server would reject.
43
+ const nav = item({ id: 'a', roles: ['agent'] });
44
+ expect(isNavItemVisible(nav, { userRoles: ['admin'] })).toBe(false);
45
+ });
46
+ it('combines roles and permissions as AND', () => {
47
+ const nav = item({ id: 'a', roles: ['manager'], permissions: ['users:write'] });
48
+ expect(isNavItemVisible(nav, { userRoles: ['manager'], userPermissions: ['users:write'] })).toBe(true);
49
+ expect(isNavItemVisible(nav, { userRoles: ['manager'], userPermissions: ['users:read'] })).toBe(false);
50
+ expect(isNavItemVisible(nav, { userRoles: ['agent'], userPermissions: ['users:write'] })).toBe(false);
51
+ });
52
+ it('gates on permissions alone', () => {
53
+ const nav = item({ id: 'a', permissions: ['settings:read'] });
54
+ expect(isNavItemVisible(nav, { userPermissions: ['settings:read'] })).toBe(true);
55
+ expect(isNavItemVisible(nav, { userRoles: ['admin'] })).toBe(false);
56
+ });
57
+ });
58
+ describe('filterNavItems', () => {
59
+ it('shows the union across a multi-role user', () => {
60
+ const items = [
61
+ item({ id: 'dashboard' }),
62
+ item({ id: 'users', roles: ['manager'] }),
63
+ item({ id: 'homes', roles: ['agent'] }),
64
+ item({ id: 'system', roles: ['admin'] }),
65
+ ];
66
+ expect(ids(filterNavItems(items, { userRoles: ['manager', 'agent'] }))).toEqual([
67
+ 'dashboard',
68
+ 'users',
69
+ 'homes',
70
+ ]);
71
+ });
72
+ it('filters a restricted child under an unrestricted parent', () => {
73
+ // The parent passes on its own merits; without recursion nothing would ever
74
+ // look inside it and the admin-only child would show to everyone.
75
+ const items = [
76
+ item({
77
+ id: 'main',
78
+ children: [item({ id: 'open' }), item({ id: 'audit', roles: ['admin'] })],
79
+ }),
80
+ ];
81
+ const filtered = filterNavItems(items, { userRoles: ['agent'] });
82
+ expect(ids(filtered[0].children)).toEqual(['open']);
83
+ });
84
+ it('filters arbitrarily deep', () => {
85
+ const items = [
86
+ item({
87
+ id: 'l1',
88
+ children: [
89
+ item({
90
+ id: 'l2',
91
+ children: [item({ id: 'l3', roles: ['admin'] }), item({ id: 'l3-open' })],
92
+ }),
93
+ ],
94
+ }),
95
+ ];
96
+ const filtered = filterNavItems(items, { userRoles: ['agent'] });
97
+ expect(ids(filtered[0].children[0].children)).toEqual(['l3-open']);
98
+ });
99
+ it('drops a container whose children are all filtered away', () => {
100
+ const items = [
101
+ item({ id: 'admin-area', children: [item({ id: 'system', roles: ['admin'] })] }),
102
+ ];
103
+ // Keeping it would render an expandable with nothing behind it.
104
+ expect(ids(filterNavItems(items, { userRoles: ['agent'] }))).toEqual([]);
105
+ });
106
+ it('keeps an emptied parent that is a destination in its own right', () => {
107
+ const items = [
108
+ item({
109
+ id: 'reports',
110
+ href: '/reports',
111
+ children: [item({ id: 'revenue', roles: ['admin'] })],
112
+ }),
113
+ ];
114
+ const filtered = filterNavItems(items, { userRoles: ['agent'] });
115
+ expect(ids(filtered)).toEqual(['reports']);
116
+ expect(filtered[0].children).toEqual([]);
117
+ });
118
+ it('hides everything restricted from a user with no roles', () => {
119
+ const items = [item({ id: 'dashboard' }), item({ id: 'users', roles: ['manager'] })];
120
+ expect(ids(filterNavItems(items, {}))).toEqual(['dashboard']);
121
+ });
122
+ it('honors requireAllRoles', () => {
123
+ const items = [item({ id: 'both', roles: ['admin', 'manager'] })];
124
+ expect(ids(filterNavItems(items, { userRoles: ['admin'], requireAllRoles: true }))).toEqual([]);
125
+ expect(ids(filterNavItems(items, { userRoles: ['admin', 'manager'], requireAllRoles: true }))).toEqual(['both']);
126
+ });
127
+ it('honors requireAllPermissions', () => {
128
+ const items = [item({ id: 'both', permissions: ['a:read', 'b:read'] })];
129
+ expect(ids(filterNavItems(items, { userPermissions: ['a:read'], requireAllPermissions: true }))).toEqual([]);
130
+ expect(ids(filterNavItems(items, {
131
+ userPermissions: ['a:read', 'b:read'],
132
+ requireAllPermissions: true,
133
+ }))).toEqual(['both']);
134
+ });
135
+ it('leaves childless items without a children key', () => {
136
+ const filtered = filterNavItems([item({ id: 'a' })], {});
137
+ expect('children' in filtered[0]).toBe(false);
138
+ });
139
+ it('does not mutate the input', () => {
140
+ const items = [item({ id: 'main', children: [item({ id: 'audit', roles: ['admin'] })] })];
141
+ const before = JSON.stringify(items);
142
+ filterNavItems(items, { userRoles: ['agent'] });
143
+ expect(JSON.stringify(items)).toBe(before);
144
+ });
145
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Sidebar Role-based Expansion Tests
3
+ *
4
+ * Covers which sections start expanded for a given user. This is a UX
5
+ * affordance, not access control — it decides what starts open, not what is
6
+ * visible (that's nav-access).
7
+ */
8
+ import { describe, it, expect } from 'vitest';
9
+ import { sidebarStore } from '../lib/stores/sidebar.svelte.js';
10
+ function section(partial) {
11
+ return { title: partial.id, items: [], ...partial };
12
+ }
13
+ describe('computeInitialExpansions', () => {
14
+ it('expands a section for any matching role', () => {
15
+ const sections = [section({ id: 'admin', expandForRoles: ['admin', 'superuser'] })];
16
+ expect(sidebarStore.computeInitialExpansions(sections, ['admin'])).toEqual(['admin']);
17
+ expect(sidebarStore.computeInitialExpansions(sections, ['superuser'])).toEqual(['admin']);
18
+ });
19
+ it('expands on a secondary role, not just the first held', () => {
20
+ const sections = [section({ id: 'ops', expandForRoles: ['agent'] })];
21
+ expect(sidebarStore.computeInitialExpansions(sections, ['manager', 'agent'])).toEqual(['ops']);
22
+ });
23
+ it('leaves a section collapsed when no role matches', () => {
24
+ const sections = [section({ id: 'admin', expandForRoles: ['admin'] })];
25
+ expect(sidebarStore.computeInitialExpansions(sections, ['agent'])).toEqual([]);
26
+ });
27
+ it('leaves a role-gated section collapsed for a user with no roles', () => {
28
+ const sections = [section({ id: 'admin', expandForRoles: ['admin'] })];
29
+ expect(sidebarStore.computeInitialExpansions(sections, [])).toEqual([]);
30
+ expect(sidebarStore.computeInitialExpansions(sections, undefined)).toEqual([]);
31
+ });
32
+ it('expands non-collapsible sections regardless of roles', () => {
33
+ const sections = [section({ id: 'main', collapsible: false })];
34
+ expect(sidebarStore.computeInitialExpansions(sections, [])).toEqual(['main']);
35
+ });
36
+ it('respects an explicitly collapsed section', () => {
37
+ const sections = [section({ id: 'main', collapsible: true, expanded: false })];
38
+ expect(sidebarStore.computeInitialExpansions(sections, ['admin'])).toEqual([]);
39
+ });
40
+ it('expands a collapsible section that is not explicitly collapsed', () => {
41
+ const sections = [section({ id: 'main', collapsible: true })];
42
+ expect(sidebarStore.computeInitialExpansions(sections, [])).toEqual(['main']);
43
+ });
44
+ it('prefers expandForRoles over the collapsed default', () => {
45
+ // expandForRoles is checked first, so it wins over `expanded: false`.
46
+ const sections = [
47
+ section({ id: 'admin', collapsible: true, expanded: false, expandForRoles: ['admin'] }),
48
+ ];
49
+ expect(sidebarStore.computeInitialExpansions(sections, ['admin'])).toEqual(['admin']);
50
+ });
51
+ it('handles a mixed navigation', () => {
52
+ const sections = [
53
+ section({ id: 'main', collapsible: false }),
54
+ section({ id: 'admin', expandForRoles: ['admin'] }),
55
+ section({ id: 'ops', expandForRoles: ['agent'] }),
56
+ ];
57
+ expect(sidebarStore.computeInitialExpansions(sections, ['agent'])).toEqual(['main', 'ops']);
58
+ });
59
+ });
@@ -229,6 +229,7 @@
229
229
  <Sidebar
230
230
  navigation={effectiveNavigation}
231
231
  userRoles={user?.roles}
232
+ userPermissions={user?.permissions}
232
233
  variant={sidebarVariant}
233
234
  collapsed={!sidebarStore.isOpen && !isMobile}
234
235
  {isMobile}
@@ -15,6 +15,7 @@
15
15
  import type { Snippet } from 'svelte';
16
16
  import type { NavSection, NavItem, QuickLink } from '../../types/layout.js';
17
17
  import { cn } from '../../utils.js';
18
+ import { filterNavItems } from '../../utils/nav-access.js';
18
19
  import { sidebarStore } from '../../stores/sidebar.svelte.js';
19
20
  import LogoMain from '../LogoMain.svelte';
20
21
  import QuickLinks from './QuickLinks.svelte';
@@ -25,8 +26,17 @@
25
26
  interface Props {
26
27
  /** Navigation sections */
27
28
  navigation: NavSection[];
28
- /** User roles for filtering navigation items */
29
+ /**
30
+ * Every role the current user holds. Items are matched against the whole
31
+ * set, so a user with several roles sees the union of what they allow.
32
+ */
29
33
  userRoles?: string[];
34
+ /** Every permission the current user holds, for permission-gated items. */
35
+ userPermissions?: string[];
36
+ /** Require ALL of an item's `roles` rather than any (default: any). */
37
+ requireAllRoles?: boolean;
38
+ /** Require ALL of an item's `permissions` rather than any (default: any). */
39
+ requireAllPermissions?: boolean;
30
40
  /** Whether sidebar is collapsed (desktop) */
31
41
  collapsed?: boolean;
32
42
  /** Whether currently on mobile */
@@ -80,6 +90,9 @@
80
90
  let {
81
91
  navigation,
82
92
  userRoles,
93
+ userPermissions,
94
+ requireAllRoles = false,
95
+ requireAllPermissions = false,
83
96
  collapsed = false,
84
97
  isMobile = false,
85
98
  mobileOpen = false,
@@ -106,27 +119,19 @@
106
119
  class: className,
107
120
  }: Props = $props();
108
121
 
109
- // Helper to check if item is visible based on roles
110
- function isItemVisible(item: NavItem): boolean {
111
- if (!item.roles || item.roles.length === 0) return true;
112
- if (!userRoles || userRoles.length === 0) return false;
113
- return item.roles.some((role) => userRoles.includes(role));
114
- }
115
-
116
- // Recursively filter items based on roles
117
- function filterItems(items: NavItem[]): NavItem[] {
118
- return items.filter(isItemVisible).map((item) => ({
119
- ...item,
120
- children: item.children ? filterItems(item.children) : undefined,
121
- }));
122
- }
122
+ const accessContext = $derived({
123
+ userRoles,
124
+ userPermissions,
125
+ requireAllRoles,
126
+ requireAllPermissions,
127
+ });
123
128
 
124
- // Filter navigation based on user roles
129
+ // Filter navigation by what the user holds, dropping sections left empty.
125
130
  const roleFilteredNavigation = $derived(
126
131
  navigation
127
132
  .map((section) => ({
128
133
  ...section,
129
- items: filterItems(section.items),
134
+ items: filterNavItems(section.items, accessContext),
130
135
  }))
131
136
  .filter((section) => section.items.length > 0)
132
137
  );
@@ -16,8 +16,17 @@ import type { NavSection, NavItem, QuickLink } from '../../types/layout.js';
16
16
  interface Props {
17
17
  /** Navigation sections */
18
18
  navigation: NavSection[];
19
- /** User roles for filtering navigation items */
19
+ /**
20
+ * Every role the current user holds. Items are matched against the whole
21
+ * set, so a user with several roles sees the union of what they allow.
22
+ */
20
23
  userRoles?: string[];
24
+ /** Every permission the current user holds, for permission-gated items. */
25
+ userPermissions?: string[];
26
+ /** Require ALL of an item's `roles` rather than any (default: any). */
27
+ requireAllRoles?: boolean;
28
+ /** Require ALL of an item's `permissions` rather than any (default: any). */
29
+ requireAllPermissions?: boolean;
21
30
  /** Whether sidebar is collapsed (desktop) */
22
31
  collapsed?: boolean;
23
32
  /** Whether currently on mobile */
@@ -243,7 +243,10 @@ export declare const emailChangeSchema: z.ZodObject<{
243
243
  }>;
244
244
  export type EmailChangeFormData = z.infer<typeof emailChangeSchema>;
245
245
  /**
246
- * User schema
246
+ * User schema — for validating a user payload from an API.
247
+ *
248
+ * Distinct from the layout `User` in `types/layout.ts`, which is a display
249
+ * view-model for the sidebar and avatar. This one describes data on the wire.
247
250
  */
248
251
  export declare const userSchema: z.ZodObject<{
249
252
  id: z.ZodString;
@@ -262,9 +265,9 @@ export declare const userSchema: z.ZodObject<{
262
265
  email: string;
263
266
  username?: string | undefined;
264
267
  roles?: string[] | undefined;
268
+ permissions?: string[] | undefined;
265
269
  fullName?: string | undefined;
266
270
  avatarUrl?: string | null | undefined;
267
- permissions?: string[] | undefined;
268
271
  mfaEnabled?: boolean | undefined;
269
272
  emailVerified?: boolean | undefined;
270
273
  createdAt?: string | undefined;
@@ -274,15 +277,22 @@ export declare const userSchema: z.ZodObject<{
274
277
  email: string;
275
278
  username?: string | undefined;
276
279
  roles?: string[] | undefined;
280
+ permissions?: string[] | undefined;
277
281
  fullName?: string | undefined;
278
282
  avatarUrl?: string | null | undefined;
279
- permissions?: string[] | undefined;
280
283
  mfaEnabled?: boolean | undefined;
281
284
  emailVerified?: boolean | undefined;
282
285
  createdAt?: string | undefined;
283
286
  updatedAt?: string | undefined;
284
287
  }>;
285
- export type User = z.infer<typeof userSchema>;
288
+ /**
289
+ * A user payload as validated by {@link userSchema}.
290
+ *
291
+ * Named `AuthUser` to keep the package's `User` unambiguous — that name belongs
292
+ * to the layout view-model. Previously exported as `User`, where it collided
293
+ * with the layout type and lost, making it unreachable from the package root.
294
+ */
295
+ export type AuthUser = z.infer<typeof userSchema>;
286
296
  /**
287
297
  * Auth tokens schema
288
298
  */
@@ -324,9 +334,9 @@ export declare const loginResponseSchema: z.ZodObject<{
324
334
  email: string;
325
335
  username?: string | undefined;
326
336
  roles?: string[] | undefined;
337
+ permissions?: string[] | undefined;
327
338
  fullName?: string | undefined;
328
339
  avatarUrl?: string | null | undefined;
329
- permissions?: string[] | undefined;
330
340
  mfaEnabled?: boolean | undefined;
331
341
  emailVerified?: boolean | undefined;
332
342
  createdAt?: string | undefined;
@@ -336,9 +346,9 @@ export declare const loginResponseSchema: z.ZodObject<{
336
346
  email: string;
337
347
  username?: string | undefined;
338
348
  roles?: string[] | undefined;
349
+ permissions?: string[] | undefined;
339
350
  fullName?: string | undefined;
340
351
  avatarUrl?: string | null | undefined;
341
- permissions?: string[] | undefined;
342
352
  mfaEnabled?: boolean | undefined;
343
353
  emailVerified?: boolean | undefined;
344
354
  createdAt?: string | undefined;
@@ -368,9 +378,9 @@ export declare const loginResponseSchema: z.ZodObject<{
368
378
  email: string;
369
379
  username?: string | undefined;
370
380
  roles?: string[] | undefined;
381
+ permissions?: string[] | undefined;
371
382
  fullName?: string | undefined;
372
383
  avatarUrl?: string | null | undefined;
373
- permissions?: string[] | undefined;
374
384
  mfaEnabled?: boolean | undefined;
375
385
  emailVerified?: boolean | undefined;
376
386
  createdAt?: string | undefined;
@@ -390,9 +400,9 @@ export declare const loginResponseSchema: z.ZodObject<{
390
400
  email: string;
391
401
  username?: string | undefined;
392
402
  roles?: string[] | undefined;
403
+ permissions?: string[] | undefined;
393
404
  fullName?: string | undefined;
394
405
  avatarUrl?: string | null | undefined;
395
- permissions?: string[] | undefined;
396
406
  mfaEnabled?: boolean | undefined;
397
407
  emailVerified?: boolean | undefined;
398
408
  createdAt?: string | undefined;
@@ -181,7 +181,10 @@ export const emailChangeSchema = z.object({
181
181
  // API Response Schemas (for validating API responses)
182
182
  // ============================================================================
183
183
  /**
184
- * User schema
184
+ * User schema — for validating a user payload from an API.
185
+ *
186
+ * Distinct from the layout `User` in `types/layout.ts`, which is a display
187
+ * view-model for the sidebar and avatar. This one describes data on the wire.
185
188
  */
186
189
  export const userSchema = z.object({
187
190
  id: z.string(),
@@ -9,7 +9,7 @@ export { emailSchema, passwordSchema, loginPasswordSchema, usernameSchema, fullN
9
9
  export { loginSchema, registerSchema, forgotPasswordSchema, resetPasswordSchema, changePasswordSchema, mfaVerifySchema, mfaSetupSchema, profileUpdateSchema, emailChangeSchema, } from './auth.js';
10
10
  export type { LoginFormData, RegisterFormData, ForgotPasswordFormData, ResetPasswordFormData, ChangePasswordFormData, MfaVerifyFormData, MfaSetupFormData, ProfileUpdateFormData, EmailChangeFormData, } from './auth.js';
11
11
  export { userSchema, authTokensSchema, loginResponseSchema } from './auth.js';
12
- export type { User, AuthTokens, LoginResponse } from './auth.js';
12
+ export type { AuthUser, AuthTokens, LoginResponse } from './auth.js';
13
13
  export { nonEmptyString, trimmedString, optionalString, urlSchema, optionalUrlSchema, slugSchema, } from './common.js';
14
14
  export { positiveInt, nonNegativeInt, percentageSchema, priceSchema, numericString, } from './common.js';
15
15
  export { isoDateSchema, dateStringSchema, timeStringSchema, futureDateSchema, pastDateSchema, } from './common.js';
@@ -122,8 +122,13 @@ declare class SidebarStore {
122
122
  getPinnedIds(): string[];
123
123
  /**
124
124
  * Compute which sections should be initially expanded based on user roles.
125
- * Sections with expandForRoles that match user roles will be auto-expanded.
126
- * Falls back to section.expanded or default behavior.
125
+ *
126
+ * A section listing `expandForRoles` expands for a user holding ANY of them.
127
+ * Sections without it fall back to the default: expanded unless explicitly
128
+ * collapsed.
129
+ *
130
+ * This is a UX affordance, not access control — it decides what starts open,
131
+ * not what is visible. Filtering is `filterNavItems`.
127
132
  */
128
133
  computeInitialExpansions(sections: NavSection[], userRoles?: string[]): string[];
129
134
  /**
@@ -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
- * Sections with expandForRoles that match user roles will be auto-expanded.
285
- * Falls back to section.expanded or default behavior.
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.some((role) => userRoles?.includes(role));
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
- /** Role-based visibility */
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
- /** User roles for authorization */
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
- /** User roles for filtering navigation items */
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "0.1.56",
3
+ "version": "1.0.0",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",