@jskit-ai/users-web 0.1.4

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 (97) hide show
  1. package/package.descriptor.mjs +507 -0
  2. package/package.json +31 -0
  3. package/src/client/components/ConsoleSettingsClientElement.vue +24 -0
  4. package/src/client/components/MembersAdminClientElement.vue +404 -0
  5. package/src/client/components/ProfileClientElement.vue +242 -0
  6. package/src/client/components/UsersProfileSurfaceSwitchMenuItem.vue +39 -0
  7. package/src/client/components/UsersShellMenuLinkItem.vue +140 -0
  8. package/src/client/components/UsersSurfaceAwareMenuLinkItem.vue +87 -0
  9. package/src/client/components/UsersWorkspaceMembersMenuItem.vue +36 -0
  10. package/src/client/components/UsersWorkspacePermissionMenuItem.vue +90 -0
  11. package/src/client/components/UsersWorkspaceSelector.vue +237 -0
  12. package/src/client/components/UsersWorkspaceSettingsMenuItem.vue +39 -0
  13. package/src/client/components/UsersWorkspaceToolsWidget.vue +23 -0
  14. package/src/client/components/WorkspaceMembersClientElement.vue +663 -0
  15. package/src/client/components/WorkspaceSettingsClientElement.vue +230 -0
  16. package/src/client/components/WorkspacesClientElement.vue +514 -0
  17. package/src/client/composables/accountSettingsAvatarUploadRuntime.js +241 -0
  18. package/src/client/composables/accountSettingsInvitesRuntime.js +88 -0
  19. package/src/client/composables/accountSettingsRuntimeConstants.js +77 -0
  20. package/src/client/composables/accountSettingsRuntimeHelpers.js +75 -0
  21. package/src/client/composables/errorMessageHelpers.js +66 -0
  22. package/src/client/composables/internal/useOperationScope.js +144 -0
  23. package/src/client/composables/modelStateHelpers.js +49 -0
  24. package/src/client/composables/operationUiHelpers.js +121 -0
  25. package/src/client/composables/operationValidationHelpers.js +52 -0
  26. package/src/client/composables/refValueHelpers.js +19 -0
  27. package/src/client/composables/scopeHelpers.js +145 -0
  28. package/src/client/composables/useAccess.js +109 -0
  29. package/src/client/composables/useAccountSettingsRuntime.js +533 -0
  30. package/src/client/composables/useAddEdit.js +135 -0
  31. package/src/client/composables/useAddEditCore.js +137 -0
  32. package/src/client/composables/useBootstrapQuery.js +52 -0
  33. package/src/client/composables/useCommand.js +112 -0
  34. package/src/client/composables/useCommandCore.js +130 -0
  35. package/src/client/composables/useEndpointResource.js +104 -0
  36. package/src/client/composables/useFieldErrorBag.js +61 -0
  37. package/src/client/composables/useList.js +85 -0
  38. package/src/client/composables/useListCore.js +65 -0
  39. package/src/client/composables/usePagedCollection.js +125 -0
  40. package/src/client/composables/usePaths.js +108 -0
  41. package/src/client/composables/useRealtimeQueryInvalidation.js +105 -0
  42. package/src/client/composables/useScopeRuntime.js +107 -0
  43. package/src/client/composables/useSurfaceRouteContext.js +31 -0
  44. package/src/client/composables/useUiFeedback.js +96 -0
  45. package/src/client/composables/useView.js +89 -0
  46. package/src/client/composables/useViewCore.js +104 -0
  47. package/src/client/composables/useWorkspaceRouteContext.js +28 -0
  48. package/src/client/composables/useWorkspaceSurfaceId.js +43 -0
  49. package/src/client/index.js +7 -0
  50. package/src/client/lib/bootstrap.js +95 -0
  51. package/src/client/lib/httpClient.js +67 -0
  52. package/src/client/lib/menuIcons.js +192 -0
  53. package/src/client/lib/permissions.js +34 -0
  54. package/src/client/lib/profileSurfaceMenuLinks.js +142 -0
  55. package/src/client/lib/surfaceAccessPolicy.js +350 -0
  56. package/src/client/lib/theme.js +99 -0
  57. package/src/client/lib/workspaceLinkResolver.js +207 -0
  58. package/src/client/lib/workspaceSurfaceContext.js +82 -0
  59. package/src/client/lib/workspaceSurfacePaths.js +163 -0
  60. package/src/client/providers/UsersWebClientProvider.js +85 -0
  61. package/src/client/runtime/bootstrapPlacementRouteGuards.js +371 -0
  62. package/src/client/runtime/bootstrapPlacementRuntime.js +413 -0
  63. package/src/client/runtime/bootstrapPlacementRuntimeConstants.js +32 -0
  64. package/src/client/runtime/bootstrapPlacementRuntimeHelpers.js +157 -0
  65. package/src/client/support/contractGuards.js +34 -0
  66. package/src/client/support/realtimeWorkspace.js +12 -0
  67. package/src/client/support/runtimeNormalization.js +27 -0
  68. package/src/client/support/workspaceQueryKeys.js +15 -0
  69. package/templates/packages/main/src/client/components/AccountPendingInvitesCue.vue +162 -0
  70. package/templates/src/components/WorkspaceNotFoundCard.vue +33 -0
  71. package/templates/src/components/account/settings/AccountSettingsClientElement.vue +153 -0
  72. package/templates/src/components/account/settings/AccountSettingsInvitesSection.vue +77 -0
  73. package/templates/src/components/account/settings/AccountSettingsNotificationsSection.vue +55 -0
  74. package/templates/src/components/account/settings/AccountSettingsPreferencesSection.vue +125 -0
  75. package/templates/src/components/account/settings/AccountSettingsProfileSection.vue +94 -0
  76. package/templates/src/composables/useWorkspaceNotFoundState.js +48 -0
  77. package/templates/src/pages/account/index.vue +17 -0
  78. package/templates/src/pages/admin/members/index.vue +7 -0
  79. package/templates/src/pages/admin/workspace/settings/index.vue +16 -0
  80. package/templates/src/pages/console/settings/index.vue +16 -0
  81. package/templates/src/surfaces/admin/index.vue +29 -0
  82. package/templates/src/surfaces/admin/root.vue +20 -0
  83. package/templates/src/surfaces/app/index.vue +27 -0
  84. package/templates/src/surfaces/app/root.vue +20 -0
  85. package/test/bootstrap.test.js +38 -0
  86. package/test/bootstrapPlacementRuntime.test.js +991 -0
  87. package/test/errorMessageHelpers.test.js +28 -0
  88. package/test/exportsContract.test.js +39 -0
  89. package/test/menuIcons.test.js +33 -0
  90. package/test/permissions.test.js +35 -0
  91. package/test/profileSurfaceMenuLinks.test.js +207 -0
  92. package/test/refValueHelpers.test.js +14 -0
  93. package/test/scopeHelpers.test.js +57 -0
  94. package/test/surfaceAccessPolicy.test.js +129 -0
  95. package/test/theme.test.js +95 -0
  96. package/test/workspaceLinkResolver.test.js +61 -0
  97. package/test/workspaceSurfacePaths.test.js +39 -0
