@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,514 @@
1
+ <script setup>
2
+ import { computed, reactive, ref, watch } from "vue";
3
+ import { useRoute, useRouter } from "vue-router";
4
+ import {
5
+ useWebPlacementContext,
6
+ resolveSurfaceNavigationTargetFromPlacementContext
7
+ } from "@jskit-ai/shell-web/client/placement";
8
+ import { useShellWebErrorRuntime } from "@jskit-ai/shell-web/client/error";
9
+ import { normalizeWorkspaceList } from "../lib/bootstrap.js";
10
+ import { useCommand } from "../composables/useCommand.js";
11
+ import { useView } from "../composables/useView.js";
12
+ import { usePaths } from "../composables/usePaths.js";
13
+ import { useRealtimeQueryInvalidation } from "../composables/useRealtimeQueryInvalidation.js";
14
+ import { useWorkspaceSurfaceId } from "../composables/useWorkspaceSurfaceId.js";
15
+ import {
16
+ WORKSPACE_SETTINGS_CHANGED_EVENT,
17
+ WORKSPACES_CHANGED_EVENT,
18
+ WORKSPACE_PENDING_INVITATIONS_CHANGED_EVENT
19
+ } from "@jskit-ai/users-core/shared/events/usersEvents";
20
+ import { USERS_ROUTE_VISIBILITY_PUBLIC } from "@jskit-ai/users-core/shared/support/usersVisibility";
21
+ import { normalizePendingInvite } from "../composables/accountSettingsRuntimeHelpers.js";
22
+
23
+ const route = useRoute();
24
+ const router = useRouter();
25
+ const { context: placementContext } = useWebPlacementContext();
26
+ const paths = usePaths();
27
+ const errorRuntime = useShellWebErrorRuntime();
28
+
29
+ const selectingWorkspaceSlug = ref("");
30
+ const bootstrapModel = reactive({
31
+ sessionAuthenticated: false,
32
+ tenancyMode: "none",
33
+ workspaceAllowSelfCreate: false,
34
+ workspaceInvitesEnabled: false,
35
+ workspaces: [],
36
+ pendingInvites: []
37
+ });
38
+ const inviteAction = ref({
39
+ token: "",
40
+ decision: ""
41
+ });
42
+ const createWorkspaceModel = reactive({
43
+ name: "",
44
+ slug: ""
45
+ });
46
+ const redeemInviteModel = reactive({
47
+ token: "",
48
+ decision: ""
49
+ });
50
+ const bootstrapQueryKey = Object.freeze(["users-web", "bootstrap", "__none__"]);
51
+ const OWNERSHIP_PUBLIC = USERS_ROUTE_VISIBILITY_PUBLIC;
52
+
53
+ const bootstrapView = useView({
54
+ ownershipFilter: OWNERSHIP_PUBLIC,
55
+ apiSuffix: "/bootstrap",
56
+ queryKeyFactory: () => bootstrapQueryKey,
57
+ realtime: {
58
+ event: WORKSPACE_SETTINGS_CHANGED_EVENT
59
+ },
60
+ fallbackLoadError: "Unable to load workspaces.",
61
+ model: bootstrapModel,
62
+ mapLoadedToModel: (model, payload = {}) => {
63
+ model.sessionAuthenticated = Boolean(payload?.session?.authenticated);
64
+ model.tenancyMode = String(payload?.tenancy?.mode || "").trim().toLowerCase() || "none";
65
+ model.workspaceAllowSelfCreate = payload?.tenancy?.workspace?.allowSelfCreate === true;
66
+ model.workspaceInvitesEnabled = payload?.app?.features?.workspaceInvites === true;
67
+ model.workspaces = normalizeWorkspaceList(payload?.workspaces);
68
+ model.pendingInvites = model.workspaceInvitesEnabled
69
+ ? (Array.isArray(payload?.pendingInvites) ? payload.pendingInvites : [])
70
+ .map(normalizePendingInvite)
71
+ .filter(Boolean)
72
+ : [];
73
+ }
74
+ });
75
+
76
+ const redeemInviteCommand = useCommand({
77
+ ownershipFilter: OWNERSHIP_PUBLIC,
78
+ apiSuffix: "/workspace/invitations/redeem",
79
+ writeMethod: "POST",
80
+ fallbackRunError: "Unable to respond to invitation.",
81
+ suppressSuccessMessage: true,
82
+ model: redeemInviteModel,
83
+ buildRawPayload: (model) => ({
84
+ token: String(model.token || "").trim(),
85
+ decision: String(model.decision || "").trim().toLowerCase()
86
+ }),
87
+ messages: {
88
+ error: "Unable to respond to invitation."
89
+ }
90
+ });
91
+
92
+ const createWorkspaceCommand = useCommand({
93
+ ownershipFilter: OWNERSHIP_PUBLIC,
94
+ apiSuffix: "/workspaces",
95
+ writeMethod: "POST",
96
+ fallbackRunError: "Unable to create workspace.",
97
+ suppressSuccessMessage: true,
98
+ model: createWorkspaceModel,
99
+ buildRawPayload: (model) => {
100
+ const payload = {
101
+ name: String(model.name || "").trim()
102
+ };
103
+ const slug = String(model.slug || "").trim().toLowerCase();
104
+ if (slug) {
105
+ payload.slug = slug;
106
+ }
107
+ return payload;
108
+ },
109
+ messages: {
110
+ error: "Unable to create workspace."
111
+ }
112
+ });
113
+
114
+ useRealtimeQueryInvalidation({
115
+ event: WORKSPACES_CHANGED_EVENT,
116
+ queryKey: bootstrapQueryKey
117
+ });
118
+
119
+ useRealtimeQueryInvalidation({
120
+ event: WORKSPACE_PENDING_INVITATIONS_CHANGED_EVENT,
121
+ queryKey: bootstrapQueryKey
122
+ });
123
+
124
+ const workspaceItems = computed(() => {
125
+ return Array.isArray(bootstrapModel.workspaces) ? bootstrapModel.workspaces : [];
126
+ });
127
+
128
+ const pendingInvites = computed(() => {
129
+ return Array.isArray(bootstrapModel.pendingInvites) ? bootstrapModel.pendingInvites : [];
130
+ });
131
+ const workspaceInvitesEnabled = computed(() => bootstrapModel.workspaceInvitesEnabled === true);
132
+
133
+ const isBootstrapping = computed(() => Boolean(bootstrapView.isLoading.value));
134
+ const isRefreshingBootstrap = computed(() => Boolean(bootstrapView.isRefetching.value));
135
+ const canCreateWorkspace = computed(() => bootstrapModel.workspaceAllowSelfCreate === true);
136
+ const isCreatingWorkspace = computed(() => Boolean(createWorkspaceCommand.isRunning.value));
137
+
138
+ function reportFeedback({
139
+ message,
140
+ severity = "error",
141
+ channel = "banner",
142
+ dedupeKey = ""
143
+ } = {}) {
144
+ const normalizedMessage = String(message || "").trim();
145
+ if (!normalizedMessage) {
146
+ return;
147
+ }
148
+
149
+ errorRuntime.report({
150
+ source: "users-web.workspaces-view",
151
+ message: normalizedMessage,
152
+ severity,
153
+ channel,
154
+ dedupeKey: dedupeKey || `users-web.workspaces-view:${severity}:${normalizedMessage}`,
155
+ dedupeWindowMs: 3000
156
+ });
157
+ }
158
+
159
+ const { workspaceSurfaceId } = useWorkspaceSurfaceId({
160
+ route,
161
+ placementContext
162
+ });
163
+
164
+ function workspaceInitials(workspace) {
165
+ const source = String(workspace?.name || workspace?.slug || "W").trim();
166
+ return source.slice(0, 2).toUpperCase();
167
+ }
168
+
169
+ function workspaceAvatarStyle(workspace) {
170
+ const color = String(workspace?.color || "").trim();
171
+ if (!/^#[0-9a-fA-F]{6}$/.test(color)) {
172
+ return {};
173
+ }
174
+
175
+ return {
176
+ backgroundColor: color
177
+ };
178
+ }
179
+
180
+ function workspaceHomePath(workspaceSlug) {
181
+ const normalizedSlug = String(workspaceSlug || "").trim();
182
+ if (!normalizedSlug || !workspaceSurfaceId.value) {
183
+ return "";
184
+ }
185
+
186
+ return paths.page("/", {
187
+ surface: workspaceSurfaceId.value,
188
+ workspaceSlug: normalizedSlug,
189
+ mode: "workspace"
190
+ });
191
+ }
192
+
193
+ async function openWorkspace(workspaceSlug) {
194
+ const normalizedSlug = String(workspaceSlug || "").trim();
195
+ if (!normalizedSlug) {
196
+ return;
197
+ }
198
+
199
+ const targetPath = workspaceHomePath(normalizedSlug);
200
+ if (!targetPath) {
201
+ reportFeedback({
202
+ message: "Workspace surface is not configured.",
203
+ severity: "error",
204
+ channel: "banner",
205
+ dedupeKey: "users-web.workspaces-view:workspace-surface-missing"
206
+ });
207
+ return;
208
+ }
209
+
210
+ selectingWorkspaceSlug.value = normalizedSlug;
211
+
212
+ try {
213
+ const navigationTarget = resolveSurfaceNavigationTargetFromPlacementContext(placementContext.value, {
214
+ path: targetPath,
215
+ surfaceId: workspaceSurfaceId.value
216
+ });
217
+ if (navigationTarget.sameOrigin && router && typeof router.push === "function") {
218
+ await router.push(navigationTarget.href);
219
+ } else if (typeof window === "object" && window?.location && typeof window.location.assign === "function") {
220
+ window.location.assign(navigationTarget.href);
221
+ return;
222
+ } else {
223
+ throw new Error("Workspace navigation is unavailable.");
224
+ }
225
+ } catch (error) {
226
+ reportFeedback({
227
+ message: String(error?.message || "Unable to open workspace."),
228
+ severity: "error",
229
+ channel: "banner",
230
+ dedupeKey: `users-web.workspaces-view:open-workspace:${normalizedSlug}`
231
+ });
232
+ } finally {
233
+ selectingWorkspaceSlug.value = "";
234
+ }
235
+ }
236
+
237
+ async function respondToInvite(invite, decision) {
238
+ if (!workspaceInvitesEnabled.value) {
239
+ return;
240
+ }
241
+
242
+ const token = String(invite?.token || "").trim();
243
+ const normalizedDecision = String(decision || "").trim().toLowerCase();
244
+ if (!token || (normalizedDecision !== "accept" && normalizedDecision !== "refuse")) {
245
+ return;
246
+ }
247
+
248
+ inviteAction.value = {
249
+ token,
250
+ decision: normalizedDecision
251
+ };
252
+ redeemInviteModel.token = token;
253
+ redeemInviteModel.decision = normalizedDecision;
254
+
255
+ try {
256
+ await redeemInviteCommand.run();
257
+
258
+ bootstrapModel.pendingInvites = pendingInvites.value.filter((entry) => entry.token !== token);
259
+ await bootstrapView.refresh();
260
+
261
+ if (normalizedDecision === "accept") {
262
+ const nextWorkspaceSlug = String(invite?.workspaceSlug || "").trim();
263
+ if (nextWorkspaceSlug) {
264
+ await openWorkspace(nextWorkspaceSlug);
265
+ }
266
+ return;
267
+ }
268
+
269
+ reportFeedback({
270
+ message: "Invitation refused.",
271
+ severity: "success",
272
+ channel: "snackbar",
273
+ dedupeKey: `users-web.workspaces-view:invite-refused:${token}`
274
+ });
275
+ } catch (error) {
276
+ reportFeedback({
277
+ message: String(
278
+ error?.message || (normalizedDecision === "accept" ? "Unable to accept invite." : "Unable to refuse invite.")
279
+ ),
280
+ severity: "error",
281
+ channel: "banner",
282
+ dedupeKey: `users-web.workspaces-view:invite-${normalizedDecision}:${token}`
283
+ });
284
+ } finally {
285
+ inviteAction.value = {
286
+ token: "",
287
+ decision: ""
288
+ };
289
+ redeemInviteModel.token = "";
290
+ redeemInviteModel.decision = "";
291
+ }
292
+ }
293
+
294
+ function acceptInvite(invite) {
295
+ return respondToInvite(invite, "accept");
296
+ }
297
+
298
+ function refuseInvite(invite) {
299
+ return respondToInvite(invite, "refuse");
300
+ }
301
+
302
+ async function createWorkspace() {
303
+ if (!canCreateWorkspace.value) {
304
+ return;
305
+ }
306
+
307
+ const name = String(createWorkspaceModel.name || "").trim();
308
+ if (!name) {
309
+ reportFeedback({
310
+ message: "Workspace name is required.",
311
+ severity: "error",
312
+ channel: "banner",
313
+ dedupeKey: "users-web.workspaces-view:create-workspace-name-required"
314
+ });
315
+ return;
316
+ }
317
+
318
+ try {
319
+ const createdWorkspace = await createWorkspaceCommand.run();
320
+ await bootstrapView.refresh();
321
+ const createdSlug = String(createdWorkspace?.slug || "").trim();
322
+ const autoOpenHandledByWatcher = workspaceItems.value.length === 1 && pendingInvites.value.length < 1;
323
+ if (createdSlug && !autoOpenHandledByWatcher) {
324
+ await openWorkspace(createdSlug);
325
+ }
326
+ createWorkspaceModel.name = "";
327
+ createWorkspaceModel.slug = "";
328
+ } catch (error) {
329
+ reportFeedback({
330
+ message: String(error?.message || "Unable to create workspace."),
331
+ severity: "error",
332
+ channel: "banner",
333
+ dedupeKey: "users-web.workspaces-view:create-workspace-error"
334
+ });
335
+ }
336
+ }
337
+
338
+ watch(
339
+ () => bootstrapView.resource.data.value,
340
+ async (payload) => {
341
+ if (!payload) {
342
+ return;
343
+ }
344
+
345
+ if (!bootstrapModel.sessionAuthenticated) {
346
+ const loginTarget = resolveSurfaceNavigationTargetFromPlacementContext(placementContext.value, {
347
+ path: "/auth/login",
348
+ surfaceId: "auth"
349
+ });
350
+ if (loginTarget.sameOrigin && router && typeof router.replace === "function") {
351
+ await router.replace(loginTarget.href);
352
+ } else if (typeof window === "object" && window?.location && typeof window.location.assign === "function") {
353
+ window.location.assign(loginTarget.href);
354
+ }
355
+ return;
356
+ }
357
+
358
+ if (workspaceItems.value.length === 1 && pendingInvites.value.length < 1) {
359
+ await openWorkspace(workspaceItems.value[0].slug);
360
+ }
361
+ },
362
+ {
363
+ immediate: true
364
+ }
365
+ );
366
+
367
+ </script>
368
+
369
+ <template>
370
+ <section class="workspaces-view py-6">
371
+ <v-container class="mx-auto" max-width="860">
372
+ <v-card rounded="lg" border elevation="1">
373
+ <v-card-item>
374
+ <v-card-title class="text-h6">You are logged in</v-card-title>
375
+ <v-card-subtitle>Select a workspace or respond to invitations.</v-card-subtitle>
376
+ </v-card-item>
377
+ <v-divider />
378
+
379
+ <v-card-text class="pt-4">
380
+ <v-progress-linear v-if="!isBootstrapping && isRefreshingBootstrap" indeterminate class="mb-4" />
381
+ <v-row>
382
+ <v-col cols="12" :md="workspaceInvitesEnabled ? 6 : 12">
383
+ <template v-if="isBootstrapping">
384
+ <v-skeleton-loader type="text, list-item-avatar-two-line@3" />
385
+ </template>
386
+ <template v-else>
387
+ <div class="text-subtitle-2 mb-2">Your workspaces</div>
388
+ <template v-if="workspaceItems.length === 0">
389
+ <p class="text-body-1 mb-2">You do not have a workspace yet.</p>
390
+ <template v-if="canCreateWorkspace">
391
+ <v-text-field
392
+ v-model="createWorkspaceModel.name"
393
+ density="comfortable"
394
+ label="Workspace name"
395
+ variant="outlined"
396
+ hide-details
397
+ class="mb-2"
398
+ />
399
+ <v-text-field
400
+ v-model="createWorkspaceModel.slug"
401
+ density="comfortable"
402
+ label="Slug (optional)"
403
+ variant="outlined"
404
+ hide-details
405
+ class="mb-3"
406
+ />
407
+ <v-btn
408
+ color="primary"
409
+ variant="tonal"
410
+ :loading="isCreatingWorkspace"
411
+ @click="createWorkspace"
412
+ >
413
+ Create Workspace
414
+ </v-btn>
415
+ </template>
416
+ <p v-else class="text-body-2 text-medium-emphasis mb-0">
417
+ Ask an administrator for an invite, or create one after policy is enabled.
418
+ </p>
419
+ </template>
420
+
421
+ <template v-else>
422
+ <v-list density="comfortable" class="pa-0">
423
+ <v-list-item
424
+ v-for="workspace in workspaceItems"
425
+ :key="workspace.id"
426
+ :title="workspace.name"
427
+ :subtitle="
428
+ workspace.isAccessible
429
+ ? `/${workspace.slug} • role: ${workspace.roleId || 'member'}`
430
+ : `/${workspace.slug} • unavailable on this surface`
431
+ "
432
+ class="px-0"
433
+ >
434
+ <template #prepend>
435
+ <v-avatar :style="workspaceAvatarStyle(workspace)" size="28">
436
+ <v-img v-if="workspace.avatarUrl" :src="workspace.avatarUrl" cover />
437
+ <span v-else class="text-caption">{{ workspaceInitials(workspace) }}</span>
438
+ </v-avatar>
439
+ </template>
440
+ <template #append>
441
+ <v-btn
442
+ color="primary"
443
+ size="small"
444
+ variant="tonal"
445
+ :disabled="!workspace.isAccessible"
446
+ :loading="selectingWorkspaceSlug === workspace.slug"
447
+ @click="openWorkspace(workspace.slug)"
448
+ >
449
+ {{ workspace.isAccessible ? "Open" : "Unavailable" }}
450
+ </v-btn>
451
+ </template>
452
+ </v-list-item>
453
+ </v-list>
454
+ </template>
455
+ </template>
456
+ </v-col>
457
+
458
+ <v-col v-if="workspaceInvitesEnabled" cols="12" md="6">
459
+ <template v-if="isBootstrapping">
460
+ <v-skeleton-loader type="text, list-item-two-line@3" />
461
+ </template>
462
+ <template v-else>
463
+ <div class="text-subtitle-2 mb-2">Invitations</div>
464
+ <template v-if="pendingInvites.length === 0">
465
+ <p class="text-body-2 text-medium-emphasis mb-0">No pending invitations.</p>
466
+ </template>
467
+
468
+ <template v-else>
469
+ <v-list density="comfortable" class="pa-0">
470
+ <v-list-item
471
+ v-for="invite in pendingInvites"
472
+ :key="invite.id"
473
+ :title="invite.workspaceName"
474
+ :subtitle="`Role: ${invite.roleId}`"
475
+ class="px-0"
476
+ >
477
+ <template #prepend>
478
+ <v-avatar color="warning" size="28">
479
+ <span class="text-caption font-weight-bold">?</span>
480
+ </v-avatar>
481
+ </template>
482
+ <template #append>
483
+ <div class="d-flex ga-2">
484
+ <v-btn
485
+ size="small"
486
+ variant="text"
487
+ color="error"
488
+ :loading="inviteAction.token === invite.token && inviteAction.decision === 'refuse'"
489
+ @click="refuseInvite(invite)"
490
+ >
491
+ Refuse
492
+ </v-btn>
493
+ <v-btn
494
+ size="small"
495
+ variant="tonal"
496
+ color="primary"
497
+ :loading="inviteAction.token === invite.token && inviteAction.decision === 'accept'"
498
+ @click="acceptInvite(invite)"
499
+ >
500
+ Join
501
+ </v-btn>
502
+ </div>
503
+ </template>
504
+ </v-list-item>
505
+ </v-list>
506
+ </template>
507
+ </template>
508
+ </v-col>
509
+ </v-row>
510
+ </v-card-text>
511
+ </v-card>
512
+ </v-container>
513
+ </section>
514
+ </template>