@jskit-ai/workspaces-core 0.1.211 → 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/package.json +9 -9
- package/patterns/workspace-server/PATTERN.md +20 -0
- package/patterns/workspace-server/example/config/roles.js +2 -0
- package/src/server/WorkspacesFeature.js +2 -1
- package/src/server/common/formatters/workspaceFormatter.js +5 -2
- package/src/server/common/services/workspaceContextService.js +8 -2
- package/src/server/workspaceMembers/defaultWorkspaceInviteEmail.js +2 -1
- package/src/server/workspaceMembers/workspaceMembersService.js +2 -0
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +10 -1
- package/src/shared/resources/workspacePendingInvitationsResource.js +2 -0
- package/src/shared/resources/workspaceResource.js +1 -0
- package/src/shared/resources/workspaceRoleCatalogSchema.js +1 -0
- package/src/shared/roles.js +5 -0
- package/test/roleLabels.test.js +135 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/workspaces-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.213",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
"./shared/resources/workspaceSettingsResource": "./src/shared/resources/workspaceSettingsResource.js"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
22
|
-
"@jskit-ai/json-rest-api-core": "0.1.
|
|
23
|
-
"@jskit-ai/resource-core": "0.1.
|
|
24
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
25
|
-
"@jskit-ai/users-core": "0.1.
|
|
21
|
+
"@jskit-ai/database-runtime": "0.1.235",
|
|
22
|
+
"@jskit-ai/json-rest-api-core": "0.1.179",
|
|
23
|
+
"@jskit-ai/resource-core": "0.1.177",
|
|
24
|
+
"@jskit-ai/resource-crud-core": "0.1.177",
|
|
25
|
+
"@jskit-ai/users-core": "0.1.248",
|
|
26
26
|
"json-rest-schema": "^1.0.17"
|
|
27
27
|
},
|
|
28
28
|
"description": "Workspace tenancy runtime plus HTTP routes, role catalog, and workspace config scaffolding.",
|
|
@@ -169,8 +169,8 @@
|
|
|
169
169
|
}
|
|
170
170
|
},
|
|
171
171
|
"peerDependencies": {
|
|
172
|
-
"@jskit-ai/auth-core": "0.1.
|
|
173
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
174
|
-
"@jskit-ai/kernel": "0.1.
|
|
172
|
+
"@jskit-ai/auth-core": "0.1.233",
|
|
173
|
+
"@jskit-ai/http-runtime": "0.1.233",
|
|
174
|
+
"@jskit-ai/kernel": "0.1.235"
|
|
175
175
|
}
|
|
176
176
|
}
|
|
@@ -28,6 +28,26 @@ member permissions, personal-workspace behavior, and surface access rules.
|
|
|
28
28
|
Use workspace routes, resources, scope helpers, tenancy profiles, and settings
|
|
29
29
|
contracts exported by `@jskit-ai/workspaces-core`.
|
|
30
30
|
|
|
31
|
+
## Role display labels
|
|
32
|
+
|
|
33
|
+
Each `roleCatalog.roles[roleId]` definition may include an optional string
|
|
34
|
+
`label`. JSKIT trims it and preserves nonempty labels in role descriptors,
|
|
35
|
+
catalogue clones and Members/Workspace Settings responses. Labels are display
|
|
36
|
+
metadata; they do not inherit from a parent role. Missing, blank or non-string
|
|
37
|
+
labels keep the existing role-title fallback.
|
|
38
|
+
|
|
39
|
+
Shared Members invitation and member-role selectors show the label but submit
|
|
40
|
+
the role id. Pending invitation captions, workspace role captions and the
|
|
41
|
+
default invitation email use the same configured labels. Pending invitation
|
|
42
|
+
responses expose optional `roleLabel` alongside the unchanged `roleSid`.
|
|
43
|
+
Applications with their own invitation renderer may use its optional
|
|
44
|
+
`roleLabel` input; `roleSid` remains the stored role id.
|
|
45
|
+
|
|
46
|
+
Keep existing permissions, inheritance, assignability and
|
|
47
|
+
`workspace.defaultInviteRole` when adding a label. `owner` remains
|
|
48
|
+
nonassignable. Custom roles, including specialised Training roles, are ordinary
|
|
49
|
+
catalogue entries. Updating labels requires no database migration.
|
|
50
|
+
|
|
31
51
|
## Transactional invitation participation
|
|
32
52
|
|
|
33
53
|
Through `workspaces.core`, register one callback during feature setup with
|
|
@@ -8,6 +8,7 @@ export const roleCatalog = {
|
|
|
8
8
|
permissions: ["*"]
|
|
9
9
|
},
|
|
10
10
|
admin: {
|
|
11
|
+
label: "Administrator",
|
|
11
12
|
assignable: true,
|
|
12
13
|
inherits: "member",
|
|
13
14
|
permissions: [
|
|
@@ -20,6 +21,7 @@ export const roleCatalog = {
|
|
|
20
21
|
]
|
|
21
22
|
},
|
|
22
23
|
member: {
|
|
24
|
+
label: "Team member",
|
|
23
25
|
assignable: true,
|
|
24
26
|
permissions: ["workspace.settings.view"]
|
|
25
27
|
}
|
|
@@ -105,7 +105,8 @@ function createWorkspacesRuntime({ config, env, jsonRestApi } = {}) {
|
|
|
105
105
|
});
|
|
106
106
|
const workspacePendingInvitationsService = createWorkspacePendingInvitationsService({
|
|
107
107
|
workspaceInvitesRepository,
|
|
108
|
-
workspaceMembershipsRepository
|
|
108
|
+
workspaceMembershipsRepository,
|
|
109
|
+
roleCatalog
|
|
109
110
|
});
|
|
110
111
|
const workspaceMembersService = createWorkspaceMembersService({
|
|
111
112
|
workspaceMembershipsRepository,
|
|
@@ -2,13 +2,16 @@ import { resolveWorkspaceThemePalettes } from "@jskit-ai/workspaces-core/shared/
|
|
|
2
2
|
import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
|
|
3
3
|
import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
|
|
4
4
|
|
|
5
|
-
function mapWorkspaceSummary(workspace, membership) {
|
|
5
|
+
function mapWorkspaceSummary(workspace, membership, roleCatalog = null) {
|
|
6
|
+
const roleSid = normalizeLowerText(membership?.roleSid || "member") || "member";
|
|
7
|
+
const roleLabel = roleCatalog?.roles?.find((role) => role.id === roleSid)?.label;
|
|
6
8
|
return {
|
|
7
9
|
id: normalizeRecordId(workspace.id, { fallback: "" }),
|
|
8
10
|
slug: normalizeText(workspace.slug),
|
|
9
11
|
name: normalizeText(workspace.name),
|
|
10
12
|
avatarUrl: normalizeText(workspace.avatarUrl),
|
|
11
|
-
roleSid
|
|
13
|
+
roleSid,
|
|
14
|
+
...(roleLabel ? { roleLabel } : {}),
|
|
12
15
|
isAccessible: normalizeLowerText(membership?.status || "active") === "active"
|
|
13
16
|
};
|
|
14
17
|
}
|
|
@@ -5,7 +5,8 @@ import {
|
|
|
5
5
|
resolveTenancyProfile
|
|
6
6
|
} from "../../../shared/tenancyProfile.js";
|
|
7
7
|
import {
|
|
8
|
-
resolveRolePermissions
|
|
8
|
+
resolveRolePermissions,
|
|
9
|
+
createWorkspaceRoleCatalog
|
|
9
10
|
} from "../../../shared/roles.js";
|
|
10
11
|
import {
|
|
11
12
|
mapWorkspaceSummary
|
|
@@ -144,8 +145,13 @@ function createService({
|
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
const list = await workspacesRepository.listForUserId(normalizedUserId, options);
|
|
148
|
+
const roleCatalog = createWorkspaceRoleCatalog(appConfig);
|
|
147
149
|
const accessible = list
|
|
148
|
-
.map((entry) => mapWorkspaceSummary(
|
|
150
|
+
.map((entry) => mapWorkspaceSummary(
|
|
151
|
+
entry,
|
|
152
|
+
{ roleSid: entry.roleSid, status: entry.membershipStatus },
|
|
153
|
+
roleCatalog
|
|
154
|
+
))
|
|
149
155
|
.filter((entry) => entry.isAccessible);
|
|
150
156
|
|
|
151
157
|
return accessible;
|
|
@@ -12,11 +12,12 @@ function renderDefaultWorkspaceInviteEmail({
|
|
|
12
12
|
workspace = {},
|
|
13
13
|
inviter = null,
|
|
14
14
|
roleSid = "member",
|
|
15
|
+
roleLabel = "",
|
|
15
16
|
expiresAt = ""
|
|
16
17
|
} = {}) {
|
|
17
18
|
const workspaceName = String(workspace?.name || workspace?.slug || "the workspace").trim() || "the workspace";
|
|
18
19
|
const inviterName = String(inviter?.displayName || inviter?.email || "").trim();
|
|
19
|
-
const roleName = String(roleSid || "member").trim() || "member";
|
|
20
|
+
const roleName = (typeof roleLabel === "string" ? roleLabel.trim() : "") || String(roleSid || "member").trim() || "member";
|
|
20
21
|
const expiryText = expiresAt ? `This invitation expires at ${expiresAt}.` : "";
|
|
21
22
|
const intro = inviterName
|
|
22
23
|
? `${inviterName} invited you to join ${workspaceName} as ${roleName}.`
|
|
@@ -110,12 +110,14 @@ function createService({
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
try {
|
|
113
|
+
const roleLabel = resolvedRoleCatalog.roles?.find((role) => role.id === invite.roleSid)?.label;
|
|
113
114
|
const message = await resolvedWorkspaceInviteEmailTemplate({
|
|
114
115
|
email: invite.email,
|
|
115
116
|
inviteUrl,
|
|
116
117
|
workspace: mapWorkspaceSummary(workspace),
|
|
117
118
|
inviter: user || null,
|
|
118
119
|
roleSid: invite.roleSid,
|
|
120
|
+
...(roleLabel ? { roleLabel } : {}),
|
|
119
121
|
expiresAt: invite.expiresAt
|
|
120
122
|
});
|
|
121
123
|
const result = await resolvedWorkspaceInviteMailer.sendWorkspaceInvite({
|
|
@@ -6,7 +6,8 @@ import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
|
|
|
6
6
|
|
|
7
7
|
function createService({
|
|
8
8
|
workspaceInvitesRepository,
|
|
9
|
-
workspaceMembershipsRepository
|
|
9
|
+
workspaceMembershipsRepository,
|
|
10
|
+
roleCatalog = null
|
|
10
11
|
} = {}) {
|
|
11
12
|
if (!workspaceInvitesRepository || !workspaceMembershipsRepository) {
|
|
12
13
|
throw new Error("workspacePendingInvitationsService requires invite and membership repositories.");
|
|
@@ -15,6 +16,12 @@ function createService({
|
|
|
15
16
|
let acceptanceParticipant = null;
|
|
16
17
|
let registrationClosed = false;
|
|
17
18
|
|
|
19
|
+
function roleLabelMetadata(roleSid) {
|
|
20
|
+
const role = roleCatalog?.roles?.find((entry) => entry.id === (normalizeLowerText(roleSid || "member") || "member"));
|
|
21
|
+
const label = typeof role?.label === "string" ? role.label.trim() : "";
|
|
22
|
+
return label ? { roleLabel: label } : {};
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
function registerAcceptanceParticipant(participant) {
|
|
19
26
|
if (typeof participant !== "function") {
|
|
20
27
|
throw new TypeError("Acceptance participant must be a function.");
|
|
@@ -123,6 +130,7 @@ function createService({
|
|
|
123
130
|
email: normalizeLowerText(invite.email),
|
|
124
131
|
maskedEmail: maskInviteEmail(invite.email),
|
|
125
132
|
roleSid: normalizeLowerText(invite.roleSid || "member") || "member",
|
|
133
|
+
...roleLabelMetadata(invite.roleSid),
|
|
126
134
|
expiresAt: invite.expiresAt || null,
|
|
127
135
|
workspace: {
|
|
128
136
|
id: normalizeRecordId(invite.workspaceId, { fallback: "" }),
|
|
@@ -149,6 +157,7 @@ function createService({
|
|
|
149
157
|
workspaceName: normalizeText(invite.workspaceName || invite.workspaceSlug),
|
|
150
158
|
workspaceAvatarUrl: normalizeText(invite.workspaceAvatarUrl),
|
|
151
159
|
roleSid: normalizeLowerText(invite.roleSid || "member") || "member",
|
|
160
|
+
...roleLabelMetadata(invite.roleSid),
|
|
152
161
|
status: normalizeLowerText(invite.status || "pending") || "pending",
|
|
153
162
|
expiresAt: invite.expiresAt || null,
|
|
154
163
|
token: encodeInviteTokenHash(tokenHash)
|
|
@@ -34,6 +34,7 @@ const invitationResolveOutputSchema = createSchema({
|
|
|
34
34
|
email: { type: "string", required: true },
|
|
35
35
|
maskedEmail: { type: "string", required: true },
|
|
36
36
|
roleSid: { type: "string", required: true },
|
|
37
|
+
roleLabel: { type: "string", required: false },
|
|
37
38
|
expiresAt: { type: "string", required: false, nullable: true, minLength: 1 },
|
|
38
39
|
workspace: {
|
|
39
40
|
type: "object",
|
|
@@ -53,6 +54,7 @@ const pendingInvitationsListOutputSchema = createSchema({
|
|
|
53
54
|
workspaceName: { type: "string", required: true, minLength: 1, maxLength: 160 },
|
|
54
55
|
workspaceAvatarUrl: { type: "string", required: true },
|
|
55
56
|
roleSid: { type: "string", required: true, minLength: 1, maxLength: 64 },
|
|
57
|
+
roleLabel: { type: "string", required: false },
|
|
56
58
|
status: { type: "string", required: true, minLength: 1, maxLength: 64 },
|
|
57
59
|
expiresAt: { type: "string", required: false, nullable: true, minLength: 1 },
|
|
58
60
|
token: { type: "string", required: true, minLength: 1 }
|
|
@@ -33,6 +33,7 @@ const workspaceListItemSchema = createSchema({
|
|
|
33
33
|
name: { type: "string", required: true, minLength: 1, maxLength: 160 },
|
|
34
34
|
avatarUrl: { type: "string", required: true },
|
|
35
35
|
roleSid: { type: "string", required: true, minLength: 1, maxLength: 64 },
|
|
36
|
+
roleLabel: { type: "string", required: false },
|
|
36
37
|
isAccessible: { type: "boolean", required: true }
|
|
37
38
|
});
|
|
38
39
|
|
|
@@ -2,6 +2,7 @@ import { createSchema } from "json-rest-schema";
|
|
|
2
2
|
|
|
3
3
|
const workspaceRoleDescriptorSchema = createSchema({
|
|
4
4
|
id: { type: "string", required: true, minLength: 1, maxLength: 64 },
|
|
5
|
+
label: { type: "string", required: false },
|
|
5
6
|
assignable: { type: "boolean", required: true },
|
|
6
7
|
permissions: {
|
|
7
8
|
type: "array",
|
package/src/shared/roles.js
CHANGED
|
@@ -47,11 +47,13 @@ function resolveInheritedRolePermissions(roleSid, configuredRoles = {}, seenRole
|
|
|
47
47
|
|
|
48
48
|
function createRoleDescriptor(roleSid, configuredDefinition, configuredRoles = {}) {
|
|
49
49
|
const source = asRecord(configuredDefinition);
|
|
50
|
+
const label = typeof source.label === "string" ? source.label.trim() : "";
|
|
50
51
|
const assignable = roleSid === OWNER_ROLE_ID ? false : source.assignable === true;
|
|
51
52
|
const permissions = resolveInheritedRolePermissions(roleSid, configuredRoles);
|
|
52
53
|
|
|
53
54
|
return Object.freeze({
|
|
54
55
|
id: roleSid,
|
|
56
|
+
...(label ? { label } : {}),
|
|
55
57
|
assignable,
|
|
56
58
|
permissions: Object.freeze([...permissions])
|
|
57
59
|
});
|
|
@@ -99,6 +101,7 @@ function createWorkspaceRoleCatalog(appConfig = {}) {
|
|
|
99
101
|
roles.map((role) =>
|
|
100
102
|
Object.freeze({
|
|
101
103
|
id: role.id,
|
|
104
|
+
...(role.label ? { label: role.label } : {}),
|
|
102
105
|
assignable: role.assignable,
|
|
103
106
|
permissions: Object.freeze([...role.permissions])
|
|
104
107
|
})
|
|
@@ -117,6 +120,7 @@ function cloneWorkspaceRoleCatalog(roleCatalog = null) {
|
|
|
117
120
|
roles: Array.isArray(source.roles)
|
|
118
121
|
? source.roles.map((role) => ({
|
|
119
122
|
id: normalizeRoleId(role?.id),
|
|
123
|
+
...(typeof role?.label === "string" && role.label.trim() ? { label: role.label.trim() } : {}),
|
|
120
124
|
assignable: role?.assignable === true,
|
|
121
125
|
permissions: Array.isArray(role?.permissions) ? [...role.permissions] : []
|
|
122
126
|
}))
|
|
@@ -129,6 +133,7 @@ function listRoleDescriptors(appConfig = {}) {
|
|
|
129
133
|
const roleCatalog = createWorkspaceRoleCatalog(appConfig);
|
|
130
134
|
return roleCatalog.roles.map((role) => ({
|
|
131
135
|
id: role.id,
|
|
136
|
+
...(role.label ? { label: role.label } : {}),
|
|
132
137
|
assignable: role.assignable,
|
|
133
138
|
permissions: [...role.permissions]
|
|
134
139
|
}));
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { validateSchemaPayload } from "@jskit-ai/kernel/shared/validators";
|
|
4
|
+
import { encodeInviteTokenHash } from "@jskit-ai/auth-core/shared/inviteTokens";
|
|
5
|
+
import { createWorkspaceRoleCatalog, cloneWorkspaceRoleCatalog, listRoleDescriptors } from "../src/shared/roles.js";
|
|
6
|
+
import { workspaceMembersResource } from "../src/shared/resources/workspaceMembersResource.js";
|
|
7
|
+
import { workspaceSettingsResource, WORKSPACE_SETTINGS_FIELD_KEYS } from "../src/shared/resources/workspaceSettingsResource.js";
|
|
8
|
+
import { workspacePendingInvitationsResource } from "../src/shared/resources/workspacePendingInvitationsResource.js";
|
|
9
|
+
import { createService as createMembersService } from "../src/server/workspaceMembers/workspaceMembersService.js";
|
|
10
|
+
import { createService as createSettingsService } from "../src/server/workspaceSettings/workspaceSettingsService.js";
|
|
11
|
+
import { createService as createPendingService } from "../src/server/workspacePendingInvitations/workspacePendingInvitationsService.js";
|
|
12
|
+
import { renderDefaultWorkspaceInviteEmail } from "../src/server/workspaceMembers/defaultWorkspaceInviteEmail.js";
|
|
13
|
+
|
|
14
|
+
const config = {
|
|
15
|
+
roleCatalog: {
|
|
16
|
+
workspace: { defaultInviteRole: "worker" },
|
|
17
|
+
roles: {
|
|
18
|
+
owner: { label: "Owner", assignable: true, permissions: ["*"] },
|
|
19
|
+
worker: { label: " Member ", assignable: true, permissions: ["training.view"] },
|
|
20
|
+
safety_manager: { label: "Operator", assignable: true, inherits: "worker", permissions: ["training.manage"] },
|
|
21
|
+
administrator: { label: "Controller", assignable: true, inherits: "safety_manager", permissions: ["workspace.members.manage"] },
|
|
22
|
+
training_assessor: { label: "Training assessor", assignable: true, permissions: ["training.assess"] }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const catalog = createWorkspaceRoleCatalog(config);
|
|
27
|
+
const workspace = { id: "7", slug: "example", name: "Example", ownerUserId: "9", avatarUrl: "" };
|
|
28
|
+
const expectedLabels = ["Owner", "Member", "Operator", "Controller", "Training assessor"];
|
|
29
|
+
|
|
30
|
+
function output(resource, operation, value) {
|
|
31
|
+
return validateSchemaPayload(resource.operations[operation].output, value, { phase: "output" });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("configured labels survive creation, cloning and listing without changing role policy", () => {
|
|
35
|
+
for (const roles of [catalog.roles, cloneWorkspaceRoleCatalog(catalog).roles, listRoleDescriptors(config)]) {
|
|
36
|
+
assert.deepEqual(roles.map((role) => role.label), expectedLabels);
|
|
37
|
+
assert.equal(roles[0].assignable, false);
|
|
38
|
+
assert.deepEqual(roles[2].permissions, ["training.view", "training.manage"]);
|
|
39
|
+
assert.deepEqual(roles[4].permissions, ["training.assess"]);
|
|
40
|
+
}
|
|
41
|
+
assert.equal(catalog.defaultInviteRole, "worker");
|
|
42
|
+
assert.equal(catalog.assignableRoleIds.includes("owner"), false);
|
|
43
|
+
assert.equal(Object.isFrozen(catalog.roles[1]), true);
|
|
44
|
+
const clone = cloneWorkspaceRoleCatalog(catalog);
|
|
45
|
+
clone.roles[1].label = "Edited";
|
|
46
|
+
assert.equal(catalog.roles[1].label, "Member");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("missing, whitespace and non-string labels retain the original descriptor shape", () => {
|
|
50
|
+
const roles = createWorkspaceRoleCatalog({ roleCatalog: { roles: {
|
|
51
|
+
member: { assignable: true, permissions: [] },
|
|
52
|
+
blank: { label: " ", permissions: [] },
|
|
53
|
+
invalid: { label: 123, permissions: [] }
|
|
54
|
+
} } }).roles;
|
|
55
|
+
assert.equal(roles.every((role) => !Object.hasOwn(role, "label")), true);
|
|
56
|
+
assert.deepEqual(cloneWorkspaceRoleCatalog({ roles }).roles, roles);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("Members and Invitations response validation preserves labels and saved role IDs", async () => {
|
|
60
|
+
let savedRole = "worker";
|
|
61
|
+
const service = createMembersService({
|
|
62
|
+
roleCatalog: catalog,
|
|
63
|
+
inviteExpiresInMs: 60_000,
|
|
64
|
+
workspaceMembershipsRepository: {
|
|
65
|
+
async listActiveByWorkspaceId() {
|
|
66
|
+
return [{ userId: "11", roleSid: savedRole, status: "active", displayName: "Person", email: "person@example.com" }];
|
|
67
|
+
},
|
|
68
|
+
async findByWorkspaceIdAndUserId() { return { userId: "11", roleSid: savedRole, status: "active" }; },
|
|
69
|
+
async upsertMembership(_workspaceId, _userId, patch) { savedRole = patch.roleSid; }
|
|
70
|
+
},
|
|
71
|
+
workspaceInvitesRepository: { async listPendingByWorkspaceIdWithWorkspace() { return []; } }
|
|
72
|
+
});
|
|
73
|
+
for (const roleSid of ["worker", "safety_manager", "administrator", "training_assessor"]) {
|
|
74
|
+
await service.updateMemberRole(workspace, { memberUserId: "11", roleSid });
|
|
75
|
+
const reloaded = output(workspaceMembersResource, "membersList", await service.listMembers(workspace));
|
|
76
|
+
assert.equal(savedRole, roleSid);
|
|
77
|
+
assert.equal(reloaded.members[0].roleSid, roleSid);
|
|
78
|
+
assert.deepEqual(reloaded.roleCatalog.roles.map((role) => role.label), expectedLabels);
|
|
79
|
+
}
|
|
80
|
+
const roles = output(workspaceMembersResource, "rolesList", await service.listRoles());
|
|
81
|
+
assert.deepEqual(roles.roles.map((role) => role.label), expectedLabels);
|
|
82
|
+
const invites = output(workspaceMembersResource, "invitesList", await service.listInvites(workspace));
|
|
83
|
+
assert.deepEqual(invites.roleCatalog.roles.map((role) => role.label), expectedLabels);
|
|
84
|
+
await assert.rejects(service.updateMemberRole(workspace, { memberUserId: "11", roleSid: "Controller" }), /Validation failed/);
|
|
85
|
+
await assert.rejects(service.updateMemberRole(workspace, { memberUserId: "11", roleSid: "owner" }), /Validation failed/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("Workspace Settings response validation preserves configured labels", async () => {
|
|
89
|
+
const service = createSettingsService({
|
|
90
|
+
roleCatalog: catalog,
|
|
91
|
+
workspaceSettingsRepository: {
|
|
92
|
+
async ensureForWorkspaceId() {
|
|
93
|
+
return { ...Object.fromEntries(WORKSPACE_SETTINGS_FIELD_KEYS.map((key) => [key, "#123456"])), invitesEnabled: true };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
const response = output(workspaceSettingsResource, "view", await service.getWorkspaceSettings(workspace, {
|
|
98
|
+
context: { actor: { id: "9" }, permissions: ["workspace.settings.view"] }
|
|
99
|
+
}));
|
|
100
|
+
assert.deepEqual(response.roleCatalog.roles.map((role) => role.label), expectedLabels);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("pending and resolved invitation responses derive labels while acceptance stores the role ID", async () => {
|
|
104
|
+
const tokenHash = "a".repeat(64);
|
|
105
|
+
const token = encodeInviteTokenHash(tokenHash);
|
|
106
|
+
const invite = { id: "10", workspaceId: "7", workspaceSlug: "example", workspaceName: "Example", roleSid: "safety_manager", status: "pending", email: "person@example.com", expiresAt: "2099-01-01T00:00:00.000Z", tokenHash };
|
|
107
|
+
let membership;
|
|
108
|
+
const service = createPendingService({
|
|
109
|
+
roleCatalog: catalog,
|
|
110
|
+
workspaceInvitesRepository: {
|
|
111
|
+
async listPendingByEmail() { return [invite]; },
|
|
112
|
+
async findByTokenHashWithWorkspace() { return invite; },
|
|
113
|
+
async findPendingByTokenHash() { return invite; },
|
|
114
|
+
async withTransaction(work) { return work({}); },
|
|
115
|
+
async markAcceptedById() {}
|
|
116
|
+
},
|
|
117
|
+
workspaceMembershipsRepository: { async upsertMembership(_workspaceId, _userId, patch) { membership = patch; } }
|
|
118
|
+
});
|
|
119
|
+
const user = { id: "11", email: invite.email };
|
|
120
|
+
const pending = output(workspacePendingInvitationsResource, "list", { pendingInvites: await service.listPendingInvitesForUser(user) });
|
|
121
|
+
const resolved = output(workspacePendingInvitationsResource, "resolve", await service.resolveInviteByToken(token));
|
|
122
|
+
assert.equal(pending.pendingInvites[0].roleLabel, "Operator");
|
|
123
|
+
assert.equal(resolved.roleLabel, "Operator");
|
|
124
|
+
assert.equal(resolved.roleSid, "safety_manager");
|
|
125
|
+
await service.acceptInviteByToken({ user, token });
|
|
126
|
+
assert.deepEqual(membership, { roleSid: "safety_manager", status: "active" });
|
|
127
|
+
assert.equal(Object.hasOwn(invite, "roleLabel"), false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("default invitation email uses the configured label with HTML escaping and the original fallback", () => {
|
|
131
|
+
const labelled = renderDefaultWorkspaceInviteEmail({ roleSid: "worker", roleLabel: " Member <team> " });
|
|
132
|
+
assert.match(labelled.text, /as Member <team>/u);
|
|
133
|
+
assert.match(labelled.html, /as Member <team>/u);
|
|
134
|
+
assert.match(renderDefaultWorkspaceInviteEmail({ roleSid: "worker" }).text, /as worker/u);
|
|
135
|
+
});
|