@jskit-ai/workspaces-web 0.1.82 → 0.1.84

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.
@@ -1,10 +1,11 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/workspaces-web",
4
- version: "0.1.82",
4
+ version: "0.1.84",
5
5
  kind: "runtime",
6
6
  description: "Workspace web module: workspace selector, tools widget, workspace surfaces, members UI, and settings hosts.",
7
7
  dependsOn: [
8
+ "@jskit-ai/auth-web",
8
9
  "@jskit-ai/workspaces-core",
9
10
  "@jskit-ai/users-web"
10
11
  ],
@@ -41,6 +42,10 @@ export default Object.freeze({
41
42
  subpath: "./client/components/AccountSettingsInvitesSection",
42
43
  summary: "Exports the default account invites section component used by multihoming installs."
43
44
  },
45
+ {
46
+ subpath: "./client/components/WorkspaceInviteLanding",
47
+ summary: "Exports the default public workspace invite landing component."
48
+ },
44
49
  {
45
50
  subpath: "./client/providers/WorkspacesWebClientProvider",
46
51
  summary: "Exports workspaces-web client provider class."
@@ -227,8 +232,9 @@ export default Object.freeze({
227
232
  mutations: {
228
233
  dependencies: {
229
234
  runtime: {
230
- "@jskit-ai/workspaces-core": "0.1.81",
231
- "@jskit-ai/users-web": "0.1.120"
235
+ "@jskit-ai/auth-web": "0.1.108",
236
+ "@jskit-ai/workspaces-core": "0.1.83",
237
+ "@jskit-ai/users-web": "0.1.122"
232
238
  },
233
239
  dev: {}
234
240
  },
@@ -256,6 +262,13 @@ export default Object.freeze({
256
262
  category: "workspaces-web",
257
263
  id: "users-web-main-component-account-settings-invites-section"
258
264
  },
265
+ {
266
+ from: "templates/src/pages/invite/[token].vue",
267
+ to: "src/pages/invite/[token].vue",
268
+ reason: "Install public workspace invite acceptance route scaffold.",
269
+ category: "workspaces-web",
270
+ id: "workspaces-web-page-public-invite"
271
+ },
259
272
  {
260
273
  from: "templates/src/components/WorkspaceNotFoundCard.vue",
261
274
  to: "src/components/WorkspaceNotFoundCard.vue",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/workspaces-web",
3
- "version": "0.1.82",
3
+ "version": "0.1.84",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -8,18 +8,20 @@
8
8
  "exports": {
9
9
  "./client": "./src/client/index.js",
10
10
  "./client/components/AccountSettingsInvitesSection": "./src/client/components/AccountSettingsInvitesSection.vue",
11
+ "./client/components/WorkspaceInviteLanding": "./src/client/components/WorkspaceInviteLanding.vue",
11
12
  "./client/providers/WorkspacesWebClientProvider": "./src/client/providers/WorkspacesWebClientProvider.js",
12
13
  "./client/components/WorkspaceMembersClientElement": "./src/client/components/WorkspaceMembersClientElement.vue",
13
14
  "./client/composables/useWorkspaceRouteContext": "./src/client/composables/useWorkspaceRouteContext.js"
14
15
  },
15
16
  "dependencies": {
16
17
  "@mdi/js": "^7.4.47",
17
- "@jskit-ai/http-runtime": "0.1.104",
18
- "@jskit-ai/kernel": "0.1.106",
19
- "@jskit-ai/shell-web": "0.1.105",
20
- "@jskit-ai/users-core": "0.1.115",
21
- "@jskit-ai/users-web": "0.1.120",
22
- "@jskit-ai/workspaces-core": "0.1.81"
18
+ "@jskit-ai/auth-web": "0.1.108",
19
+ "@jskit-ai/http-runtime": "0.1.106",
20
+ "@jskit-ai/kernel": "0.1.108",
21
+ "@jskit-ai/shell-web": "0.1.107",
22
+ "@jskit-ai/users-core": "0.1.117",
23
+ "@jskit-ai/users-web": "0.1.122",
24
+ "@jskit-ai/workspaces-core": "0.1.83"
23
25
  },
24
26
  "peerDependencies": {
25
27
  "@tanstack/vue-query": "^5.90.5",
@@ -63,6 +63,31 @@
63
63
  Send invite
64
64
  </v-btn>
65
65
  </v-form>
66
+
67
+ <div v-if="hasCreatedInviteUrl" class="invite-link-panel mt-4">
68
+ <v-divider class="mb-4" />
69
+ <p class="text-caption text-medium-emphasis mb-2">{{ inviteLinkStatusText }}</p>
70
+ <div class="invite-link-row">
71
+ <v-text-field
72
+ :model-value="createdInviteUrl"
73
+ label="Invite link"
74
+ variant="outlined"
75
+ density="compact"
76
+ readonly
77
+ hide-details
78
+ class="invite-link-field"
79
+ />
80
+ <v-btn
81
+ color="primary"
82
+ variant="tonal"
83
+ :prepend-icon="mdiContentCopy"
84
+ :loading="isCopyingInviteLink"
85
+ @click="onCopyInviteLink"
86
+ >
87
+ Copy
88
+ </v-btn>
89
+ </div>
90
+ </div>
66
91
  </template>
67
92
  </template>
68
93
  </v-card-text>
@@ -167,6 +192,7 @@
167
192
 
168
193
  <script setup>
169
194
  import { computed, toRefs, unref } from "vue";
195
+ import { mdiContentCopy } from "@mdi/js";
170
196
  import { formatDateTime as formatKernelDateTime } from "@jskit-ai/kernel/shared/support";
171
197
  import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
172
198
  import { requireBoolean, requireFunction, requireRecord } from "@jskit-ai/users-web/client/support/contractGuards";
@@ -226,6 +252,11 @@ const {
226
252
 
227
253
  const actionHandlers = Object.freeze({
228
254
  submitInvite: requireFunction(actions.value.submitInvite, "actions.submitInvite", "MembersAdminClientElement"),
255
+ copyCreatedInviteLink: requireFunction(
256
+ actions.value.copyCreatedInviteLink,
257
+ "actions.copyCreatedInviteLink",
258
+ "MembersAdminClientElement"
259
+ ),
229
260
  submitRevokeInvite: requireFunction(
230
261
  actions.value.submitRevokeInvite,
231
262
  "actions.submitRevokeInvite",
@@ -275,6 +306,11 @@ const canRevokeInvites = computed(() => Boolean(unref(permissions.value.canRevok
275
306
  const isCreatingInvite = computed(() => Boolean(unref(status.value.isCreatingInvite)));
276
307
  const isRevokingInvite = computed(() => Boolean(unref(status.value.isRevokingInvite)));
277
308
  const isRemovingMember = computed(() => Boolean(unref(status.value.isRemovingMember)));
309
+ const isCopyingInviteLink = computed(() => Boolean(unref(status.value.isCopyingInviteLink)));
310
+ const createdInviteUrl = computed(() => String(unref(status.value.createdInviteUrl) || "").trim());
311
+ const createdInviteDeliveryStatus = computed(() =>
312
+ String(unref(status.value.createdInviteDeliveryStatus) || "").trim().toLowerCase()
313
+ );
278
314
  const workspaceInvitePolicyLoaded = computed(() =>
279
315
  requireBoolean(status.value.hasLoadedWorkspaceSettings, "status.hasLoadedWorkspaceSettings", "MembersAdminClientElement")
280
316
  );
@@ -324,6 +360,18 @@ const workspaceInvitesEnabled = computed(() => Boolean(unref(workspaceForm.value
324
360
  const canShowInviteForm = computed(
325
361
  () => canInviteMembers.value && workspaceInvitesAvailable.value && workspaceInvitesEnabled.value
326
362
  );
363
+ const hasCreatedInviteUrl = computed(() => Boolean(createdInviteUrl.value));
364
+ const inviteLinkStatusText = computed(() => {
365
+ if (createdInviteDeliveryStatus.value === "sent") {
366
+ return "Invite sent. The link remains available here.";
367
+ }
368
+
369
+ if (createdInviteDeliveryStatus.value === "failed") {
370
+ return "Email delivery failed. Copy this link to share the invite.";
371
+ }
372
+
373
+ return "Copy this link to share the invite.";
374
+ });
327
375
 
328
376
  function formatDateTime(value) {
329
377
  if (typeof options.value.formatDateTime === "function") {
@@ -368,6 +416,14 @@ async function onSubmitInvite() {
368
416
  await actionHandlers.submitInvite();
369
417
  }
370
418
 
419
+ async function onCopyInviteLink() {
420
+ if (!hasCreatedInviteUrl.value) {
421
+ return;
422
+ }
423
+
424
+ await actionHandlers.copyCreatedInviteLink();
425
+ }
426
+
371
427
  async function onRevokeInvite(inviteId) {
372
428
  if (!canRevokeInvites.value) {
373
429
  return;
@@ -397,4 +453,16 @@ async function onRemoveMember(member) {
397
453
  .member-role-select {
398
454
  width: 160px;
399
455
  }
456
+
457
+ .invite-link-row {
458
+ display: flex;
459
+ flex-wrap: wrap;
460
+ gap: 12px;
461
+ align-items: flex-start;
462
+ }
463
+
464
+ .invite-link-field {
465
+ flex: 1 1 260px;
466
+ min-width: 0;
467
+ }
400
468
  </style>
@@ -0,0 +1,257 @@
1
+ <template>
2
+ <main class="workspace-invite-landing">
3
+ <v-container class="workspace-invite-landing__container">
4
+ <v-sheet class="workspace-invite-landing__panel" border rounded="lg">
5
+ <div v-if="isLoading" class="workspace-invite-landing__state">
6
+ <v-progress-circular color="primary" indeterminate />
7
+ </div>
8
+
9
+ <div v-else-if="loadError" class="workspace-invite-landing__state">
10
+ <h1 class="workspace-invite-landing__title">Invitation unavailable</h1>
11
+ <p class="text-body-2 text-medium-emphasis mb-5">{{ loadError }}</p>
12
+ <v-btn color="primary" variant="tonal" :loading="isRefreshing" @click="refreshInvite">Retry</v-btn>
13
+ </div>
14
+
15
+ <div v-else-if="isTerminal" class="workspace-invite-landing__state">
16
+ <h1 class="workspace-invite-landing__title">{{ terminalTitle }}</h1>
17
+ <p class="text-body-2 text-medium-emphasis mb-0">{{ terminalMessage }}</p>
18
+ </div>
19
+
20
+ <div v-else class="workspace-invite-landing__content">
21
+ <div class="workspace-invite-landing__header">
22
+ <v-avatar v-if="workspaceAvatarUrl" size="48" :image="workspaceAvatarUrl" />
23
+ <v-avatar v-else size="48" color="primary" variant="tonal">
24
+ {{ workspaceInitial }}
25
+ </v-avatar>
26
+ <div>
27
+ <h1 class="workspace-invite-landing__title">{{ workspaceName }}</h1>
28
+ <p class="text-body-2 text-medium-emphasis mb-0">
29
+ Invitation for {{ inviteEmailLabel }}
30
+ </p>
31
+ </div>
32
+ </div>
33
+
34
+ <v-alert v-if="emailMismatch" type="warning" variant="tonal" class="mb-5">
35
+ Sign in with {{ inviteEmailLabel }} to accept this invitation.
36
+ </v-alert>
37
+
38
+ <div v-if="!isAuthenticated" class="workspace-invite-landing__actions">
39
+ <v-btn color="primary" :href="registerUrl">Create account</v-btn>
40
+ <v-btn variant="outlined" color="secondary" :href="loginUrl">Sign in</v-btn>
41
+ </div>
42
+
43
+ <div v-else class="workspace-invite-landing__actions">
44
+ <v-btn
45
+ color="primary"
46
+ :disabled="emailMismatch || isRedeeming"
47
+ :loading="isRedeeming"
48
+ @click="acceptInvite"
49
+ >
50
+ Accept invitation
51
+ </v-btn>
52
+ </div>
53
+
54
+ <p v-if="redeemError" class="text-body-2 text-error mb-0">{{ redeemError }}</p>
55
+ </div>
56
+ </v-sheet>
57
+ </v-container>
58
+ </main>
59
+ </template>
60
+
61
+ <script setup>
62
+ import { computed, onMounted } from "vue";
63
+ import { useRoute, useRouter } from "vue-router";
64
+ import { useAuthStore } from "@jskit-ai/auth-web/client";
65
+ import { useEndpointResource } from "@jskit-ai/users-web/client/composables/useEndpointResource";
66
+ import {
67
+ WORKSPACE_INVITATION_RESOLVE_TRANSPORT,
68
+ WORKSPACE_INVITE_REDEEM_TRANSPORT
69
+ } from "@jskit-ai/workspaces-core/shared/jsonApiTransports";
70
+ import { workspacesWebHttpClient } from "../lib/httpClient.js";
71
+
72
+ const route = useRoute();
73
+ const router = useRouter();
74
+ const authStore = useAuthStore();
75
+
76
+ const token = computed(() => {
77
+ const value = route.params?.token || route.query?.token || "";
78
+ return String(Array.isArray(value) ? value[0] : value).trim();
79
+ });
80
+ const invitePath = computed(() => `/invite/${encodeURIComponent(token.value)}`);
81
+ const inviteQuery = computed(() => ({ token: token.value }));
82
+
83
+ const inviteResource = useEndpointResource({
84
+ queryKey: computed(() => ["workspaces-web", "invite-landing", token.value]),
85
+ path: "/api/workspace/invitations/resolve",
86
+ enabled: computed(() => Boolean(token.value)),
87
+ client: workspacesWebHttpClient,
88
+ readQuery: inviteQuery,
89
+ transport: WORKSPACE_INVITATION_RESOLVE_TRANSPORT,
90
+ fallbackLoadError: "Unable to load invitation."
91
+ });
92
+
93
+ const redeemResource = useEndpointResource({
94
+ queryKey: computed(() => ["workspaces-web", "invite-landing", "redeem", token.value]),
95
+ path: "/api/workspace/invitations/redeem",
96
+ enabled: false,
97
+ client: workspacesWebHttpClient,
98
+ writeMethod: "POST",
99
+ transport: WORKSPACE_INVITE_REDEEM_TRANSPORT,
100
+ fallbackSaveError: "Unable to accept invitation."
101
+ });
102
+
103
+ const invite = computed(() => inviteResource.data.value || {});
104
+ const workspace = computed(() => invite.value.workspace || {});
105
+ const status = computed(() => String(invite.value.status || "").trim().toLowerCase());
106
+ const isAuthenticated = computed(() => authStore.authenticated === true);
107
+ const authEmail = computed(() => String(authStore.authState?.email || "").trim().toLowerCase());
108
+ const inviteEmail = computed(() => String(invite.value.email || "").trim().toLowerCase());
109
+ const inviteEmailLabel = computed(() => invite.value.maskedEmail || inviteEmail.value || "the invited email");
110
+ const workspaceName = computed(() => String(workspace.value.name || "Workspace invitation").trim());
111
+ const workspaceAvatarUrl = computed(() => String(workspace.value.avatarUrl || "").trim());
112
+ const workspaceInitial = computed(() => workspaceName.value.slice(0, 1).toUpperCase() || "W");
113
+ const isLoading = computed(() => Boolean(inviteResource.isLoading.value));
114
+ const isRefreshing = computed(() => Boolean(inviteResource.isFetching.value));
115
+ const loadError = computed(() => inviteResource.loadError.value);
116
+ const isRedeeming = computed(() => Boolean(redeemResource.isSaving.value));
117
+ const redeemError = computed(() => redeemResource.saveError.value);
118
+ const isTerminal = computed(() => ["expired", "accepted", "revoked", "not_found"].includes(status.value));
119
+ const emailMismatch = computed(() =>
120
+ Boolean(isAuthenticated.value && inviteEmail.value && authEmail.value && inviteEmail.value !== authEmail.value)
121
+ );
122
+ const terminalTitle = computed(() => {
123
+ if (status.value === "expired") {
124
+ return "Invitation expired";
125
+ }
126
+ if (status.value === "accepted") {
127
+ return "Invitation already accepted";
128
+ }
129
+ if (status.value === "revoked") {
130
+ return "Invitation revoked";
131
+ }
132
+ return "Invitation unavailable";
133
+ });
134
+ const terminalMessage = computed(() => {
135
+ if (status.value === "expired") {
136
+ return "Ask a workspace admin to send a new invitation.";
137
+ }
138
+ if (status.value === "accepted") {
139
+ return "This invitation has already been used.";
140
+ }
141
+ if (status.value === "revoked") {
142
+ return "This invitation is no longer active.";
143
+ }
144
+ return "The invitation link is invalid or no longer exists.";
145
+ });
146
+ const loginUrl = computed(() => buildAuthUrl("login"));
147
+ const registerUrl = computed(() => buildAuthUrl("register"));
148
+
149
+ function buildAuthUrl(mode) {
150
+ const params = new URLSearchParams({
151
+ mode,
152
+ returnTo: invitePath.value,
153
+ invitationToken: token.value
154
+ });
155
+ if (inviteEmail.value) {
156
+ params.set("email", inviteEmail.value);
157
+ }
158
+ return `/auth/login?${params.toString()}`;
159
+ }
160
+
161
+ async function refreshInvite() {
162
+ await inviteResource.reload();
163
+ }
164
+
165
+ async function acceptInvite() {
166
+ if (!token.value || emailMismatch.value || isRedeeming.value) {
167
+ return;
168
+ }
169
+
170
+ await redeemResource.save({
171
+ token: token.value,
172
+ decision: "accept"
173
+ });
174
+ await authStore.refresh();
175
+ const workspaceSlug = String(workspace.value.slug || "").trim();
176
+ if (workspaceSlug) {
177
+ await router.replace(`/w/${encodeURIComponent(workspaceSlug)}`);
178
+ return;
179
+ }
180
+ await refreshInvite();
181
+ }
182
+
183
+ onMounted(() => {
184
+ void authStore.refresh();
185
+ });
186
+ </script>
187
+
188
+ <style scoped>
189
+ .workspace-invite-landing {
190
+ min-height: 100vh;
191
+ display: flex;
192
+ align-items: center;
193
+ background: rgb(var(--v-theme-surface));
194
+ }
195
+
196
+ .workspace-invite-landing__container {
197
+ max-width: 560px;
198
+ }
199
+
200
+ .workspace-invite-landing__panel {
201
+ padding: 28px;
202
+ border-radius: 8px;
203
+ }
204
+
205
+ .workspace-invite-landing__state {
206
+ min-height: 180px;
207
+ display: flex;
208
+ flex-direction: column;
209
+ justify-content: center;
210
+ align-items: flex-start;
211
+ }
212
+
213
+ .workspace-invite-landing__content {
214
+ display: grid;
215
+ gap: 20px;
216
+ }
217
+
218
+ .workspace-invite-landing__header {
219
+ display: flex;
220
+ align-items: center;
221
+ gap: 16px;
222
+ }
223
+
224
+ .workspace-invite-landing__title {
225
+ font-size: 1.35rem;
226
+ line-height: 1.25;
227
+ font-weight: 650;
228
+ margin: 0 0 4px;
229
+ }
230
+
231
+ .workspace-invite-landing__actions {
232
+ display: flex;
233
+ flex-wrap: wrap;
234
+ gap: 12px;
235
+ }
236
+
237
+ @media (max-width: 600px) {
238
+ .workspace-invite-landing {
239
+ align-items: stretch;
240
+ }
241
+
242
+ .workspace-invite-landing__container {
243
+ padding: 0;
244
+ }
245
+
246
+ .workspace-invite-landing__panel {
247
+ min-height: 100vh;
248
+ border-radius: 0;
249
+ border-inline: 0;
250
+ padding: 24px;
251
+ }
252
+
253
+ .workspace-invite-landing__actions > * {
254
+ flex: 1 1 100%;
255
+ }
256
+ }
257
+ </style>
@@ -80,6 +80,9 @@ const membersFeedback = useUiFeedback();
80
80
  const teamFeedback = useUiFeedback();
81
81
  const revokeInviteId = ref("");
82
82
  const removeMemberUserId = ref("");
83
+ const createdInviteUrl = ref("");
84
+ const createdInviteDeliveryStatus = ref("");
85
+ const isCopyingInviteLink = ref(false);
83
86
 
84
87
  const { route, currentSurfaceId, workspaceSlugFromRoute } = useWorkspaceRouteContext();
85
88
  const usersPaths = usePaths();
@@ -166,6 +169,8 @@ function resetViewState() {
166
169
  clearRoleOptions();
167
170
  revokeInviteId.value = "";
168
171
  removeMemberUserId.value = "";
172
+ createdInviteUrl.value = "";
173
+ createdInviteDeliveryStatus.value = "";
169
174
  }
170
175
 
171
176
  function toRoleTitle(roleSid) {
@@ -340,14 +345,14 @@ const inviteCreateCommand = useCommand({
340
345
  runPermissions: ["workspace.members.invite"],
341
346
  writeMethod: "POST",
342
347
  transport: WORKSPACE_INVITE_CREATE_TRANSPORT,
343
- fallbackRunError: "Unable to send invite.",
348
+ fallbackRunError: "Unable to create invite.",
349
+ suppressSuccessMessage: true,
344
350
  buildRawPayload: () => ({
345
351
  email: forms.invite.email,
346
352
  roleSid: forms.invite.roleSid
347
353
  }),
348
354
  messages: {
349
- success: "Invite sent.",
350
- error: "Unable to send invite."
355
+ error: "Unable to create invite."
351
356
  }
352
357
  });
353
358
 
@@ -416,6 +421,9 @@ const status = computed(() => {
416
421
  isCreatingInvite: Boolean(inviteCreateCommand.isRunning),
417
422
  isRevokingInvite: Boolean(revokeInviteCommand.isRunning),
418
423
  isRemovingMember: Boolean(memberRemoveCommand.isRunning),
424
+ isCopyingInviteLink: Boolean(isCopyingInviteLink.value),
425
+ createdInviteUrl: createdInviteUrl.value,
426
+ createdInviteDeliveryStatus: createdInviteDeliveryStatus.value,
419
427
  hasLoadedWorkspaceSettings: !canInviteMembers.value || !workspaceSettingsView.isLoading,
420
428
  hasLoadedMembersList: !canViewMembers.value || !workspaceMembersList.isInitialLoading,
421
429
  hasLoadedInviteList: !canViewMembers.value || !workspaceInvitesList.isInitialLoading,
@@ -470,6 +478,7 @@ async function refreshLoad() {
470
478
 
471
479
  const actions = Object.freeze({
472
480
  submitInvite,
481
+ copyCreatedInviteLink,
473
482
  submitRevokeInvite,
474
483
  submitMemberRoleUpdate,
475
484
  submitRemoveMember
@@ -579,17 +588,53 @@ async function submitInvite() {
579
588
  }
580
589
 
581
590
  inviteFeedback.clear();
591
+ createdInviteUrl.value = "";
592
+ createdInviteDeliveryStatus.value = "";
582
593
 
583
594
  try {
584
- await inviteCreateCommand.run();
595
+ const result = await inviteCreateCommand.run();
596
+ const delivery = result?.inviteDelivery && typeof result.inviteDelivery === "object"
597
+ ? result.inviteDelivery
598
+ : {};
599
+ createdInviteUrl.value = String(result?.inviteUrl || "").trim();
600
+ createdInviteDeliveryStatus.value = String(delivery.status || "").trim().toLowerCase();
585
601
  forms.invite.email = "";
586
602
  await Promise.all([
587
603
  workspaceInvitesList.reload(),
588
604
  workspaceRolesView.refresh()
589
605
  ]);
590
- inviteFeedback.success("Invite sent.");
606
+ if (createdInviteDeliveryStatus.value === "sent") {
607
+ inviteFeedback.success("Invite sent.");
608
+ } else if (createdInviteDeliveryStatus.value === "failed") {
609
+ inviteFeedback.success("Invite created, but email delivery failed. Copy the invite link to share it.");
610
+ } else {
611
+ inviteFeedback.success("Invite created. Copy the invite link to share it.");
612
+ }
591
613
  } catch (error) {
592
- inviteFeedback.error(error, "Unable to send invite.");
614
+ inviteFeedback.error(error, "Unable to create invite.");
615
+ }
616
+ }
617
+
618
+ async function copyCreatedInviteLink() {
619
+ const inviteUrl = String(createdInviteUrl.value || "").trim();
620
+ if (!inviteUrl || isCopyingInviteLink.value) {
621
+ return;
622
+ }
623
+
624
+ isCopyingInviteLink.value = true;
625
+ try {
626
+ const clipboard = globalThis?.navigator?.clipboard;
627
+ if (!clipboard || typeof clipboard.writeText !== "function") {
628
+ inviteFeedback.success("Invite link ready to copy.");
629
+ return;
630
+ }
631
+
632
+ await clipboard.writeText(inviteUrl);
633
+ inviteFeedback.success("Invite link copied.");
634
+ } catch (error) {
635
+ inviteFeedback.error(error, "Unable to copy invite link.");
636
+ } finally {
637
+ isCopyingInviteLink.value = false;
593
638
  }
594
639
  }
595
640
 
@@ -4,4 +4,5 @@ const clientProviders = Object.freeze([WorkspacesWebClientProvider]);
4
4
 
5
5
  export { WorkspacesWebClientProvider } from "./providers/WorkspacesWebClientProvider.js";
6
6
  export { default as WorkspaceMembersClientElement } from "./components/WorkspaceMembersClientElement.vue";
7
+ export { default as WorkspaceInviteLanding } from "./components/WorkspaceInviteLanding.vue";
7
8
  export { clientProviders };
@@ -0,0 +1,7 @@
1
+ <template>
2
+ <WorkspaceInviteLanding />
3
+ </template>
4
+
5
+ <script setup>
6
+ import WorkspaceInviteLanding from "@jskit-ai/workspaces-web/client/components/WorkspaceInviteLanding";
7
+ </script>
@@ -16,6 +16,7 @@ test("workspaces-web exports are explicit and aligned with production usage", ()
16
16
  requiredExports: [
17
17
  "./client",
18
18
  "./client/components/AccountSettingsInvitesSection",
19
+ "./client/components/WorkspaceInviteLanding",
19
20
  "./client/providers/WorkspacesWebClientProvider",
20
21
  "./client/composables/useWorkspaceRouteContext"
21
22
  ]
@@ -105,6 +105,27 @@ test("workspaces-web installs an app-owned account invites section wrapper", asy
105
105
  });
106
106
  });
107
107
 
108
+ test("workspaces-web installs a public workspace invite route scaffold", async () => {
109
+ const source = await readFile(
110
+ path.join(PACKAGE_DIR, "templates", "src", "pages", "invite", "[token].vue"),
111
+ "utf8"
112
+ );
113
+
114
+ assert.doesNotMatch(source, /\bfetch\s*\(/);
115
+ assert.match(
116
+ source,
117
+ /@jskit-ai\/workspaces-web\/client\/components\/WorkspaceInviteLanding/
118
+ );
119
+ assert.match(source, /<WorkspaceInviteLanding \/>/);
120
+ assert.deepEqual(findFileMutation("workspaces-web-page-public-invite"), {
121
+ from: "templates/src/pages/invite/[token].vue",
122
+ to: "src/pages/invite/[token].vue",
123
+ reason: "Install public workspace invite acceptance route scaffold.",
124
+ category: "workspaces-web",
125
+ id: "workspaces-web-page-public-invite"
126
+ });
127
+ });
128
+
108
129
  test("workspaces-web settings components use direct panels instead of card scaffolds", async () => {
109
130
  for (const relativePath of [
110
131
  path.join("templates", "src", "components", "WorkspaceNotFoundCard.vue"),