@@ -0,0 +1,404 @@
1
+ <template>
2
+ <section class="members-admin-client-element">
3
+ <v-row>
4
+ <v-col cols="12" lg="5">
5
+ <v-card rounded="lg" elevation="1" border data-testid="members-admin-invite-card">
6
+ <v-card-item>
7
+ <v-card-title class="text-subtitle-1">Invite people</v-card-title>
8
+ <v-card-subtitle>Send workspace invites with a role.</v-card-subtitle>
9
+ </v-card-item>
10
+ <v-divider />
11
+ <v-card-text>
12
+ <template v-if="showWorkspaceInviteLoadingSkeleton">
13
+ <v-skeleton-loader type="text@2, paragraph, button" class="mb-3" />
14
+ </template>
15
+ <template v-else>
16
+ <v-progress-linear v-if="showWorkspaceInviteRefreshingIndicator" indeterminate class="mb-3" />
17
+ <p
18
+ v-if="workspaceInvitePolicyLoaded && !workspaceInvitesAvailable"
19
+ class="text-body-2 text-medium-emphasis mb-3"
20
+ >
21
+ Invites are disabled by app policy or role manifest.
22
+ </p>
23
+ <p
24
+ v-else-if="workspaceInvitePolicyLoaded && !workspaceInvitesEnabled"
25
+ class="text-body-2 text-medium-emphasis mb-3"
26
+ >
27
+ Invites are currently off for this workspace.
28
+ </p>
29
+
30
+ <p v-if="!canInviteMembers" class="text-body-2 text-medium-emphasis mb-3">
31
+ You do not have permission to send invites.
32
+ </p>
33
+
34
+ <template v-else-if="canShowInviteForm">
35
+ <v-form @submit.prevent="onSubmitInvite" novalidate>
36
+ <v-text-field
37
+ v-model="inviteForm.email"
38
+ label="Email"
39
+ variant="outlined"
40
+ density="comfortable"
41
+ type="email"
42
+ autocomplete="email"
43
+ :disabled="isCreatingInvite || showWorkspaceInviteRefreshingIndicator"
44
+ class="mb-3"
45
+ />
46
+ <v-select
47
+ v-model="inviteForm.roleId"
48
+ label="Role"
49
+ :items="inviteRoleOptions"
50
+ item-title="title"
51
+ item-value="value"
52
+ variant="outlined"
53
+ density="comfortable"
54
+ :disabled="isCreatingInvite || showWorkspaceInviteRefreshingIndicator"
55
+ class="mb-3"
56
+ />
57
+ <v-btn
58
+ type="submit"
59
+ color="primary"
60
+ :loading="isCreatingInvite"
61
+ :disabled="showWorkspaceInviteRefreshingIndicator"
62
+ >
63
+ Send invite
64
+ </v-btn>
65
+ </v-form>
66
+ </template>
67
+ </template>
68
+ </v-card-text>
69
+ </v-card>
70
+ </v-col>
71
+
72
+ <v-col cols="12" lg="7">
73
+ <v-card rounded="lg" elevation="1" border data-testid="members-admin-members-card">
74
+ <v-card-item>
75
+ <v-card-title class="text-subtitle-1">Team</v-card-title>
76
+ <v-card-subtitle>Members and pending invites.</v-card-subtitle>
77
+ </v-card-item>
78
+ <v-divider />
79
+ <v-card-text>
80
+ <template v-if="showMembersLoadingSkeleton">
81
+ <v-skeleton-loader type="text@2, list-item-avatar-two-line@3" class="mb-3" />
82
+ <v-divider class="mb-3" />
83
+ <v-skeleton-loader type="text, list-item-two-line@2" />
84
+ </template>
85
+ <template v-else>
86
+ <v-progress-linear v-if="showMembersRefreshingIndicator" indeterminate class="mb-3" />
87
+ <p v-if="!canViewMembers" class="text-body-2 text-medium-emphasis mb-0">
88
+ You do not have permission to view members.
89
+ </p>
90
+
91
+ <template v-else>
92
+ <div class="text-caption text-medium-emphasis mb-2">Members</div>
93
+ <v-list density="comfortable" class="pa-0 mb-3">
94
+ <v-list-item v-for="member in memberRows" :key="member.userId" class="px-0">
95
+ <template #title>
96
+ <div class="d-flex align-center ga-2">
97
+ <span>{{ member.displayName || member.email }}</span>
98
+ <v-chip v-if="showOwnerChip(member)" size="x-small" label color="secondary">Owner</v-chip>
99
+ </div>
100
+ </template>
101
+ <template #subtitle>
102
+ {{ member.email }}
103
+ </template>
104
+
105
+ <template #append>
106
+ <div class="d-flex align-center ga-2">
107
+ <v-select
108
+ v-model="member.roleId"
109
+ :items="memberRoleOptions"
110
+ item-title="title"
111
+ item-value="value"
112
+ density="compact"
113
+ variant="outlined"
114
+ hide-details
115
+ class="member-role-select"
116
+ :disabled="showMembersRefreshingIndicator || isMemberRoleLocked(member)"
117
+ @update:model-value="(value) => onMemberRoleUpdate(member, value)"
118
+ />
119
+ <v-btn
120
+ variant="text"
121
+ color="error"
122
+ :disabled="showMembersRefreshingIndicator || isMemberRemoveLocked(member)"
123
+ :loading="isRemoveMemberLoading(member.userId)"
124
+ @click="onRemoveMember(member)"
125
+ >
126
+ Remove
127
+ </v-btn>
128
+ </div>
129
+ </template>
130
+ </v-list-item>
131
+ </v-list>
132
+
133
+ <v-divider class="mb-3" />
134
+
135
+ <div class="text-caption text-medium-emphasis mb-2">Pending invites</div>
136
+ <v-list density="comfortable" class="pa-0">
137
+ <v-list-item v-for="invite in inviteRows" :key="invite.id" class="px-0">
138
+ <template #title>
139
+ {{ invite.email }}
140
+ </template>
141
+ <template #subtitle>
142
+ Role: {{ invite.roleId }} • expires {{ formatDateTime(invite.expiresAt) }}
143
+ </template>
144
+ <template #append>
145
+ <v-btn
146
+ v-if="canRevokeInvites"
147
+ variant="text"
148
+ color="error"
149
+ :disabled="showMembersRefreshingIndicator"
150
+ :loading="isRevokeInviteLoading(invite.id)"
151
+ @click="onRevokeInvite(invite.id)"
152
+ >
153
+ Revoke
154
+ </v-btn>
155
+ </template>
156
+ </v-list-item>
157
+ <p v-if="inviteRows.length < 1" class="text-body-2 text-medium-emphasis mb-0">No pending invites.</p>
158
+ </v-list>
159
+ </template>
160
+ </template>
161
+ </v-card-text>
162
+ </v-card>
163
+ </v-col>
164
+ </v-row>
165
+ </section>
166
+ </template>
167
+
168
+ <script setup>
169
+ import { computed, toRefs, unref } from "vue";
170
+ import { requireBoolean, requireFunction, requireRecord } from "../support/contractGuards.js";
171
+
172
+ const props = defineProps({
173
+ forms: {
174
+ type: Object,
175
+ required: true
176
+ },
177
+ options: {
178
+ type: Object,
179
+ required: true
180
+ },
181
+ collections: {
182
+ type: Object,
183
+ required: true
184
+ },
185
+ permissions: {
186
+ type: Object,
187
+ required: true
188
+ },
189
+ revokeInviteId: {
190
+ type: Number,
191
+ required: true
192
+ },
193
+ removeMemberUserId: {
194
+ type: Number,
195
+ required: true
196
+ },
197
+ status: {
198
+ type: Object,
199
+ required: true
200
+ },
201
+ actions: {
202
+ type: Object,
203
+ required: true
204
+ }
205
+ });
206
+
207
+ requireRecord(props.forms, "forms", "MembersAdminClientElement");
208
+ requireRecord(props.options, "options", "MembersAdminClientElement");
209
+ requireRecord(props.collections, "collections", "MembersAdminClientElement");
210
+ requireRecord(props.permissions, "permissions", "MembersAdminClientElement");
211
+ requireRecord(props.status, "status", "MembersAdminClientElement");
212
+ requireRecord(props.actions, "actions", "MembersAdminClientElement");
213
+
214
+ const {
215
+ forms,
216
+ options,
217
+ collections,
218
+ permissions,
219
+ revokeInviteId,
220
+ removeMemberUserId,
221
+ status,
222
+ actions
223
+ } = toRefs(props);
224
+
225
+ const actionHandlers = Object.freeze({
226
+ submitInvite: requireFunction(actions.value.submitInvite, "actions.submitInvite", "MembersAdminClientElement"),
227
+ submitRevokeInvite: requireFunction(
228
+ actions.value.submitRevokeInvite,
229
+ "actions.submitRevokeInvite",
230
+ "MembersAdminClientElement"
231
+ ),
232
+ submitMemberRoleUpdate: requireFunction(
233
+ actions.value.submitMemberRoleUpdate,
234
+ "actions.submitMemberRoleUpdate",
235
+ "MembersAdminClientElement"
236
+ ),
237
+ submitRemoveMember: requireFunction(
238
+ actions.value.submitRemoveMember,
239
+ "actions.submitRemoveMember",
240
+ "MembersAdminClientElement"
241
+ )
242
+ });
243
+
244
+ const inviteForm = computed(() => requireRecord(forms.value.invite, "forms.invite", "MembersAdminClientElement"));
245
+ const workspaceForm = computed(() =>
246
+ requireRecord(forms.value.workspace, "forms.workspace", "MembersAdminClientElement")
247
+ );
248
+
249
+ const memberRows = computed(() => {
250
+ const source = collections.value.members;
251
+ return Array.isArray(unref(source)) ? unref(source) : [];
252
+ });
253
+
254
+ const inviteRows = computed(() => {
255
+ const source = collections.value.invites;
256
+ return Array.isArray(unref(source)) ? unref(source) : [];
257
+ });
258
+
259
+ const inviteRoleOptions = computed(() => {
260
+ const source = options.value.inviteRoleOptions;
261
+ return Array.isArray(unref(source)) ? unref(source) : [];
262
+ });
263
+
264
+ const memberRoleOptions = computed(() => {
265
+ const source = options.value.memberRoleOptions;
266
+ return Array.isArray(unref(source)) ? unref(source) : [];
267
+ });
268
+
269
+ const canViewMembers = computed(() => Boolean(unref(permissions.value.canViewMembers)));
270
+ const canInviteMembers = computed(() => Boolean(unref(permissions.value.canInviteMembers)));
271
+ const canManageMembers = computed(() => Boolean(unref(permissions.value.canManageMembers)));
272
+ const canRevokeInvites = computed(() => Boolean(unref(permissions.value.canRevokeInvites)));
273
+ const isCreatingInvite = computed(() => Boolean(unref(status.value.isCreatingInvite)));
274
+ const isRevokingInvite = computed(() => Boolean(unref(status.value.isRevokingInvite)));
275
+ const isRemovingMember = computed(() => Boolean(unref(status.value.isRemovingMember)));
276
+ const workspaceInvitePolicyLoaded = computed(() =>
277
+ requireBoolean(status.value.hasLoadedWorkspaceSettings, "status.hasLoadedWorkspaceSettings", "MembersAdminClientElement")
278
+ );
279
+ const workspaceInvitePolicyRefreshing = computed(() =>
280
+ requireBoolean(
281
+ status.value.isRefreshingWorkspaceSettings,
282
+ "status.isRefreshingWorkspaceSettings",
283
+ "MembersAdminClientElement"
284
+ )
285
+ );
286
+ const membersListLoaded = computed(() =>
287
+ requireBoolean(status.value.hasLoadedMembersList, "status.hasLoadedMembersList", "MembersAdminClientElement")
288
+ );
289
+ const membersListRefreshing = computed(() =>
290
+ requireBoolean(status.value.isRefreshingMembersList, "status.isRefreshingMembersList", "MembersAdminClientElement")
291
+ );
292
+ const inviteListLoaded = computed(() =>
293
+ requireBoolean(status.value.hasLoadedInviteList, "status.hasLoadedInviteList", "MembersAdminClientElement")
294
+ );
295
+ const inviteListRefreshing = computed(() =>
296
+ requireBoolean(status.value.isRefreshingInviteList, "status.isRefreshingInviteList", "MembersAdminClientElement")
297
+ );
298
+
299
+ const showWorkspaceInviteLoadingSkeleton = computed(
300
+ () => canInviteMembers.value && !workspaceInvitePolicyLoaded.value
301
+ );
302
+ const showWorkspaceInviteRefreshingIndicator = computed(
303
+ () => canInviteMembers.value && workspaceInvitePolicyLoaded.value && workspaceInvitePolicyRefreshing.value
304
+ );
305
+
306
+ const showMembersLoadingSkeleton = computed(
307
+ () =>
308
+ canViewMembers.value &&
309
+ (!membersListLoaded.value || !inviteListLoaded.value)
310
+ );
311
+ const showMembersRefreshingIndicator = computed(
312
+ () =>
313
+ canViewMembers.value &&
314
+ membersListLoaded.value &&
315
+ inviteListLoaded.value &&
316
+ (membersListRefreshing.value || inviteListRefreshing.value)
317
+ );
318
+
319
+ const workspaceInvitesAvailable = computed(() => Boolean(unref(workspaceForm.value.invitesAvailable)));
320
+ const workspaceInvitesEnabled = computed(() => Boolean(unref(workspaceForm.value.invitesEnabled)));
321
+
322
+ const canShowInviteForm = computed(
323
+ () => canInviteMembers.value && workspaceInvitesAvailable.value && workspaceInvitesEnabled.value
324
+ );
325
+
326
+ function formatDateTime(value) {
327
+ if (typeof options.value.formatDateTime === "function") {
328
+ return options.value.formatDateTime(value);
329
+ }
330
+
331
+ const date = new Date(value);
332
+ if (Number.isNaN(date.getTime())) {
333
+ return "unknown";
334
+ }
335
+
336
+ return date.toLocaleString();
337
+ }
338
+
339
+ function showOwnerChip(member) {
340
+ return Boolean(member?.isOwner);
341
+ }
342
+
343
+ function isMemberRoleLocked(member) {
344
+ if (!canManageMembers.value) {
345
+ return true;
346
+ }
347
+
348
+ return Boolean(member?.isOwner);
349
+ }
350
+
351
+ function isMemberRemoveLocked(member) {
352
+ if (!canManageMembers.value) {
353
+ return true;
354
+ }
355
+
356
+ return Boolean(member?.isOwner);
357
+ }
358
+
359
+ function isRevokeInviteLoading(inviteId) {
360
+ return isRevokingInvite.value && revokeInviteId.value === Number(inviteId || 0);
361
+ }
362
+
363
+ function isRemoveMemberLoading(memberUserId) {
364
+ return isRemovingMember.value && removeMemberUserId.value === Number(memberUserId || 0);
365
+ }
366
+
367
+ async function onSubmitInvite() {
368
+ if (!canShowInviteForm.value) {
369
+ return;
370
+ }
371
+
372
+ await actionHandlers.submitInvite();
373
+ }
374
+
375
+ async function onRevokeInvite(inviteId) {
376
+ if (!canRevokeInvites.value) {
377
+ return;
378
+ }
379
+
380
+ await actionHandlers.submitRevokeInvite(inviteId);
381
+ }
382
+
383
+ async function onMemberRoleUpdate(member, roleId) {
384
+ if (isMemberRoleLocked(member)) {
385
+ return;
386
+ }
387
+
388
+ await actionHandlers.submitMemberRoleUpdate(member, roleId);
389
+ }
390
+
391
+ async function onRemoveMember(member) {
392
+ if (isMemberRemoveLocked(member)) {
393
+ return;
394
+ }
395
+
396
+ await actionHandlers.submitRemoveMember(member);
397
+ }
398
+ </script>
399
+
400
+ <style scoped>
401
+ .member-role-select {
402
+ width: 160px;
403
+ }
404
+ </style>
@@ -0,0 +1,242 @@
1
+ <template>
2
+ <section :class="rootClasses" :data-testid="uiTestIds.root">
3
+ <v-card
4
+ class="profile-client-card"
5
+ :class="uiClasses.card"
6
+ :rounded="resolvedVariant.surface === 'plain' ? '0' : 'lg'"
7
+ :elevation="0"
8
+ :border="resolvedVariant.surface !== 'plain'"
9
+ :variant="resolvedVariant.surface === 'plain' ? 'text' : undefined"
10
+ :data-testid="uiTestIds.card"
11
+ >
12
+ <v-card-item v-if="resolvedFeatures.header">
13
+ <v-card-title class="text-subtitle-1">{{ copyText.title }}</v-card-title>
14
+ </v-card-item>
15
+ <v-divider v-if="resolvedFeatures.header" />
16
+ <v-card-text>
17
+ <slot name="form-before" :state="state" :actions="actions" />
18
+
19
+ <v-form @submit.prevent="onSubmitProfile" novalidate>
20
+ <v-row class="mb-2">
21
+ <v-col cols="12" md="4" class="d-flex flex-column align-center justify-center">
22
+ <v-avatar :size="state.preferencesForm.avatarSize" color="surface-variant" rounded="circle" class="mb-3">
23
+ <v-img v-if="state.profileAvatar.effectiveUrl" :src="state.profileAvatar.effectiveUrl" cover />
24
+ <span v-else class="text-h6">{{ state.profileInitials }}</span>
25
+ </v-avatar>
26
+ <div class="text-caption text-medium-emphasis">
27
+ {{ copyText.previewSizePrefix }} {{ state.preferencesForm.avatarSize }} {{ copyText.previewSizeSuffix }}
28
+ </div>
29
+ </v-col>
30
+ <v-col cols="12" md="8">
31
+ <div class="d-flex flex-wrap ga-2 mb-2">
32
+ <v-btn variant="tonal" color="secondary" :data-testid="uiTestIds.avatarReplaceButton" @click="onAvatarReplace">
33
+ {{ copyText.replaceAvatar }}
34
+ </v-btn>
35
+ <v-btn
36
+ v-if="resolvedFeatures.removeAvatar && state.profileAvatar.hasUploadedAvatar"
37
+ variant="text"
38
+ color="error"
39
+ :data-testid="uiTestIds.avatarRemoveButton"
40
+ :loading="state.avatarDeleteMutation.isPending.value"
41
+ @click="onAvatarRemove"
42
+ >
43
+ {{ copyText.removeAvatar }}
44
+ </v-btn>
45
+ <slot name="avatar-actions-extra" :state="state" :actions="actions" />
46
+ </div>
47
+ <div v-if="state.selectedAvatarFileName" class="text-caption text-medium-emphasis mb-2">
48
+ {{ copyText.selectedFilePrefix }} {{ state.selectedAvatarFileName }}
49
+ </div>
50
+ </v-col>
51
+ </v-row>
52
+
53
+ <v-row>
54
+ <v-col cols="12" md="6">
55
+ <v-text-field
56
+ v-model="state.profileForm.displayName"
57
+ :label="copyText.displayName"
58
+ variant="outlined"
59
+ :density="resolvedVariant.density"
60
+ autocomplete="nickname"
61
+ :error-messages="state.profileFieldErrors.displayName ? [state.profileFieldErrors.displayName] : []"
62
+ />
63
+ </v-col>
64
+ <v-col cols="12" md="6">
65
+ <v-text-field
66
+ v-model="state.profileForm.email"
67
+ :label="copyText.email"
68
+ variant="outlined"
69
+ :density="resolvedVariant.density"
70
+ readonly
71
+ :hint="copyText.emailHint"
72
+ persistent-hint
73
+ />
74
+ </v-col>
75
+ </v-row>
76
+ <v-btn type="submit" color="primary" :loading="state.profileMutation.isPending.value" :data-testid="uiTestIds.submitButton">
77
+ {{ copyText.saveProfile }}
78
+ </v-btn>
79
+
80
+ <slot name="form-after" :state="state" :actions="actions" />
81
+ </v-form>
82
+
83
+ <slot name="footer-extra" :state="state" :actions="actions" />
84
+ </v-card-text>
85
+ </v-card>
86
+ </section>
87
+ </template>
88
+
89
+ <script setup>
90
+ import { computed } from "vue";
91
+ import { createComponentInteractionEmitter } from "@jskit-ai/kernel/client";
92
+ import {
93
+ normalizeObject,
94
+ normalizeOneOf
95
+ } from "@jskit-ai/kernel/shared/support/normalize";
96
+
97
+ const DEFAULT_COPY = Object.freeze({
98
+ title: "Profile",
99
+ previewSizePrefix: "Preview size:",
100
+ previewSizeSuffix: "px",
101
+ replaceAvatar: "Replace avatar",
102
+ removeAvatar: "Remove avatar",
103
+ selectedFilePrefix: "Selected file:",
104
+ displayName: "Display name",
105
+ email: "Email",
106
+ emailHint: "Managed by Supabase Auth",
107
+ saveProfile: "Save profile"
108
+ });
109
+
110
+ const props = defineProps({
111
+ state: {
112
+ type: Object,
113
+ required: true
114
+ },
115
+ actions: {
116
+ type: Object,
117
+ required: true
118
+ },
119
+ copy: {
120
+ type: Object,
121
+ default: () => ({})
122
+ },
123
+ variant: {
124
+ type: Object,
125
+ default: () => ({})
126
+ },
127
+ features: {
128
+ type: Object,
129
+ default: () => ({})
130
+ },
131
+ ui: {
132
+ type: Object,
133
+ default: () => ({})
134
+ }
135
+ });
136
+
137
+ const emit = defineEmits(["action:started", "action:succeeded", "action:failed", "interaction", "profile:submit", "avatar:replace", "avatar:remove"]);
138
+
139
+ const state = props.state;
140
+ const actions = props.actions;
141
+ const {
142
+ emitInteraction,
143
+ invokeAction
144
+ } = createComponentInteractionEmitter(emit);
145
+
146
+ const copyText = computed(() => ({
147
+ ...DEFAULT_COPY,
148
+ ...normalizeObject(props.copy)
149
+ }));
150
+
151
+ const resolvedVariant = computed(() => {
152
+ const variant = normalizeObject(props.variant);
153
+ return {
154
+ layout: normalizeOneOf(variant.layout, ["compact", "comfortable"], "comfortable"),
155
+ surface: normalizeOneOf(variant.surface, ["plain", "carded"], "carded"),
156
+ density: normalizeOneOf(variant.density, ["compact", "comfortable"], "comfortable"),
157
+ tone: normalizeOneOf(variant.tone, ["neutral", "emphasized"], "neutral")
158
+ };
159
+ });
160
+
161
+ const resolvedFeatures = computed(() => {
162
+ const features = normalizeObject(props.features);
163
+ return {
164
+ header: features.header !== false,
165
+ removeAvatar: features.removeAvatar !== false
166
+ };
167
+ });
168
+
169
+ const uiClasses = computed(() => {
170
+ const classes = normalizeObject(normalizeObject(props.ui).classes);
171
+ return {
172
+ root: String(classes.root || "").trim(),
173
+ card: String(classes.card || "").trim()
174
+ };
175
+ });
176
+
177
+ const uiTestIds = computed(() => {
178
+ const testIds = normalizeObject(normalizeObject(props.ui).testIds);
179
+ return {
180
+ root: String(testIds.root || "profile-client-element"),
181
+ card: String(testIds.card || "profile-client-card"),
182
+ submitButton: String(testIds.submitButton || "profile-submit-button"),
183
+ avatarReplaceButton: String(testIds.avatarReplaceButton || "profile-avatar-replace-button"),
184
+ avatarRemoveButton: String(testIds.avatarRemoveButton || "profile-avatar-remove-button")
185
+ };
186
+ });
187
+
188
+ const rootClasses = computed(() => {
189
+ const classes = [
190
+ "profile-client-element",
191
+ `profile-client-element--layout-${resolvedVariant.value.layout}`,
192
+ `profile-client-element--surface-${resolvedVariant.value.surface}`,
193
+ `profile-client-element--density-${resolvedVariant.value.density}`,
194
+ `profile-client-element--tone-${resolvedVariant.value.tone}`
195
+ ];
196
+ if (uiClasses.value.root) {
197
+ classes.push(uiClasses.value.root);
198
+ }
199
+ return classes;
200
+ });
201
+
202
+ async function onSubmitProfile() {
203
+ const payload = {
204
+ displayName: String(state.profileForm?.displayName || "").trim()
205
+ };
206
+ emit("profile:submit", payload);
207
+ emitInteraction("profile:submit", payload);
208
+ await invokeAction("submitProfile", payload, actions.submitProfile);
209
+ }
210
+
211
+ async function onAvatarReplace() {
212
+ emit("avatar:replace", {});
213
+ emitInteraction("avatar:replace");
214
+ await invokeAction("openAvatarEditor", {}, actions.openAvatarEditor);
215
+ }
216
+
217
+ async function onAvatarRemove() {
218
+ emit("avatar:remove", {});
219
+ emitInteraction("avatar:remove");
220
+ await invokeAction("submitAvatarDelete", {}, actions.submitAvatarDelete);
221
+ }
222
+ </script>
223
+
224
+ <style scoped>
225
+ .profile-client-element--layout-compact :deep(.v-card-item),
226
+ .profile-client-element--layout-compact :deep(.v-card-text) {
227
+ padding-block: 0.72rem;
228
+ }
229
+
230
+ .profile-client-element--surface-plain .profile-client-card {
231
+ box-shadow: none;
232
+ border-width: 0;
233
+ }
234
+
235
+ .profile-client-element--density-compact :deep(.v-field__input) {
236
+ min-height: 34px;
237
+ }
238
+
239
+ .profile-client-element--tone-emphasized :deep(.v-avatar) {
240
+ box-shadow: 0 0 0 2px rgba(var(--v-theme-primary), 0.18);
241
+ }
242
+ </style>
@@ -0,0 +1,39 @@
1
+ <script setup>
2
+ import { computed } from "vue";
3
+ import { useSurfaceRouteContext } from "../composables/useSurfaceRouteContext.js";
4
+ import { resolveProfileSurfaceMenuLinks } from "../lib/profileSurfaceMenuLinks.js";
5
+
6
+ const props = defineProps({
7
+ surface: {
8
+ type: String,
9
+ default: "*"
10
+ }
11
+ });
12
+
13
+ const { placementContext, currentSurfaceId } = useSurfaceRouteContext();
14
+
15
+ const resolvedSurfaceId = computed(() => {
16
+ const explicitSurface = String(props.surface || "").trim().toLowerCase();
17
+ if (explicitSurface && explicitSurface !== "*") {
18
+ return explicitSurface;
19
+ }
20
+ return String(currentSurfaceId.value || "").trim().toLowerCase() || "*";
21
+ });
22
+
23
+ const resolvedLinks = computed(() => {
24
+ return resolveProfileSurfaceMenuLinks({
25
+ context: placementContext.value,
26
+ surface: resolvedSurfaceId.value
27
+ });
28
+ });
29
+ </script>
30
+
31
+ <template>
32
+ <v-list-item
33
+ v-for="link in resolvedLinks"
34
+ :key="link.id"
35
+ :title="link.label"
36
+ :to="link.to"
37
+ :prepend-icon="link.icon"
38
+ />
39
+ </template>