@happyvertical/smrt-users 0.30.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.
Files changed (150) hide show
  1. package/AGENTS.md +85 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +7 -0
  4. package/README.md +459 -0
  5. package/dist/__smrt-register__.d.ts +2 -0
  6. package/dist/__smrt-register__.d.ts.map +1 -0
  7. package/dist/chunks/TerminalAuthService-DoAMQ_yn.js +5118 -0
  8. package/dist/chunks/TerminalAuthService-DoAMQ_yn.js.map +1 -0
  9. package/dist/chunks/index-DkoYIvIu.js +169 -0
  10. package/dist/chunks/index-DkoYIvIu.js.map +1 -0
  11. package/dist/collections/CliAuthRequestCollection.d.ts +19 -0
  12. package/dist/collections/CliAuthRequestCollection.d.ts.map +1 -0
  13. package/dist/collections/GroupCollection.d.ts +17 -0
  14. package/dist/collections/GroupCollection.d.ts.map +1 -0
  15. package/dist/collections/GroupMemberCollection.d.ts +43 -0
  16. package/dist/collections/GroupMemberCollection.d.ts.map +1 -0
  17. package/dist/collections/GroupRoleCollection.d.ts +33 -0
  18. package/dist/collections/GroupRoleCollection.d.ts.map +1 -0
  19. package/dist/collections/MagicLinkTokenCollection.d.ts +26 -0
  20. package/dist/collections/MagicLinkTokenCollection.d.ts.map +1 -0
  21. package/dist/collections/MembershipCollection.d.ts +38 -0
  22. package/dist/collections/MembershipCollection.d.ts.map +1 -0
  23. package/dist/collections/MembershipOverrideCollection.d.ts +55 -0
  24. package/dist/collections/MembershipOverrideCollection.d.ts.map +1 -0
  25. package/dist/collections/PermissionCollection.d.ts +34 -0
  26. package/dist/collections/PermissionCollection.d.ts.map +1 -0
  27. package/dist/collections/RoleCollection.d.ts +29 -0
  28. package/dist/collections/RoleCollection.d.ts.map +1 -0
  29. package/dist/collections/RolePermissionCollection.d.ts +33 -0
  30. package/dist/collections/RolePermissionCollection.d.ts.map +1 -0
  31. package/dist/collections/SessionCollection.d.ts +82 -0
  32. package/dist/collections/SessionCollection.d.ts.map +1 -0
  33. package/dist/collections/TenantCollection.d.ts +119 -0
  34. package/dist/collections/TenantCollection.d.ts.map +1 -0
  35. package/dist/collections/TenantPermissionOverrideCollection.d.ts +111 -0
  36. package/dist/collections/TenantPermissionOverrideCollection.d.ts.map +1 -0
  37. package/dist/collections/UserCollection.d.ts +116 -0
  38. package/dist/collections/UserCollection.d.ts.map +1 -0
  39. package/dist/collections/index.d.ts +19 -0
  40. package/dist/collections/index.d.ts.map +1 -0
  41. package/dist/index.d.ts +5 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +1482 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/manifest.json +5216 -0
  46. package/dist/models/CliAuthRequest.d.ts +25 -0
  47. package/dist/models/CliAuthRequest.d.ts.map +1 -0
  48. package/dist/models/Group.d.ts +34 -0
  49. package/dist/models/Group.d.ts.map +1 -0
  50. package/dist/models/GroupMember.d.ts +29 -0
  51. package/dist/models/GroupMember.d.ts.map +1 -0
  52. package/dist/models/GroupRole.d.ts +29 -0
  53. package/dist/models/GroupRole.d.ts.map +1 -0
  54. package/dist/models/MagicLinkToken.d.ts +22 -0
  55. package/dist/models/MagicLinkToken.d.ts.map +1 -0
  56. package/dist/models/Membership.d.ts +48 -0
  57. package/dist/models/Membership.d.ts.map +1 -0
  58. package/dist/models/MembershipOverride.d.ts +50 -0
  59. package/dist/models/MembershipOverride.d.ts.map +1 -0
  60. package/dist/models/Permission.d.ts +79 -0
  61. package/dist/models/Permission.d.ts.map +1 -0
  62. package/dist/models/Role.d.ts +67 -0
  63. package/dist/models/Role.d.ts.map +1 -0
  64. package/dist/models/RolePermission.d.ts +29 -0
  65. package/dist/models/RolePermission.d.ts.map +1 -0
  66. package/dist/models/Session.d.ts +105 -0
  67. package/dist/models/Session.d.ts.map +1 -0
  68. package/dist/models/Tenant.d.ts +138 -0
  69. package/dist/models/Tenant.d.ts.map +1 -0
  70. package/dist/models/TenantPermissionOverride.d.ts +74 -0
  71. package/dist/models/TenantPermissionOverride.d.ts.map +1 -0
  72. package/dist/models/User.d.ts +72 -0
  73. package/dist/models/User.d.ts.map +1 -0
  74. package/dist/models/index.d.ts +19 -0
  75. package/dist/models/index.d.ts.map +1 -0
  76. package/dist/playground.d.ts +2 -0
  77. package/dist/playground.d.ts.map +1 -0
  78. package/dist/playground.js +139 -0
  79. package/dist/playground.js.map +1 -0
  80. package/dist/services/MagicLinkService.d.ts +84 -0
  81. package/dist/services/MagicLinkService.d.ts.map +1 -0
  82. package/dist/services/OidcLoginService.d.ts +134 -0
  83. package/dist/services/OidcLoginService.d.ts.map +1 -0
  84. package/dist/services/PermissionCatalogService.d.ts +62 -0
  85. package/dist/services/PermissionCatalogService.d.ts.map +1 -0
  86. package/dist/services/PermissionResolver.d.ts +150 -0
  87. package/dist/services/PermissionResolver.d.ts.map +1 -0
  88. package/dist/services/PostgresPermissionPolicies.d.ts +29 -0
  89. package/dist/services/PostgresPermissionPolicies.d.ts.map +1 -0
  90. package/dist/services/SessionPermissionContext.d.ts +43 -0
  91. package/dist/services/SessionPermissionContext.d.ts.map +1 -0
  92. package/dist/services/SessionService.d.ts +139 -0
  93. package/dist/services/SessionService.d.ts.map +1 -0
  94. package/dist/services/TenantService.d.ts +135 -0
  95. package/dist/services/TenantService.d.ts.map +1 -0
  96. package/dist/services/TerminalAuthService.d.ts +189 -0
  97. package/dist/services/TerminalAuthService.d.ts.map +1 -0
  98. package/dist/services/index.d.ts +14 -0
  99. package/dist/services/index.d.ts.map +1 -0
  100. package/dist/smrt-knowledge.json +2744 -0
  101. package/dist/svelte/components/InviteUserModal.svelte +351 -0
  102. package/dist/svelte/components/InviteUserModal.svelte.d.ts +17 -0
  103. package/dist/svelte/components/InviteUserModal.svelte.d.ts.map +1 -0
  104. package/dist/svelte/components/UserAvatar.svelte +105 -0
  105. package/dist/svelte/components/UserAvatar.svelte.d.ts +10 -0
  106. package/dist/svelte/components/UserAvatar.svelte.d.ts.map +1 -0
  107. package/dist/svelte/components/UserCard.svelte +179 -0
  108. package/dist/svelte/components/UserCard.svelte.d.ts +18 -0
  109. package/dist/svelte/components/UserCard.svelte.d.ts.map +1 -0
  110. package/dist/svelte/components/UserForm.svelte +194 -0
  111. package/dist/svelte/components/UserForm.svelte.d.ts +18 -0
  112. package/dist/svelte/components/UserForm.svelte.d.ts.map +1 -0
  113. package/dist/svelte/components/UserList.svelte +107 -0
  114. package/dist/svelte/components/UserList.svelte.d.ts +20 -0
  115. package/dist/svelte/components/UserList.svelte.d.ts.map +1 -0
  116. package/dist/svelte/components/UserMenu.svelte +326 -0
  117. package/dist/svelte/components/UserMenu.svelte.d.ts +33 -0
  118. package/dist/svelte/components/UserMenu.svelte.d.ts.map +1 -0
  119. package/dist/svelte/components/__tests__/InviteUserModal.test.js +54 -0
  120. package/dist/svelte/components/__tests__/UserAvatar.test.js +31 -0
  121. package/dist/svelte/components/__tests__/UserCard.test.js +39 -0
  122. package/dist/svelte/components/__tests__/UserForm.test.js +50 -0
  123. package/dist/svelte/components/__tests__/UserList.test.js +48 -0
  124. package/dist/svelte/components/__tests__/UserMenu.test.js +38 -0
  125. package/dist/svelte/i18n.d.ts +15 -0
  126. package/dist/svelte/i18n.d.ts.map +1 -0
  127. package/dist/svelte/i18n.js +15 -0
  128. package/dist/svelte/index.d.ts +23 -0
  129. package/dist/svelte/index.d.ts.map +1 -0
  130. package/dist/svelte/index.js +27 -0
  131. package/dist/svelte/playground.d.ts +151 -0
  132. package/dist/svelte/playground.d.ts.map +1 -0
  133. package/dist/svelte/playground.js +134 -0
  134. package/dist/sveltekit/index.d.ts +379 -0
  135. package/dist/sveltekit/index.d.ts.map +1 -0
  136. package/dist/sveltekit/resource-list-handler.d.ts +127 -0
  137. package/dist/sveltekit/resource-list-handler.d.ts.map +1 -0
  138. package/dist/sveltekit/types.d.ts +31 -0
  139. package/dist/sveltekit/types.d.ts.map +1 -0
  140. package/dist/sveltekit.d.ts +2 -0
  141. package/dist/sveltekit.d.ts.map +1 -0
  142. package/dist/sveltekit.js +978 -0
  143. package/dist/sveltekit.js.map +1 -0
  144. package/dist/types/index.d.ts +61 -0
  145. package/dist/types/index.d.ts.map +1 -0
  146. package/dist/ui.d.ts +10 -0
  147. package/dist/ui.d.ts.map +1 -0
  148. package/dist/ui.js +75 -0
  149. package/dist/ui.js.map +1 -0
  150. package/package.json +97 -0
