@jskit-ai/workspaces-web 0.1.81 → 0.1.83
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.
- package/package.descriptor.mjs +16 -3
- package/package.json +9 -7
- package/src/client/components/MembersAdminClientElement.vue +68 -0
- package/src/client/components/WorkspaceInviteLanding.vue +257 -0
- package/src/client/components/WorkspaceMembersClientElement.vue +51 -6
- package/src/client/index.js +1 -0
- package/templates/src/pages/invite/[token].vue +7 -0
- package/test/exportsContract.test.js +1 -0
- package/test/settingsPlacementContract.test.js +21 -0
package/package.descriptor.mjs
CHANGED
|
@@ -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.
|
|
4
|
+
version: "0.1.83",
|
|
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/
|
|
231
|
-
"@jskit-ai/
|
|
235
|
+
"@jskit-ai/auth-web": "0.1.107",
|
|
236
|
+
"@jskit-ai/workspaces-core": "0.1.82",
|
|
237
|
+
"@jskit-ai/users-web": "0.1.121"
|
|
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.
|
|
3
|
+
"version": "0.1.83",
|
|
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/
|
|
18
|
-
"@jskit-ai/
|
|
19
|
-
"@jskit-ai/
|
|
20
|
-
"@jskit-ai/
|
|
21
|
-
"@jskit-ai/users-
|
|
22
|
-
"@jskit-ai/
|
|
18
|
+
"@jskit-ai/auth-web": "0.1.107",
|
|
19
|
+
"@jskit-ai/http-runtime": "0.1.105",
|
|
20
|
+
"@jskit-ai/kernel": "0.1.107",
|
|
21
|
+
"@jskit-ai/shell-web": "0.1.106",
|
|
22
|
+
"@jskit-ai/users-core": "0.1.116",
|
|
23
|
+
"@jskit-ai/users-web": "0.1.121",
|
|
24
|
+
"@jskit-ai/workspaces-core": "0.1.82"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/src/client/index.js
CHANGED
|
@@ -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 };
|
|
@@ -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"),
|