@jskit-ai/workspaces-web 0.1.13 → 0.1.15

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