@jskit-ai/workspaces-web 0.1.212 → 0.1.213
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/fixtures/role-labels/index.html +2 -0
- package/fixtures/role-labels/main.js +37 -0
- package/fixtures/role-labels/vite.config.mjs +9 -0
- package/package.json +8 -8
- package/src/client/account-settings/useAccountSettingsInvitesSectionRuntime.js +1 -0
- package/src/client/components/AccountSettingsInvitesSection.vue +1 -1
- package/src/client/components/MembersAdminClientElement.vue +8 -2
- package/src/client/components/WorkspaceMembersClientElement.vue +16 -1
- package/src/client/components/WorkspacesClientElement.vue +3 -2
- package/src/client/lib/bootstrap.js +1 -0
- package/test/roleLabels.browser.test.js +114 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createApp, h } from "vue";
|
|
2
|
+
import { createRouter, createWebHistory, RouterView } from "vue-router";
|
|
3
|
+
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
|
|
4
|
+
import "vuetify/styles";
|
|
5
|
+
import { createVuetify } from "vuetify";
|
|
6
|
+
import * as components from "vuetify/components";
|
|
7
|
+
import * as directives from "vuetify/directives";
|
|
8
|
+
import { aliases, mdi } from "vuetify/iconsets/mdi-svg";
|
|
9
|
+
import WorkspaceMembersClientElement from "../../src/client/components/WorkspaceMembersClientElement.vue";
|
|
10
|
+
|
|
11
|
+
const context = {
|
|
12
|
+
permissions: ["*"],
|
|
13
|
+
surfaceConfig: {
|
|
14
|
+
tenancyMode: "workspaces",
|
|
15
|
+
defaultSurfaceId: "admin",
|
|
16
|
+
enabledSurfaceIds: ["admin"],
|
|
17
|
+
surfacesById: {
|
|
18
|
+
admin: { id: "admin", enabled: true, pagesRoot: "w/[workspaceSlug]/admin", routeBase: "/w/:workspaceSlug/admin", requiresWorkspace: true }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const router = createRouter({
|
|
23
|
+
history: createWebHistory(),
|
|
24
|
+
routes: [{ path: "/w/:workspaceSlug/admin/members", component: WorkspaceMembersClientElement }]
|
|
25
|
+
});
|
|
26
|
+
const app = createApp({ render: () => h(components.VApp, null, { default: () => h(components.VMain, null, { default: () => h(RouterView) }) }) });
|
|
27
|
+
app.provide("jskit.shell-web.runtime.web-placement.client", {
|
|
28
|
+
getContext: () => context,
|
|
29
|
+
getPlacements: () => [],
|
|
30
|
+
subscribe: () => () => {},
|
|
31
|
+
setContext: () => context
|
|
32
|
+
});
|
|
33
|
+
app.use(router);
|
|
34
|
+
app.use(VueQueryPlugin, { queryClient: new QueryClient({ defaultOptions: { queries: { retry: false } } }) });
|
|
35
|
+
app.use(createVuetify({ components, directives, icons: { defaultSet: "mdi", aliases, sets: { mdi } } }));
|
|
36
|
+
await router.isReady();
|
|
37
|
+
app.mount("#app");
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { defineConfig } from "vite";
|
|
3
|
+
import vue from "@vitejs/plugin-vue";
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
root: fileURLToPath(new URL("./", import.meta.url)),
|
|
7
|
+
plugins: [vue()],
|
|
8
|
+
server: { host: "127.0.0.1", strictPort: true }
|
|
9
|
+
});
|
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.213",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -14,16 +14,16 @@
|
|
|
14
14
|
"./client/composables/useWorkspaceRouteContext": "./src/client/composables/useWorkspaceRouteContext.js"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@jskit-ai/users-core": "0.1.
|
|
18
|
-
"@jskit-ai/workspaces-core": "0.1.
|
|
17
|
+
"@jskit-ai/users-core": "0.1.247",
|
|
18
|
+
"@jskit-ai/workspaces-core": "0.1.212",
|
|
19
19
|
"@mdi/js": "^7.4.47"
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|
|
22
|
-
"@jskit-ai/auth-web": "0.1.
|
|
23
|
-
"@jskit-ai/http-web": "0.1.
|
|
24
|
-
"@jskit-ai/kernel": "0.1.
|
|
25
|
-
"@jskit-ai/shell-web": "0.1.
|
|
26
|
-
"@jskit-ai/users-web": "0.1.
|
|
22
|
+
"@jskit-ai/auth-web": "0.1.234",
|
|
23
|
+
"@jskit-ai/http-web": "0.1.79",
|
|
24
|
+
"@jskit-ai/kernel": "0.1.234",
|
|
25
|
+
"@jskit-ai/shell-web": "0.1.238",
|
|
26
|
+
"@jskit-ai/users-web": "0.1.252",
|
|
27
27
|
"@tanstack/vue-query": "^5.90.5",
|
|
28
28
|
"vue": "^3.5.13",
|
|
29
29
|
"vue-router": "^5.0.4",
|
|
@@ -43,6 +43,7 @@ function normalizePendingInvite(entry) {
|
|
|
43
43
|
workspaceName: String(entry.workspaceName || workspaceSlug).trim() || workspaceSlug,
|
|
44
44
|
workspaceAvatarUrl: String(entry.workspaceAvatarUrl || "").trim(),
|
|
45
45
|
roleSid: String(entry.roleSid || "member").trim().toLowerCase() || "member",
|
|
46
|
+
roleLabel: typeof entry.roleLabel === "string" ? entry.roleLabel.trim() : "",
|
|
46
47
|
status: String(entry.status || "pending").trim().toLowerCase() || "pending",
|
|
47
48
|
expiresAt: String(entry.expiresAt || "").trim()
|
|
48
49
|
};
|
|
@@ -28,7 +28,7 @@ const invites = useAccountSettingsInvitesSectionRuntime();
|
|
|
28
28
|
v-for="invite in invites.items.value"
|
|
29
29
|
:key="invite.id"
|
|
30
30
|
:title="invite.workspaceName"
|
|
31
|
-
:subtitle="`/${invite.workspaceSlug} • role: ${invite.roleSid}`"
|
|
31
|
+
:subtitle="`/${invite.workspaceSlug} • role: ${invite.roleLabel || invite.roleSid}`"
|
|
32
32
|
class="px-0"
|
|
33
33
|
>
|
|
34
34
|
<template #prepend>
|
|
@@ -140,7 +140,9 @@
|
|
|
140
140
|
class="member-role-select"
|
|
141
141
|
:disabled="showMembersRefreshingIndicator || isMemberRoleLocked(member)"
|
|
142
142
|
@update:model-value="(value) => onMemberRoleUpdate(member, value)"
|
|
143
|
-
|
|
143
|
+
>
|
|
144
|
+
<template #selection="{ internalItem }">{{ formatRole(internalItem.value, internalItem.title) }}</template>
|
|
145
|
+
</v-select>
|
|
144
146
|
<v-btn
|
|
145
147
|
variant="text"
|
|
146
148
|
color="error"
|
|
@@ -164,7 +166,7 @@
|
|
|
164
166
|
{{ invite.email }}
|
|
165
167
|
</template>
|
|
166
168
|
<template #subtitle>
|
|
167
|
-
Role: {{ invite.roleSid }} • expires {{ formatDateTime(invite.expiresAt) }}
|
|
169
|
+
Role: {{ formatRole(invite.roleSid) }} • expires {{ formatDateTime(invite.expiresAt) }}
|
|
168
170
|
</template>
|
|
169
171
|
<template #append>
|
|
170
172
|
<v-btn
|
|
@@ -373,6 +375,10 @@ const inviteLinkStatusText = computed(() => {
|
|
|
373
375
|
return "Copy this link to share the invite.";
|
|
374
376
|
});
|
|
375
377
|
|
|
378
|
+
function formatRole(roleSid, fallback = roleSid) {
|
|
379
|
+
return typeof options.value.formatRole === "function" ? options.value.formatRole(roleSid, fallback) : fallback;
|
|
380
|
+
}
|
|
381
|
+
|
|
376
382
|
function formatDateTime(value) {
|
|
377
383
|
if (typeof options.value.formatDateTime === "function") {
|
|
378
384
|
return options.value.formatDateTime(value);
|
|
@@ -65,6 +65,10 @@ const forms = reactive({
|
|
|
65
65
|
const options = reactive({
|
|
66
66
|
inviteRoleOptions: [],
|
|
67
67
|
memberRoleOptions: [],
|
|
68
|
+
roleLabels: Object.create(null),
|
|
69
|
+
formatRole(roleSid, fallback = roleSid) {
|
|
70
|
+
return options.roleLabels[roleSid] || fallback;
|
|
71
|
+
},
|
|
68
72
|
formatDateTime(value) {
|
|
69
73
|
return formatDateTime(value);
|
|
70
74
|
}
|
|
@@ -156,6 +160,7 @@ function resetMessages() {
|
|
|
156
160
|
function clearRoleOptions() {
|
|
157
161
|
options.inviteRoleOptions = [];
|
|
158
162
|
options.memberRoleOptions = [];
|
|
163
|
+
options.roleLabels = Object.create(null);
|
|
159
164
|
}
|
|
160
165
|
|
|
161
166
|
function resetViewState() {
|
|
@@ -206,8 +211,16 @@ function normalizeRoleCatalog(payload = {}) {
|
|
|
206
211
|
}
|
|
207
212
|
|
|
208
213
|
const uniqueRoleIds = Array.from(new Set(assignableRoleIds));
|
|
214
|
+
const roleLabels = Object.create(null);
|
|
215
|
+
for (const role of roles) {
|
|
216
|
+
const id = String(role?.id || "").trim().toLowerCase();
|
|
217
|
+
const label = typeof role?.label === "string" ? role.label.trim() : "";
|
|
218
|
+
if (id && label) {
|
|
219
|
+
roleLabels[id] = label;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
209
222
|
const roleOptions = uniqueRoleIds.map((roleSid) => ({
|
|
210
|
-
title: toRoleTitle(roleSid),
|
|
223
|
+
title: roleLabels[roleSid] || toRoleTitle(roleSid),
|
|
211
224
|
value: roleSid
|
|
212
225
|
}));
|
|
213
226
|
|
|
@@ -217,12 +230,14 @@ function normalizeRoleCatalog(payload = {}) {
|
|
|
217
230
|
|
|
218
231
|
return {
|
|
219
232
|
roleOptions,
|
|
233
|
+
roleLabels,
|
|
220
234
|
defaultInviteRole
|
|
221
235
|
};
|
|
222
236
|
}
|
|
223
237
|
|
|
224
238
|
function applyRoleCatalog(payload = {}) {
|
|
225
239
|
const normalizedCatalog = normalizeRoleCatalog(payload);
|
|
240
|
+
options.roleLabels = normalizedCatalog.roleLabels;
|
|
226
241
|
options.inviteRoleOptions = [...normalizedCatalog.roleOptions];
|
|
227
242
|
options.memberRoleOptions = [...normalizedCatalog.roleOptions];
|
|
228
243
|
|
|
@@ -44,6 +44,7 @@ function normalizePendingInvite(entry) {
|
|
|
44
44
|
workspaceName: String(entry.workspaceName || workspaceSlug).trim() || workspaceSlug,
|
|
45
45
|
workspaceAvatarUrl: String(entry.workspaceAvatarUrl || "").trim(),
|
|
46
46
|
roleSid: String(entry.roleSid || "member").trim().toLowerCase() || "member",
|
|
47
|
+
roleLabel: typeof entry.roleLabel === "string" ? entry.roleLabel.trim() : "",
|
|
47
48
|
status: String(entry.status || "pending").trim().toLowerCase() || "pending",
|
|
48
49
|
expiresAt: String(entry.expiresAt || "").trim()
|
|
49
50
|
};
|
|
@@ -469,7 +470,7 @@ watch(
|
|
|
469
470
|
:title="workspace.name"
|
|
470
471
|
:subtitle="
|
|
471
472
|
workspace.isAccessible
|
|
472
|
-
? `/${workspace.slug} • role: ${workspace.roleSid || 'member'}`
|
|
473
|
+
? `/${workspace.slug} • role: ${workspace.roleLabel || workspace.roleSid || 'member'}`
|
|
473
474
|
: `/${workspace.slug} • unavailable on this surface`
|
|
474
475
|
"
|
|
475
476
|
class="px-0"
|
|
@@ -514,7 +515,7 @@ watch(
|
|
|
514
515
|
v-for="invite in pendingInvites"
|
|
515
516
|
:key="invite.id"
|
|
516
517
|
:title="invite.workspaceName"
|
|
517
|
-
:subtitle="`Role: ${invite.roleSid}`"
|
|
518
|
+
:subtitle="`Role: ${invite.roleLabel || invite.roleSid}`"
|
|
518
519
|
class="px-0"
|
|
519
520
|
>
|
|
520
521
|
<template #prepend>
|
|
@@ -23,6 +23,7 @@ function normalizeWorkspaceEntry(entry = null) {
|
|
|
23
23
|
name,
|
|
24
24
|
avatarUrl: normalizeText(entry.avatarUrl),
|
|
25
25
|
roleSid: normalizeLowerText(entry.roleSid || "member") || "member",
|
|
26
|
+
...(typeof entry.roleLabel === "string" && entry.roleLabel.trim() ? { roleLabel: entry.roleLabel.trim() } : {}),
|
|
26
27
|
isAccessible: entry.isAccessible !== false
|
|
27
28
|
});
|
|
28
29
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { chromium, expect } from "@playwright/test";
|
|
5
|
+
import { createWorkspaceRoleCatalog } from "../../workspaces-core/src/shared/roles.js";
|
|
6
|
+
import { createChromiumLaunchOptions, startViteFixture, stopProcess } from "../../../tooling/testUtils/browserFixture.mjs";
|
|
7
|
+
|
|
8
|
+
test("Members displays configured labels but sends and reloads role IDs", {
|
|
9
|
+
skip: process.env.JSKIT_WORKSPACE_ROLE_LABELS_BROWSER !== "1",
|
|
10
|
+
timeout: 120_000
|
|
11
|
+
}, async () => {
|
|
12
|
+
const runtime = await startViteFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/role-labels/", import.meta.url)) });
|
|
13
|
+
let browser;
|
|
14
|
+
let page;
|
|
15
|
+
try {
|
|
16
|
+
browser = await chromium.launch(createChromiumLaunchOptions());
|
|
17
|
+
page = await browser.newPage({ baseURL: runtime.baseURL });
|
|
18
|
+
page.setDefaultTimeout(15_000);
|
|
19
|
+
page.setDefaultNavigationTimeout(60_000);
|
|
20
|
+
const errors = [];
|
|
21
|
+
page.on("pageerror", (error) => {
|
|
22
|
+
errors.push(error.message);
|
|
23
|
+
console.error(error.message);
|
|
24
|
+
});
|
|
25
|
+
let labelled = true;
|
|
26
|
+
let memberRole = "worker";
|
|
27
|
+
const writes = [];
|
|
28
|
+
const roles = {
|
|
29
|
+
owner: { label: "Workspace owner", assignable: true, permissions: ["*"] },
|
|
30
|
+
worker: { label: "Member", assignable: true, permissions: [] },
|
|
31
|
+
safety_manager: { label: "Operator", assignable: true, permissions: [] },
|
|
32
|
+
administrator: { label: "Controller", assignable: true, permissions: [] },
|
|
33
|
+
training_assessor: { label: "Training assessor", assignable: true, permissions: [] }
|
|
34
|
+
};
|
|
35
|
+
const workspace = { id: "7", slug: "example", name: "Example", ownerUserId: "9", avatarUrl: "" };
|
|
36
|
+
await page.route("**/api/**", async (route) => {
|
|
37
|
+
const request = route.request();
|
|
38
|
+
const path = new URL(request.url()).pathname;
|
|
39
|
+
if (path === "/api/session") {
|
|
40
|
+
await route.fulfill({ json: { csrfToken: "fixture-csrf" } });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const catalog = createWorkspaceRoleCatalog({ roleCatalog: {
|
|
44
|
+
workspace: { defaultInviteRole: "worker" },
|
|
45
|
+
roles: Object.fromEntries(Object.entries(roles).map(([id, role]) => [id, labelled ? role : { assignable: role.assignable, permissions: role.permissions }]))
|
|
46
|
+
} });
|
|
47
|
+
if (request.method() !== "GET") {
|
|
48
|
+
const body = request.postDataJSON();
|
|
49
|
+
writes.push({ method: request.method(), path, attributes: body.data.attributes });
|
|
50
|
+
if (request.method() === "PATCH") memberRole = body.data.attributes.roleSid;
|
|
51
|
+
}
|
|
52
|
+
let type;
|
|
53
|
+
let attributes;
|
|
54
|
+
if (path.endsWith("/roles")) {
|
|
55
|
+
type = "workspace-role-catalogs";
|
|
56
|
+
attributes = catalog;
|
|
57
|
+
} else if (path.endsWith("/settings")) {
|
|
58
|
+
type = "workspace-settings";
|
|
59
|
+
attributes = { workspace, roleCatalog: catalog, settings: { invitesEnabled: true, invitesAvailable: true } };
|
|
60
|
+
} else if (path.endsWith("/invites")) {
|
|
61
|
+
type = "workspace-invites";
|
|
62
|
+
attributes = { workspace, roleCatalog: catalog, invites: [{ id: "10", email: "pending@example.com", roleSid: "safety_manager", status: "pending", expiresAt: "2099-01-01T00:00:00.000Z", invitedByUserId: "9" }], createdInviteId: "10", inviteUrl: "", inviteDelivery: { status: "mailer_unconfigured" } };
|
|
63
|
+
} else {
|
|
64
|
+
type = "workspace-members";
|
|
65
|
+
attributes = { workspace, roleCatalog: catalog, members: [
|
|
66
|
+
{ userId: "11", displayName: "Person", email: "person@example.com", roleSid: memberRole, status: "active", isOwner: false },
|
|
67
|
+
{ userId: "9", displayName: "Workspace creator", email: "owner@example.com", roleSid: "owner", status: "active", isOwner: true }
|
|
68
|
+
] };
|
|
69
|
+
}
|
|
70
|
+
await route.fulfill({ contentType: "application/vnd.api+json", json: { data: { type, id: "7", attributes } } });
|
|
71
|
+
});
|
|
72
|
+
await page.goto("/w/example/admin/members");
|
|
73
|
+
const inviteRole = page.getByRole("combobox").first();
|
|
74
|
+
const memberSelect = page.locator(".member-role-select");
|
|
75
|
+
await memberSelect.getByText("Member", { exact: true }).waitFor();
|
|
76
|
+
await expect(memberSelect.last()).toContainText("Workspace owner");
|
|
77
|
+
await expect(memberSelect.last().locator("input")).toBeDisabled();
|
|
78
|
+
await page.getByText(/Role: Operator/u).waitFor();
|
|
79
|
+
assert.match(await inviteRole.locator("xpath=ancestor::*[contains(@class, 'v-select')][1]").textContent(), /Member/u);
|
|
80
|
+
for (const [id, label] of [["worker", "Member"], ["safety_manager", "Operator"], ["administrator", "Controller"], ["training_assessor", "Training assessor"]]) {
|
|
81
|
+
await inviteRole.click();
|
|
82
|
+
assert.equal(await page.getByRole("option", { name: "Workspace owner", exact: true }).count(), 0);
|
|
83
|
+
await page.getByRole("option", { name: label, exact: true }).click();
|
|
84
|
+
await page.getByLabel("Email", { exact: true }).fill("invite@example.com");
|
|
85
|
+
await Promise.all([
|
|
86
|
+
page.waitForResponse((response) => response.request().method() === "POST" && response.url().endsWith("/invites")),
|
|
87
|
+
page.getByRole("button", { name: "Send invite", exact: true }).click()
|
|
88
|
+
]);
|
|
89
|
+
await expect(page.getByLabel("Email", { exact: true })).toHaveValue("");
|
|
90
|
+
assert.equal(writes.at(-1).attributes.roleSid, id);
|
|
91
|
+
await memberSelect.getByRole("combobox").first().click();
|
|
92
|
+
const changed = memberRole !== id;
|
|
93
|
+
const saved = changed
|
|
94
|
+
? page.waitForResponse((response) => response.request().method() === "PATCH" && response.url().endsWith("/role"))
|
|
95
|
+
: Promise.resolve();
|
|
96
|
+
await page.getByRole("option", { name: label, exact: true }).click();
|
|
97
|
+
await saved;
|
|
98
|
+
await page.reload();
|
|
99
|
+
await memberSelect.getByText(label, { exact: true }).waitFor();
|
|
100
|
+
assert.equal(memberRole, id);
|
|
101
|
+
}
|
|
102
|
+
labelled = false;
|
|
103
|
+
await page.reload();
|
|
104
|
+
await memberSelect.getByText("Training_assessor", { exact: true }).waitFor();
|
|
105
|
+
await page.getByText(/Role: safety_manager/u).waitFor();
|
|
106
|
+
assert.deepEqual(errors, []);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error(await page?.locator("body").innerText());
|
|
109
|
+
throw error;
|
|
110
|
+
} finally {
|
|
111
|
+
if (browser) await browser.close();
|
|
112
|
+
await stopProcess(runtime);
|
|
113
|
+
}
|
|
114
|
+
});
|