@@ -0,0 +1,194 @@
1
+ <script lang="ts">
2
+ import type { Profile } from '@happyvertical/smrt-profiles';
3
+ import { UserStatus } from '@happyvertical/smrt-types';
4
+ import { useI18n } from '@happyvertical/smrt-ui/i18n';
5
+ import type { User } from '@happyvertical/smrt-users';
6
+ import { M } from '../i18n.js';
7
+
8
+ const { t } = useI18n();
9
+
10
+ export interface Props {
11
+ user?: User | null;
12
+ profile?: Profile | null;
13
+ onsubmit: (data: { name: string; email: string; status: UserStatus }) => void;
14
+ oncancel?: () => void;
15
+ loading?: boolean;
16
+ }
17
+
18
+ const {
19
+ user = null,
20
+ profile = null,
21
+ onsubmit,
22
+ oncancel,
23
+ loading = false,
24
+ }: Props = $props();
25
+
26
+ function getInitialFormState() {
27
+ return {
28
+ name: profile?.name ?? '',
29
+ email: user?.email ?? '',
30
+ status: user?.status ?? UserStatus.ACTIVE,
31
+ user,
32
+ profile,
33
+ };
34
+ }
35
+
36
+ const initialFormState = getInitialFormState();
37
+ let name = $state(initialFormState.name);
38
+ let email = $state(initialFormState.email);
39
+ let status = $state(initialFormState.status);
40
+ let appliedUser: User | null = initialFormState.user;
41
+ let appliedProfile: Profile | null = initialFormState.profile;
42
+
43
+ $effect(() => {
44
+ if (appliedUser === user && appliedProfile === profile) {
45
+ return;
46
+ }
47
+
48
+ appliedUser = user;
49
+ appliedProfile = profile;
50
+ name = profile?.name ?? '';
51
+ email = user?.email ?? '';
52
+ status = user?.status ?? UserStatus.ACTIVE;
53
+ });
54
+
55
+ function handleSubmit(e: Event) {
56
+ e.preventDefault();
57
+ onsubmit({ name, email, status });
58
+ }
59
+ </script>
60
+
61
+ <form class="user-form" onsubmit={handleSubmit}>
62
+ <div class="field">
63
+ <label for="name">Name</label>
64
+ <input id="name" type="text" bind:value={name} required disabled={loading} />
65
+ </div>
66
+
67
+ <div class="field">
68
+ <label for="email">Email</label>
69
+ <input id="email" type="email" bind:value={email} required disabled={loading || !!user} />
70
+ {#if user}
71
+ <span class="hint">{t(M['users.user_form.email_cannot_be_changed'])}</span>
72
+ {/if}
73
+ </div>
74
+
75
+ <div class="field">
76
+ <label for="status">Status</label>
77
+ <select id="status" bind:value={status} disabled={loading}>
78
+ <option value="active">Active</option>
79
+ <option value="pending">Pending</option>
80
+ <option value="suspended">Suspended</option>
81
+ <option value="deactivated">Deactivated</option>
82
+ </select>
83
+ </div>
84
+
85
+ <div class="actions">
86
+ {#if oncancel}
87
+ <button type="button" class="btn-secondary" onclick={oncancel} disabled={loading}>
88
+ Cancel
89
+ </button>
90
+ {/if}
91
+ <button type="submit" class="btn-primary" disabled={loading}>
92
+ {#if loading}
93
+ Saving...
94
+ {:else}
95
+ {user ? 'Update User' : 'Create User'}
96
+ {/if}
97
+ </button>
98
+ </div>
99
+ </form>
100
+
101
+ <style>
102
+ .user-form {
103
+ display: flex;
104
+ flex-direction: column;
105
+ gap: var(--smrt-spacing-md, 1rem);
106
+ }
107
+
108
+ .field {
109
+ display: flex;
110
+ flex-direction: column;
111
+ gap: 0.25rem;
112
+ }
113
+
114
+ label {
115
+ font: var(--smrt-typography-body-medium-font, 500 0.875rem / 1.25 sans-serif);
116
+ color: var(--smrt-color-on-surface-variant, #43474e);
117
+ }
118
+
119
+ input,
120
+ select {
121
+ padding: var(--smrt-spacing-sm, 0.5rem) var(--smrt-spacing-md, 0.75rem);
122
+ border: 1px solid var(--smrt-color-outline-variant, #c4c6cf);
123
+ border-radius: var(--smrt-radius-medium, 0.5rem);
124
+ font: var(--smrt-typography-body-medium-font, 0.875rem / 1.25 sans-serif);
125
+ transition: border-color var(--smrt-duration-short2, 150ms) var(--smrt-easing-standard, ease);
126
+ }
127
+
128
+ input:focus,
129
+ select:focus {
130
+ outline: none;
131
+ border-color: var(--smrt-color-primary, #005ac1);
132
+ box-shadow: 0 0 0 3px var(--smrt-color-primary-container, rgba(0, 90, 193, 0.1));
133
+ }
134
+
135
+ input:disabled,
136
+ select:disabled {
137
+ background: var(--smrt-color-surface-container, #f3f4f6);
138
+ cursor: not-allowed;
139
+ }
140
+
141
+ .hint {
142
+ font: var(--smrt-typography-body-small-font, 0.75rem / 1.25 sans-serif);
143
+ color: var(--smrt-color-on-surface-variant, #43474e);
144
+ }
145
+
146
+ .actions {
147
+ display: flex;
148
+ justify-content: flex-end;
149
+ gap: var(--smrt-spacing-sm, 0.5rem);
150
+ margin-top: var(--smrt-spacing-sm, 0.5rem);
151
+ }
152
+
153
+ button {
154
+ padding: var(--smrt-spacing-sm, 0.5rem) var(--smrt-spacing-md, 1rem);
155
+ border-radius: var(--smrt-radius-medium, 0.5rem);
156
+ font: var(--smrt-typography-label-large-font, 500 0.875rem / 1.25 sans-serif);
157
+ cursor: pointer;
158
+ transition: all var(--smrt-duration-short2, 150ms) var(--smrt-easing-standard, ease);
159
+ }
160
+
161
+ button:disabled {
162
+ opacity: 0.6;
163
+ cursor: not-allowed;
164
+ }
165
+
166
+ .btn-primary {
167
+ background: var(--smrt-color-primary, #005ac1);
168
+ color: var(--smrt-color-on-primary, #ffffff);
169
+ border: none;
170
+ }
171
+
172
+ .btn-primary:hover:not(:disabled) {
173
+ background: var(--smrt-color-primary-container, #005ac1);
174
+ opacity: 0.9;
175
+ }
176
+
177
+ .btn-secondary {
178
+ background: var(--smrt-color-surface, white);
179
+ color: var(--smrt-color-on-surface-variant, #43474e);
180
+ border: 1px solid var(--smrt-color-outline-variant, #c4c6cf);
181
+ }
182
+
183
+ .btn-secondary:hover:not(:disabled) {
184
+ background: var(--smrt-color-surface-container-low, #f9fafb);
185
+ }
186
+
187
+ @media (prefers-reduced-motion: reduce) {
188
+ input,
189
+ select,
190
+ button {
191
+ transition: none;
192
+ }
193
+ }
194
+ </style>
@@ -0,0 +1,18 @@
1
+ import type { Profile } from '@happyvertical/smrt-profiles';
2
+ import { UserStatus } from '@happyvertical/smrt-types';
3
+ import type { User } from '@happyvertical/smrt-users';
4
+ export interface Props {
5
+ user?: User | null;
6
+ profile?: Profile | null;
7
+ onsubmit: (data: {
8
+ name: string;
9
+ email: string;
10
+ status: UserStatus;
11
+ }) => void;
12
+ oncancel?: () => void;
13
+ loading?: boolean;
14
+ }
15
+ declare const UserForm: import("svelte").Component<Props, {}, "">;
16
+ type UserForm = ReturnType<typeof UserForm>;
17
+ export default UserForm;
18
+ //# sourceMappingURL=UserForm.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"UserForm.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/UserForm.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAC5D,OAAO,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAEvD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,2BAA2B,CAAC;AAItD,MAAM,WAAW,KAAK;IACpB,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IACzB,QAAQ,EAAE,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,UAAU,CAAA;KAAE,KAAK,IAAI,CAAC;IAC9E,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAgGD,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -0,0 +1,107 @@
1
+ <script lang="ts">
2
+ import type { Profile } from '@happyvertical/smrt-profiles';
3
+ import { useI18n } from '@happyvertical/smrt-ui/i18n';
4
+ import type { User } from '@happyvertical/smrt-users';
5
+ import type { Snippet } from 'svelte';
6
+ import { M } from '../i18n.js';
7
+ import UserCard from './UserCard.svelte';
8
+
9
+ const { t } = useI18n();
10
+
11
+ interface UserWithProfile {
12
+ user: User;
13
+ profile: Profile;
14
+ role?: string;
15
+ }
16
+
17
+ export interface Props {
18
+ users: UserWithProfile[];
19
+ selectedId?: string | null;
20
+ onselect?: (user: User) => void;
21
+ emptyMessage?: string;
22
+ empty?: Snippet;
23
+ loading?: boolean;
24
+ }
25
+
26
+ const {
27
+ users,
28
+ selectedId = null,
29
+ onselect,
30
+ emptyMessage = 'No users found',
31
+ empty,
32
+ loading = false,
33
+ }: Props = $props();
34
+ </script>
35
+
36
+ <div class="user-list">
37
+ {#if loading}
38
+ <div class="loading">
39
+ <div class="spinner"></div>
40
+ <span>{t(M['users.user_list.loading_users'])}</span>
41
+ </div>
42
+ {:else if users.length === 0}
43
+ {#if empty}
44
+ {@render empty()}
45
+ {:else}
46
+ <div class="empty">{emptyMessage}</div>
47
+ {/if}
48
+ {:else}
49
+ {#each users as { user, profile, role } (user.id)}
50
+ <UserCard
51
+ {user}
52
+ {profile}
53
+ {role}
54
+ status={user.status}
55
+ selected={selectedId === user.id}
56
+ onclick={onselect ? () => onselect(user) : undefined}
57
+ />
58
+ {/each}
59
+ {/if}
60
+ </div>
61
+
62
+ <style>
63
+ .user-list {
64
+ display: flex;
65
+ flex-direction: column;
66
+ gap: var(--smrt-spacing-sm, 0.5rem);
67
+ }
68
+
69
+ .loading {
70
+ display: flex;
71
+ align-items: center;
72
+ justify-content: center;
73
+ gap: var(--smrt-spacing-sm, 0.75rem);
74
+ padding: var(--smrt-spacing-xl, 2rem);
75
+ color: var(--smrt-color-on-surface-variant, #43474e);
76
+ }
77
+
78
+ .spinner {
79
+ width: 1.25rem;
80
+ height: 1.25rem;
81
+ border: 2px solid var(--smrt-color-outline-variant, #c4c6cf);
82
+ border-top-color: var(--smrt-color-primary, #005ac1);
83
+ border-radius: var(--smrt-radius-full, 9999px);
84
+ animation: spin 0.6s linear infinite;
85
+ }
86
+
87
+ @keyframes spin {
88
+ to {
89
+ transform: rotate(360deg);
90
+ }
91
+ }
92
+
93
+ .empty {
94
+ padding: var(--smrt-spacing-xl, 2rem);
95
+ text-align: center;
96
+ color: var(--smrt-color-on-surface-variant, #43474e);
97
+ background: var(--smrt-color-surface-container-low, #f9fafb);
98
+ border: 1px dashed var(--smrt-color-outline-variant, #c4c6cf);
99
+ border-radius: var(--smrt-radius-medium, 0.5rem);
100
+ }
101
+
102
+ @media (prefers-reduced-motion: reduce) {
103
+ .spinner {
104
+ animation: none;
105
+ }
106
+ }
107
+ </style>
@@ -0,0 +1,20 @@
1
+ import type { Profile } from '@happyvertical/smrt-profiles';
2
+ import type { User } from '@happyvertical/smrt-users';
3
+ import type { Snippet } from 'svelte';
4
+ interface UserWithProfile {
5
+ user: User;
6
+ profile: Profile;
7
+ role?: string;
8
+ }
9
+ export interface Props {
10
+ users: UserWithProfile[];
11
+ selectedId?: string | null;
12
+ onselect?: (user: User) => void;
13
+ emptyMessage?: string;
14
+ empty?: Snippet;
15
+ loading?: boolean;
16
+ }
17
+ declare const UserList: import("svelte").Component<Props, {}, "">;
18
+ type UserList = ReturnType<typeof UserList>;
19
+ export default UserList;
20
+ //# sourceMappingURL=UserList.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"UserList.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/UserList.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAE5D,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,2BAA2B,CAAC;AACtD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAKtC,UAAU,eAAe;IACvB,IAAI,EAAE,IAAI,CAAC;IACX,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,KAAK;IACpB,KAAK,EAAE,eAAe,EAAE,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,IAAI,CAAC;IAChC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AA8CD,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -0,0 +1,326 @@
1
+ <script lang="ts">
2
+ /**
3
+ * UserMenu - User profile menu dropdown
4
+ * refactored for Material 3
5
+ *
6
+ * Accessibility:
7
+ * - Proper ARIA attributes for menu state
8
+ * - Keyboard navigation (Escape to close)
9
+ * - Click outside to close
10
+ * - Focus management
11
+ */
12
+ import type { Profile } from '@happyvertical/smrt-profiles';
13
+ import { ripple } from '@happyvertical/smrt-ui';
14
+ import { useI18n } from '@happyvertical/smrt-ui/i18n';
15
+ import { M } from '../i18n.js';
16
+
17
+ const { t } = useI18n();
18
+
19
+ /** Props for the UserMenu component */
20
+ export interface Props {
21
+ /** Full SMRT profile object */
22
+ profile?: Profile;
23
+ /** Simple user object (used when profile is not available) */
24
+ user?: { name: string; email?: string };
25
+ /** URL for sign out */
26
+ signoutUrl?: string;
27
+ /** URL for profile page */
28
+ profileUrl?: string;
29
+ /** URL for settings page */
30
+ settingsUrl?: string;
31
+ /** Accessible label for the menu button */
32
+ 'aria-label'?: string;
33
+ }
34
+
35
+ const {
36
+ profile,
37
+ user,
38
+ signoutUrl = '/auth/signout',
39
+ profileUrl = '/profile',
40
+ settingsUrl = '/settings',
41
+ 'aria-label': ariaLabel = 'User menu',
42
+ }: Props = $props();
43
+
44
+ const displayName = $derived(profile?.name ?? user?.name ?? 'User');
45
+ const userEmail = $derived(profile?.email ?? user?.email);
46
+
47
+ let open = $state(false);
48
+ let triggerButton: HTMLButtonElement;
49
+ const instanceId = $props.id();
50
+ const menuId = `user-menu-${instanceId}`;
51
+
52
+ function toggle() {
53
+ open = !open;
54
+ if (!open && triggerButton) {
55
+ triggerButton.focus();
56
+ }
57
+ }
58
+
59
+ function close() {
60
+ if (open) {
61
+ open = false;
62
+ // Return focus to trigger button
63
+ triggerButton?.focus();
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Handle keyboard navigation
69
+ */
70
+ function handleKeydown(event: KeyboardEvent) {
71
+ if (event.key === 'Escape' && open) {
72
+ event.preventDefault();
73
+ close();
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Handle click outside to close menu
79
+ */
80
+ function handleClickOutside(event: MouseEvent) {
81
+ const target = event.target as Node;
82
+ const menu = document.getElementById(menuId);
83
+ if (menu && !menu.contains(target) && !triggerButton.contains(target)) {
84
+ close();
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Get initials from name
90
+ */
91
+ function getInitials(name: string): string {
92
+ return name
93
+ .split(' ')
94
+ .filter(Boolean)
95
+ .map((part) => part[0])
96
+ .join('')
97
+ .toUpperCase()
98
+ .slice(0, 2);
99
+ }
100
+ </script>
101
+
102
+ <svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
103
+
104
+ <div class="user-menu" id={menuId}>
105
+ <button
106
+ bind:this={triggerButton}
107
+ id="{menuId}-trigger"
108
+ class="user-menu-trigger"
109
+ onclick={toggle}
110
+ type="button"
111
+ use:ripple
112
+ aria-haspopup="menu"
113
+ aria-expanded={open}
114
+ aria-controls={open ? `${menuId}-dropdown` : undefined}
115
+ aria-label={ariaLabel}
116
+ >
117
+ <span class="avatar" aria-hidden="true">
118
+ {getInitials(displayName)}
119
+ </span>
120
+ <span class="user-name">{displayName}</span>
121
+ <svg class="chevron" class:open viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
122
+ <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
123
+ </svg>
124
+ </button>
125
+
126
+ {#if open}
127
+ <div
128
+ id="{menuId}-dropdown"
129
+ class="dropdown"
130
+ role="menu"
131
+ aria-orientation="vertical"
132
+ aria-labelledby="{menuId}-trigger"
133
+ >
134
+ {#if userEmail}
135
+ <div class="user-info" role="none">
136
+ <span class="user-info-name">{displayName}</span>
137
+ <span class="user-info-email">{userEmail}</span>
138
+ </div>
139
+ <hr class="divider" />
140
+ {/if}
141
+
142
+ <a
143
+ href={profileUrl}
144
+ class="dropdown-item"
145
+ onclick={close}
146
+ use:ripple
147
+ role="menuitem"
148
+ tabindex="-1"
149
+ >
150
+ <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
151
+ <path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" />
152
+ </svg>
153
+ Profile
154
+ </a>
155
+ <a
156
+ href={settingsUrl}
157
+ class="dropdown-item"
158
+ onclick={close}
159
+ use:ripple
160
+ role="menuitem"
161
+ tabindex="-1"
162
+ >
163
+ <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
164
+ <path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
165
+ </svg>
166
+ Settings
167
+ </a>
168
+ <hr class="divider" />
169
+ <a
170
+ href={signoutUrl}
171
+ class="dropdown-item danger"
172
+ onclick={close}
173
+ use:ripple
174
+ role="menuitem"
175
+ tabindex="-1"
176
+ >
177
+ <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
178
+ <path fill-rule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clip-rule="evenodd" />
179
+ </svg>
180
+ {t(M['users.user_menu.sign_out'])}
181
+ </a>
182
+ </div>
183
+ {/if}
184
+ </div>
185
+
186
+ <style>
187
+ .user-menu {
188
+ position: relative;
189
+ display: inline-block;
190
+ }
191
+
192
+ .user-menu-trigger {
193
+ display: flex;
194
+ align-items: center;
195
+ gap: var(--smrt-spacing-3, 12px);
196
+ padding: var(--smrt-spacing-2, 8px) var(--smrt-spacing-3, 12px);
197
+ background: transparent;
198
+ border: none;
199
+ border-radius: var(--smrt-radius-2xl, 24px);
200
+ cursor: pointer;
201
+ transition: background-color var(--smrt-duration-short3, 200ms) var(--smrt-easing-standard, ease);
202
+ color: var(--smrt-color-on-surface);
203
+ position: relative;
204
+ overflow: hidden;
205
+ }
206
+
207
+ .user-menu-trigger:hover {
208
+ background-color: var(--smrt-color-surface-container-high);
209
+ }
210
+
211
+ .avatar {
212
+ display: flex;
213
+ align-items: center;
214
+ justify-content: center;
215
+ width: 32px;
216
+ height: 32px;
217
+ border-radius: var(--smrt-radius-full, 50%);
218
+ background-color: var(--smrt-color-primary-container);
219
+ color: var(--smrt-color-on-primary-container);
220
+ font: var(--smrt-typography-label-large-font);
221
+ font-weight: var(--smrt-typography-weight-semibold, 600);
222
+ }
223
+
224
+ .user-name {
225
+ font: var(--smrt-typography-label-large-font);
226
+ font-weight: var(--smrt-typography-weight-medium, 500);
227
+ }
228
+
229
+ .chevron {
230
+ width: 18px;
231
+ height: 18px;
232
+ transition: transform var(--smrt-duration-short3, 200ms) var(--smrt-easing-standard, cubic-bezier(0.2, 0, 0, 1));
233
+ opacity: 0.7;
234
+ }
235
+
236
+ .chevron.open {
237
+ transform: rotate(180deg);
238
+ }
239
+
240
+ .dropdown {
241
+ position: absolute;
242
+ top: 100%;
243
+ right: 0;
244
+ margin-top: var(--smrt-spacing-1, 4px);
245
+ min-width: 200px;
246
+ background-color: var(--smrt-color-surface-container);
247
+ border-radius: var(--smrt-radius-medium, 4px);
248
+ box-shadow: var(--smrt-elevation-2);
249
+ z-index: var(--smrt-z-index-dropdown, 1000);
250
+ padding: var(--smrt-spacing-1, 4px) 0;
251
+ overflow: hidden;
252
+ }
253
+
254
+ .dropdown-item {
255
+ display: flex;
256
+ align-items: center;
257
+ gap: var(--smrt-spacing-3, 12px);
258
+ padding: var(--smrt-spacing-3, 12px) var(--smrt-spacing-4, 16px);
259
+ font: var(--smrt-typography-body-medium-font);
260
+ color: var(--smrt-color-on-surface);
261
+ text-decoration: none;
262
+ transition: background-color var(--smrt-duration-short3, 200ms) var(--smrt-easing-standard, ease);
263
+ position: relative;
264
+ overflow: hidden;
265
+ }
266
+
267
+ .dropdown-item:hover {
268
+ background-color: var(--smrt-color-surface-container-highest);
269
+ }
270
+
271
+ .dropdown-item svg {
272
+ width: 18px;
273
+ height: 18px;
274
+ opacity: 0.7;
275
+ }
276
+
277
+ .dropdown-item.danger {
278
+ color: var(--smrt-color-error);
279
+ }
280
+
281
+ .user-info {
282
+ padding: var(--smrt-spacing-3, 12px) var(--smrt-spacing-4, 16px);
283
+ display: flex;
284
+ flex-direction: column;
285
+ gap: var(--smrt-spacing-1, 4px);
286
+ }
287
+
288
+ .user-info-name {
289
+ font: var(--smrt-typography-body-medium-font);
290
+ font-weight: var(--smrt-typography-weight-medium, 500);
291
+ color: var(--smrt-color-on-surface);
292
+ }
293
+
294
+ .user-info-email {
295
+ font: var(--smrt-typography-body-small-font);
296
+ color: var(--smrt-color-on-surface-variant);
297
+ }
298
+
299
+ .user-menu-trigger:focus-visible {
300
+ outline: 2px solid var(--smrt-color-primary);
301
+ outline-offset: 2px;
302
+ }
303
+
304
+ .dropdown-item:focus-visible {
305
+ outline: 2px solid var(--smrt-color-primary);
306
+ outline-offset: -2px;
307
+ }
308
+
309
+ .divider {
310
+ margin: var(--smrt-spacing-1, 4px) 0;
311
+ border: none;
312
+ border-top: 1px solid var(--smrt-color-outline-variant);
313
+ }
314
+
315
+ @media (prefers-reduced-motion: reduce) {
316
+ .user-menu-trigger,
317
+ .chevron,
318
+ .dropdown-item {
319
+ transition: none;
320
+ }
321
+
322
+ .chevron.open {
323
+ transform: none;
324
+ }
325
+ }
326
+ </style>
@@ -0,0 +1,33 @@
1
+ /**
2
+ * UserMenu - User profile menu dropdown
3
+ * refactored for Material 3
4
+ *
5
+ * Accessibility:
6
+ * - Proper ARIA attributes for menu state
7
+ * - Keyboard navigation (Escape to close)
8
+ * - Click outside to close
9
+ * - Focus management
10
+ */
11
+ import type { Profile } from '@happyvertical/smrt-profiles';
12
+ /** Props for the UserMenu component */
13
+ export interface Props {
14
+ /** Full SMRT profile object */
15
+ profile?: Profile;
16
+ /** Simple user object (used when profile is not available) */
17
+ user?: {
18
+ name: string;
19
+ email?: string;
20
+ };
21
+ /** URL for sign out */
22
+ signoutUrl?: string;
23
+ /** URL for profile page */
24
+ profileUrl?: string;
25
+ /** URL for settings page */
26
+ settingsUrl?: string;
27
+ /** Accessible label for the menu button */
28
+ 'aria-label'?: string;
29
+ }
30
+ declare const UserMenu: import("svelte").Component<Props, {}, "">;
31
+ type UserMenu = ReturnType<typeof UserMenu>;
32
+ export default UserMenu;
33
+ //# sourceMappingURL=UserMenu.svelte.d.ts.map