@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.
@@ -0,0 +1,2 @@
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Workspace role labels</title></head><body><div id="app"></div><script type="module" src="/main.js"></script></body></html>
@@ -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.212",
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.246",
18
- "@jskit-ai/workspaces-core": "0.1.211",
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.233",
23
- "@jskit-ai/http-web": "0.1.78",
24
- "@jskit-ai/kernel": "0.1.233",
25
- "@jskit-ai/shell-web": "0.1.237",
26
- "@jskit-ai/users-web": "0.1.251",
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
+ });