@jskit-ai/workspaces-core 0.1.226 → 0.2.1
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/CHANGELOG.md +83 -0
- package/locales/en.json +52 -0
- package/package.json +12 -10
- package/patterns/workspace-server/PATTERN.md +7 -0
- package/src/server/WorkspacesFeature.js +9 -4
- package/src/server/common/repositories/workspaceInvitesRepository.js +7 -3
- package/src/server/common/services/workspaceContextService.js +13 -11
- package/src/server/i18n.js +10 -0
- package/src/server/workspaceMembers/defaultWorkspaceInviteEmail.js +20 -8
- package/src/server/workspaceMembers/workspaceMembersService.js +55 -29
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +14 -8
- package/src/shared/resources/workspaceMembersResource.js +9 -0
- package/test/exportsContract.test.js +3 -1
- package/test/featureRuntime.test.js +1 -0
- package/test/i18nCatalog.test.js +16 -0
- package/test/repositories.integration.test.js +1 -1
- package/test/workspaceMembersService.test.js +146 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 - 2026-10-29
|
|
4
|
+
|
|
5
|
+
- Translation ready: every user-visible text is a message key with its English in `locales/en.json`, so an app translates it by supplying translation files only. With no translations, texts and behaviour are unchanged. See the guide "Translating a JSKIT app".
|
|
6
|
+
- Workspace invitation emails are rendered in the recipient's saved locale (`createServerI18n`); `WorkspacesFeature` requires the `runtime.app-root` capability.
|
|
7
|
+
|
|
8
|
+
### New message keys (34)
|
|
9
|
+
|
|
10
|
+
All keys in `locales/en.json` (exported as `./locales/en.json`) are new in this version.
|
|
11
|
+
|
|
12
|
+
<details><summary>Key list</summary>
|
|
13
|
+
|
|
14
|
+
- `jskit.workspaces.auth.required`
|
|
15
|
+
- `jskit.workspaces.user.invalidPayload`
|
|
16
|
+
- `jskit.workspaces.workspace.slugUnavailable`
|
|
17
|
+
- `jskit.workspaces.workspace.creationDisabled`
|
|
18
|
+
- `jskit.workspaces.workspace.nameRequired`
|
|
19
|
+
- `jskit.workspaces.workspace.contextDisabled`
|
|
20
|
+
- `jskit.workspaces.workspace.slugRequired`
|
|
21
|
+
- `jskit.workspaces.workspace.notFound`
|
|
22
|
+
- `jskit.workspaces.workspace.accessDenied`
|
|
23
|
+
- `jskit.workspaces.member.roleNotAssignable`
|
|
24
|
+
- `jskit.workspaces.member.notFound`
|
|
25
|
+
- `jskit.workspaces.member.ownerRoleLocked`
|
|
26
|
+
- `jskit.workspaces.member.ownerNotRemovable`
|
|
27
|
+
- `jskit.workspaces.invite.disabled`
|
|
28
|
+
- `jskit.workspaces.invite.notFound`
|
|
29
|
+
- `jskit.workspaces.invite.notFoundOrHandled`
|
|
30
|
+
- `jskit.workspaces.invite.invitationNotFoundOrHandled`
|
|
31
|
+
- `jskit.workspaces.invite.workspaceMissing`
|
|
32
|
+
- `jskit.workspaces.invite.expired`
|
|
33
|
+
- `jskit.workspaces.invite.tokenMismatch`
|
|
34
|
+
- `jskit.workspaces.invite.tokenRequired`
|
|
35
|
+
- `jskit.workspaces.invite.tokenInvalid`
|
|
36
|
+
- `jskit.workspaces.invite.emailMismatchUser`
|
|
37
|
+
- `jskit.workspaces.invite.emailMismatchAccount`
|
|
38
|
+
- `jskit.workspaces.invite.notAvailable`
|
|
39
|
+
- `jskit.workspaces.invite.mailerUnconfigured`
|
|
40
|
+
- `jskit.workspaces.invite.sendFailed`
|
|
41
|
+
- `jskit.workspaces.inviteEmail.subject`
|
|
42
|
+
- `jskit.workspaces.inviteEmail.intro`
|
|
43
|
+
- `jskit.workspaces.inviteEmail.introFromInviter`
|
|
44
|
+
- `jskit.workspaces.inviteEmail.expiry`
|
|
45
|
+
- `jskit.workspaces.inviteEmail.openLink`
|
|
46
|
+
- `jskit.workspaces.inviteEmail.accept`
|
|
47
|
+
- `jskit.workspaces.inviteEmail.workspaceFallback`
|
|
48
|
+
|
|
49
|
+
</details>
|
|
50
|
+
|
|
51
|
+
### Codes (27)
|
|
52
|
+
|
|
53
|
+
Error, field-error and message codes this package sends, with their English. Undotted codes kept their existing values and render from `jskit.errors.<code>`.
|
|
54
|
+
|
|
55
|
+
| Code | English |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `workspaces.auth.required` | Authentication required. |
|
|
58
|
+
| `workspaces.user.invalidPayload` | Invalid authenticated user payload. |
|
|
59
|
+
| `workspaces.workspace.slugUnavailable` | Unable to generate unique workspace slug. |
|
|
60
|
+
| `workspaces.workspace.creationDisabled` | Workspace creation is disabled for this tenancy mode. |
|
|
61
|
+
| `workspaces.workspace.nameRequired` | Workspace name is required. |
|
|
62
|
+
| `workspaces.workspace.contextDisabled` | Workspace context is disabled. |
|
|
63
|
+
| `workspaces.workspace.slugRequired` | workspaceSlug is required. |
|
|
64
|
+
| `workspaces.workspace.notFound` | Workspace not found. |
|
|
65
|
+
| `workspaces.workspace.accessDenied` | You do not have access to this workspace. |
|
|
66
|
+
| `workspaces.member.roleNotAssignable` | Role is not assignable. |
|
|
67
|
+
| `workspaces.member.notFound` | Member not found. |
|
|
68
|
+
| `workspaces.member.ownerRoleLocked` | Cannot change workspace owner role. |
|
|
69
|
+
| `workspaces.member.ownerNotRemovable` | Cannot remove workspace owner. |
|
|
70
|
+
| `workspaces.invite.disabled` | Workspace invitations are disabled. |
|
|
71
|
+
| `workspaces.invite.notFound` | Invite not found. |
|
|
72
|
+
| `workspaces.invite.notFoundOrHandled` | Invite not found or already handled. |
|
|
73
|
+
| `workspaces.invite.invitationNotFoundOrHandled` | Invitation not found or already handled. |
|
|
74
|
+
| `workspaces.invite.workspaceMissing` | Invitation workspace no longer exists. |
|
|
75
|
+
| `workspaces.invite.expired` | Invitation has expired. |
|
|
76
|
+
| `workspaces.invite.tokenMismatch` | Invitation token does not match the saved invitation. |
|
|
77
|
+
| `workspaces.invite.tokenRequired` | Invite token is required. |
|
|
78
|
+
| `workspaces.invite.tokenInvalid` | Invite token is invalid. |
|
|
79
|
+
| `workspaces.invite.emailMismatchUser` | Invitation email does not match authenticated user. |
|
|
80
|
+
| `workspaces.invite.emailMismatchAccount` | Invitation email does not match account email. |
|
|
81
|
+
| `workspaces.invite.notAvailable` | Invitation is not available. |
|
|
82
|
+
| `workspaces.invite.mailerUnconfigured` | No workspace invite mailer is configured. |
|
|
83
|
+
| `workspaces.invite.sendFailed` | Unable to send workspace invite email. |
|
package/locales/en.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"jskit": {
|
|
3
|
+
"workspaces": {
|
|
4
|
+
"auth": {
|
|
5
|
+
"required": "Authentication required."
|
|
6
|
+
},
|
|
7
|
+
"user": {
|
|
8
|
+
"invalidPayload": "Invalid authenticated user payload."
|
|
9
|
+
},
|
|
10
|
+
"workspace": {
|
|
11
|
+
"slugUnavailable": "Unable to generate unique workspace slug.",
|
|
12
|
+
"creationDisabled": "Workspace creation is disabled for this tenancy mode.",
|
|
13
|
+
"nameRequired": "Workspace name is required.",
|
|
14
|
+
"contextDisabled": "Workspace context is disabled.",
|
|
15
|
+
"slugRequired": "workspaceSlug is required.",
|
|
16
|
+
"notFound": "Workspace not found.",
|
|
17
|
+
"accessDenied": "You do not have access to this workspace."
|
|
18
|
+
},
|
|
19
|
+
"member": {
|
|
20
|
+
"roleNotAssignable": "Role is not assignable.",
|
|
21
|
+
"notFound": "Member not found.",
|
|
22
|
+
"ownerRoleLocked": "Cannot change workspace owner role.",
|
|
23
|
+
"ownerNotRemovable": "Cannot remove workspace owner."
|
|
24
|
+
},
|
|
25
|
+
"invite": {
|
|
26
|
+
"disabled": "Workspace invitations are disabled.",
|
|
27
|
+
"notFound": "Invite not found.",
|
|
28
|
+
"notFoundOrHandled": "Invite not found or already handled.",
|
|
29
|
+
"invitationNotFoundOrHandled": "Invitation not found or already handled.",
|
|
30
|
+
"workspaceMissing": "Invitation workspace no longer exists.",
|
|
31
|
+
"expired": "Invitation has expired.",
|
|
32
|
+
"tokenMismatch": "Invitation token does not match the saved invitation.",
|
|
33
|
+
"tokenRequired": "Invite token is required.",
|
|
34
|
+
"tokenInvalid": "Invite token is invalid.",
|
|
35
|
+
"emailMismatchUser": "Invitation email does not match authenticated user.",
|
|
36
|
+
"emailMismatchAccount": "Invitation email does not match account email.",
|
|
37
|
+
"notAvailable": "Invitation is not available.",
|
|
38
|
+
"mailerUnconfigured": "No workspace invite mailer is configured.",
|
|
39
|
+
"sendFailed": "Unable to send workspace invite email."
|
|
40
|
+
},
|
|
41
|
+
"inviteEmail": {
|
|
42
|
+
"subject": "You're invited to {workspace}",
|
|
43
|
+
"intro": "You have been invited to join {workspace} as {role}.",
|
|
44
|
+
"introFromInviter": "{inviter} invited you to join {workspace} as {role}.",
|
|
45
|
+
"expiry": "This invitation expires at {expiresAt}.",
|
|
46
|
+
"openLink": "Open this link to accept the invitation:",
|
|
47
|
+
"accept": "Accept invitation",
|
|
48
|
+
"workspaceFallback": "the workspace"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/workspaces-core",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -15,14 +15,15 @@
|
|
|
15
15
|
"./shared/tenancyProfile": "./src/shared/tenancyProfile.js",
|
|
16
16
|
"./shared/support/workspacePathModel": "./src/shared/support/workspacePathModel.js",
|
|
17
17
|
"./shared/resources/workspaceResource": "./src/shared/resources/workspaceResource.js",
|
|
18
|
-
"./shared/resources/workspaceSettingsResource": "./src/shared/resources/workspaceSettingsResource.js"
|
|
18
|
+
"./shared/resources/workspaceSettingsResource": "./src/shared/resources/workspaceSettingsResource.js",
|
|
19
|
+
"./locales/en.json": "./locales/en.json"
|
|
19
20
|
},
|
|
20
21
|
"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
|
|
22
|
+
"@jskit-ai/database-runtime": "0.1.250",
|
|
23
|
+
"@jskit-ai/json-rest-api-core": "0.2.1",
|
|
24
|
+
"@jskit-ai/resource-core": "0.1.192",
|
|
25
|
+
"@jskit-ai/resource-crud-core": "0.1.192",
|
|
26
|
+
"@jskit-ai/users-core": "0.2.1",
|
|
26
27
|
"json-rest-schema": "^1.0.17"
|
|
27
28
|
},
|
|
28
29
|
"description": "Workspace tenancy runtime plus HTTP routes, role catalog, and workspace config scaffolding.",
|
|
@@ -35,6 +36,7 @@
|
|
|
35
36
|
"requires": [
|
|
36
37
|
"auth.extensions",
|
|
37
38
|
"runtime.actions",
|
|
39
|
+
"runtime.app-root",
|
|
38
40
|
"runtime.bootstrap",
|
|
39
41
|
"runtime.config",
|
|
40
42
|
"runtime.database",
|
|
@@ -169,8 +171,8 @@
|
|
|
169
171
|
}
|
|
170
172
|
},
|
|
171
173
|
"peerDependencies": {
|
|
172
|
-
"@jskit-ai/auth-core": "0.1
|
|
173
|
-
"@jskit-ai/http-runtime": "0.1
|
|
174
|
-
"@jskit-ai/kernel": "0.1
|
|
174
|
+
"@jskit-ai/auth-core": "0.2.1",
|
|
175
|
+
"@jskit-ai/http-runtime": "0.2.1",
|
|
176
|
+
"@jskit-ai/kernel": "0.2.1"
|
|
175
177
|
}
|
|
176
178
|
}
|
|
@@ -49,6 +49,13 @@ responses expose optional `roleLabel` alongside the unchanged `roleSid`.
|
|
|
49
49
|
Applications with their own invitation renderer may use its optional
|
|
50
50
|
`roleLabel` input; `roleSid` remains the stored role id.
|
|
51
51
|
|
|
52
|
+
Invitation emails use the recipient's language: `locale` is the saved
|
|
53
|
+
`user_settings.locale` of an existing account with the invited email, else `en`.
|
|
54
|
+
Renderers receive `locale` and `t(key, params)`, which renders a
|
|
55
|
+
`jskit.workspaces.inviteEmail.*` message from the app's
|
|
56
|
+
`src/locales/<code>/jskit.json` (exact locale, then language, then English); the
|
|
57
|
+
mailer's `sendWorkspaceInvite` receives `locale` too.
|
|
58
|
+
|
|
52
59
|
Keep existing permissions, inheritance, assignability and
|
|
53
60
|
`workspace.defaultInviteRole` when adding a label. `owner` remains
|
|
54
61
|
nonassignable. Custom roles, including specialised Training roles, are ordinary
|
|
@@ -15,6 +15,7 @@ import { createRepository as createWorkspaceInvitesRepository } from "./common/r
|
|
|
15
15
|
import { createRepository as createWorkspaceMembershipsRepository } from "./common/repositories/workspaceMembershipsRepository.js";
|
|
16
16
|
import { createRepository as createWorkspacesRepository } from "./common/repositories/workspacesRepository.js";
|
|
17
17
|
import { createService as createWorkspaceService } from "./common/services/workspaceContextService.js";
|
|
18
|
+
import { createWorkspacesServerI18n } from "./i18n.js";
|
|
18
19
|
import { createWorkspaceBootstrapContributor } from "./workspaceBootstrapContributor.js";
|
|
19
20
|
import { registerWorkspaceDirectoryRoutes } from "./workspaceDirectory/bootWorkspaceDirectoryRoutes.js";
|
|
20
21
|
import { buildWorkspaceDirectoryActions } from "./workspaceDirectory/workspaceDirectoryActions.js";
|
|
@@ -98,7 +99,7 @@ async function installWorkspaceResources(jsonRestApi) {
|
|
|
98
99
|
);
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
function createWorkspacesRuntime({ config, env, jsonRestApi } = {}) {
|
|
102
|
+
function createWorkspacesRuntime({ appRoot = "", config, env, jsonRestApi, users = null } = {}) {
|
|
102
103
|
const tenancyProfile = resolveTenancyProfile(config);
|
|
103
104
|
const invitationsPolicy = resolveWorkspaceInvitationsPolicy({ config, appConfig: config, tenancyProfile });
|
|
104
105
|
const invitationsEnabled = invitationsPolicy.enabled === true;
|
|
@@ -136,7 +137,10 @@ function createWorkspacesRuntime({ config, env, jsonRestApi } = {}) {
|
|
|
136
137
|
workspaceInvitationsEnabled: invitationsEnabled,
|
|
137
138
|
inviteUrlBuilder: createWorkspaceInviteUrlBuilder({ appConfig: config, env }),
|
|
138
139
|
workspaceInviteMailer: config?.workspaceInviteMailer || null,
|
|
139
|
-
workspaceInviteEmailTemplate: resolveInviteEmailTemplate(config)
|
|
140
|
+
workspaceInviteEmailTemplate: resolveInviteEmailTemplate(config),
|
|
141
|
+
i18n: createWorkspacesServerI18n({ appRoot }),
|
|
142
|
+
userProfilesRepository: users?.repositories?.userProfiles || null,
|
|
143
|
+
userSettingsRepository: users?.repositories?.userSettings || null
|
|
140
144
|
});
|
|
141
145
|
const workspaceSettingsService = createWorkspaceSettingsService({
|
|
142
146
|
workspaceSettingsRepository,
|
|
@@ -174,6 +178,7 @@ const WorkspacesFeature = defineFeature({
|
|
|
174
178
|
id: "workspaces.core",
|
|
175
179
|
domain: "workspace",
|
|
176
180
|
requires: {
|
|
181
|
+
appRoot: "runtime.app-root",
|
|
177
182
|
bootstrap: "runtime.bootstrap",
|
|
178
183
|
config: "runtime.config",
|
|
179
184
|
env: "runtime.env",
|
|
@@ -184,9 +189,9 @@ const WorkspacesFeature = defineFeature({
|
|
|
184
189
|
provides: {
|
|
185
190
|
workspaces: "workspaces.core"
|
|
186
191
|
},
|
|
187
|
-
async setup({ bootstrap, config, env, http, jsonRestApi, users }) {
|
|
192
|
+
async setup({ appRoot, bootstrap, config, env, http, jsonRestApi, users }) {
|
|
188
193
|
await installWorkspaceResources(jsonRestApi);
|
|
189
|
-
const workspaces = createWorkspacesRuntime({ config, env, jsonRestApi });
|
|
194
|
+
const workspaces = createWorkspacesRuntime({ appRoot, config, env, jsonRestApi, users });
|
|
190
195
|
|
|
191
196
|
bootstrap.register({
|
|
192
197
|
id: "workspaces.bootstrap",
|
|
@@ -146,7 +146,7 @@ function createRepository({ api } = {}) {
|
|
|
146
146
|
}
|
|
147
147
|
const row = await options.trx("workspaces").where({ id }).forUpdate().first("id");
|
|
148
148
|
if (!row) {
|
|
149
|
-
throw new AppError(404, "Invitation workspace no longer exists.");
|
|
149
|
+
throw new AppError(404, "Invitation workspace no longer exists.", { code: "workspaces.invite.workspaceMissing" });
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
152
|
|
|
@@ -335,7 +335,9 @@ function createRepository({ api } = {}) {
|
|
|
335
335
|
if (!workspaceId) {
|
|
336
336
|
const candidate = await queryInvites({ id }, { ...options, lock: false });
|
|
337
337
|
if (!candidate[0]) {
|
|
338
|
-
throw new AppError(404, "Invitation not found or already handled."
|
|
338
|
+
throw new AppError(404, "Invitation not found or already handled.", {
|
|
339
|
+
code: "workspaces.invite.invitationNotFoundOrHandled"
|
|
340
|
+
});
|
|
339
341
|
}
|
|
340
342
|
workspaceId = normalizeInviteRecord(candidate[0]).workspaceId;
|
|
341
343
|
}
|
|
@@ -344,7 +346,9 @@ function createRepository({ api } = {}) {
|
|
|
344
346
|
}
|
|
345
347
|
const invite = await findPendingByIdForWorkspace(id, workspaceId, { ...options, lock: true });
|
|
346
348
|
if (!invite) {
|
|
347
|
-
throw new AppError(404, "Invitation not found or already handled."
|
|
349
|
+
throw new AppError(404, "Invitation not found or already handled.", {
|
|
350
|
+
code: "workspaces.invite.invitationNotFoundOrHandled"
|
|
351
|
+
});
|
|
348
352
|
}
|
|
349
353
|
const updatedAt = new Date().toISOString();
|
|
350
354
|
await api.resources.workspaceInvites.patch(
|
|
@@ -96,7 +96,7 @@ function createService({
|
|
|
96
96
|
return candidate;
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
|
-
throw new AppError(500, "Unable to generate unique workspace slug.");
|
|
99
|
+
throw new AppError(500, "Unable to generate unique workspace slug.", { code: "workspaces.workspace.slugUnavailable" });
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
async function ensureWorkspaceSettingsForWorkspace(workspace, options = {}) {
|
|
@@ -109,7 +109,7 @@ function createService({
|
|
|
109
109
|
async function ensurePersonalWorkspaceForUser(user, options = {}) {
|
|
110
110
|
const normalizedUserId = normalizeRecordId(user?.id, { fallback: null });
|
|
111
111
|
if (!normalizedUserId) {
|
|
112
|
-
throw new AppError(400, "Invalid authenticated user payload.");
|
|
112
|
+
throw new AppError(400, "Invalid authenticated user payload.", { code: "workspaces.user.invalidPayload" });
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
const existing = await workspacesRepository.findPersonalByOwnerUserId(normalizedUserId, options);
|
|
@@ -166,7 +166,7 @@ function createService({
|
|
|
166
166
|
async function ensureProvisionedWorkspaceForAuthenticatedUser(user, options = {}) {
|
|
167
167
|
const normalizedUserId = normalizeRecordId(user?.id, { fallback: null });
|
|
168
168
|
if (!normalizedUserId) {
|
|
169
|
-
throw new AppError(400, "Invalid authenticated user payload.");
|
|
169
|
+
throw new AppError(400, "Invalid authenticated user payload.", { code: "workspaces.user.invalidPayload" });
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
if (workspacePolicy.autoProvision !== true) {
|
|
@@ -182,16 +182,18 @@ function createService({
|
|
|
182
182
|
async function createWorkspaceForAuthenticatedUser(user, payload = {}, options = {}) {
|
|
183
183
|
const normalizedUserId = normalizeRecordId(user?.id, { fallback: null });
|
|
184
184
|
if (!normalizedUserId) {
|
|
185
|
-
throw new AppError(401, "Authentication required.");
|
|
185
|
+
throw new AppError(401, "Authentication required.", { code: "workspaces.auth.required" });
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
if (workspacePolicy.allowSelfCreate !== true) {
|
|
189
|
-
throw new AppError(403, "Workspace creation is disabled for this tenancy mode."
|
|
189
|
+
throw new AppError(403, "Workspace creation is disabled for this tenancy mode.", {
|
|
190
|
+
code: "workspaces.workspace.creationDisabled"
|
|
191
|
+
});
|
|
190
192
|
}
|
|
191
193
|
|
|
192
194
|
const createInput = normalizeWorkspaceCreationInput(payload);
|
|
193
195
|
if (!createInput.name) {
|
|
194
|
-
throw new AppError(400, "Workspace name is required.");
|
|
196
|
+
throw new AppError(400, "Workspace name is required.", { code: "workspaces.workspace.nameRequired" });
|
|
195
197
|
}
|
|
196
198
|
|
|
197
199
|
const slugBase = createInput.requestedSlug || toSlugPart(createInput.name);
|
|
@@ -226,22 +228,22 @@ function createService({
|
|
|
226
228
|
const requireMembership = options?.requireMembership !== false;
|
|
227
229
|
const normalizedUserId = normalizeRecordId(user?.id, { fallback: null });
|
|
228
230
|
if (!normalizedUserId) {
|
|
229
|
-
throw new AppError(401, "Authentication required.");
|
|
231
|
+
throw new AppError(401, "Authentication required.", { code: "workspaces.auth.required" });
|
|
230
232
|
}
|
|
231
233
|
|
|
232
234
|
if (resolvedTenancyMode === TENANCY_MODE_NONE) {
|
|
233
|
-
throw new AppError(403, "Workspace context is disabled.");
|
|
235
|
+
throw new AppError(403, "Workspace context is disabled.", { code: "workspaces.workspace.contextDisabled" });
|
|
234
236
|
}
|
|
235
237
|
|
|
236
238
|
const normalizedWorkspaceSlug = normalizeLowerText(workspaceSlug);
|
|
237
239
|
if (!normalizedWorkspaceSlug) {
|
|
238
|
-
throw new AppError(400, "workspaceSlug is required.");
|
|
240
|
+
throw new AppError(400, "workspaceSlug is required.", { code: "workspaces.workspace.slugRequired" });
|
|
239
241
|
}
|
|
240
242
|
|
|
241
243
|
const workspace = await workspacesRepository.findBySlug(normalizedWorkspaceSlug, options);
|
|
242
244
|
|
|
243
245
|
if (!workspace) {
|
|
244
|
-
throw new AppError(404, "Workspace not found.");
|
|
246
|
+
throw new AppError(404, "Workspace not found.", { code: "workspaces.workspace.notFound" });
|
|
245
247
|
}
|
|
246
248
|
|
|
247
249
|
let membership = await workspaceMembershipsRepository.findByWorkspaceIdAndUserId(
|
|
@@ -259,7 +261,7 @@ function createService({
|
|
|
259
261
|
}
|
|
260
262
|
|
|
261
263
|
if ((!membership || normalizeLowerText(membership.status) !== "active") && requireMembership) {
|
|
262
|
-
throw new AppError(403, "You do not have access to this workspace.");
|
|
264
|
+
throw new AppError(403, "You do not have access to this workspace.", { code: "workspaces.workspace.accessDenied" });
|
|
263
265
|
}
|
|
264
266
|
|
|
265
267
|
if (!membership || normalizeLowerText(membership.status) !== "active") {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createServerI18n } from "@jskit-ai/kernel/server/i18n";
|
|
2
|
+
import en from "../../locales/en.json" with { type: "json" };
|
|
3
|
+
|
|
4
|
+
// Workspace texts rendered on the server (invitation emails) in the recipient's
|
|
5
|
+
// language, from the app's translations under appRoot, or English.
|
|
6
|
+
function createWorkspacesServerI18n({ appRoot = "" } = {}) {
|
|
7
|
+
return createServerI18n({ appRoot, catalogs: [en] });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export { createWorkspacesServerI18n };
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import { createWorkspacesServerI18n } from "../i18n.js";
|
|
2
|
+
|
|
3
|
+
const englishI18n = createWorkspacesServerI18n();
|
|
4
|
+
|
|
5
|
+
function english(key, params) {
|
|
6
|
+
return englishI18n.t("en", key, params);
|
|
7
|
+
}
|
|
8
|
+
|
|
1
9
|
function escapeHtml(value = "") {
|
|
2
10
|
return String(value ?? "")
|
|
3
11
|
.replaceAll("&", "&")
|
|
@@ -7,29 +15,33 @@ function escapeHtml(value = "") {
|
|
|
7
15
|
.replaceAll("'", "'");
|
|
8
16
|
}
|
|
9
17
|
|
|
18
|
+
// t renders a message in the recipient's language. The markup stays here; each
|
|
19
|
+
// translated fragment, parameters included, is escaped before it enters the HTML.
|
|
10
20
|
function renderDefaultWorkspaceInviteEmail({
|
|
11
21
|
inviteUrl = "",
|
|
12
22
|
workspace = {},
|
|
13
23
|
inviter = null,
|
|
14
24
|
roleSid = "member",
|
|
15
25
|
roleLabel = "",
|
|
16
|
-
expiresAt = ""
|
|
26
|
+
expiresAt = "",
|
|
27
|
+
t = english
|
|
17
28
|
} = {}) {
|
|
18
|
-
const workspaceName =
|
|
29
|
+
const workspaceName =
|
|
30
|
+
String(workspace?.name || workspace?.slug || "").trim() || t("jskit.workspaces.inviteEmail.workspaceFallback");
|
|
19
31
|
const inviterName = String(inviter?.displayName || inviter?.email || "").trim();
|
|
20
32
|
const roleName = (typeof roleLabel === "string" ? roleLabel.trim() : "") || String(roleSid || "member").trim() || "member";
|
|
21
|
-
const expiryText = expiresAt ?
|
|
33
|
+
const expiryText = expiresAt ? t("jskit.workspaces.inviteEmail.expiry", { expiresAt }) : "";
|
|
22
34
|
const intro = inviterName
|
|
23
|
-
?
|
|
24
|
-
:
|
|
25
|
-
const subject =
|
|
26
|
-
const text = [intro, expiryText,
|
|
35
|
+
? t("jskit.workspaces.inviteEmail.introFromInviter", { inviter: inviterName, workspace: workspaceName, role: roleName })
|
|
36
|
+
: t("jskit.workspaces.inviteEmail.intro", { workspace: workspaceName, role: roleName });
|
|
37
|
+
const subject = t("jskit.workspaces.inviteEmail.subject", { workspace: workspaceName });
|
|
38
|
+
const text = [intro, expiryText, t("jskit.workspaces.inviteEmail.openLink"), inviteUrl]
|
|
27
39
|
.filter(Boolean)
|
|
28
40
|
.join("\n\n");
|
|
29
41
|
const html = [
|
|
30
42
|
`<p>${escapeHtml(intro)}</p>`,
|
|
31
43
|
expiryText ? `<p>${escapeHtml(expiryText)}</p>` : "",
|
|
32
|
-
`<p><a href="${escapeHtml(inviteUrl)}"
|
|
44
|
+
`<p><a href="${escapeHtml(inviteUrl)}">${escapeHtml(t("jskit.workspaces.inviteEmail.accept"))}</a></p>`,
|
|
33
45
|
`<p>${escapeHtml(inviteUrl)}</p>`
|
|
34
46
|
].filter(Boolean).join("\n");
|
|
35
47
|
|
|
@@ -2,10 +2,19 @@ import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
|
|
|
2
2
|
import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
3
3
|
import { encodeInviteTokenHash } from "@jskit-ai/auth-core/shared/inviteTokens";
|
|
4
4
|
import { resolveInviteTokenHash, buildInviteToken, hashInviteToken } from "@jskit-ai/auth-core/server/inviteTokens";
|
|
5
|
-
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
5
|
+
import { AppError, createValidationError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
6
6
|
import { OWNER_ROLE_ID, createWorkspaceRoleCatalog, cloneWorkspaceRoleCatalog } from "../../shared/roles.js";
|
|
7
|
+
import { createWorkspacesServerI18n } from "../i18n.js";
|
|
7
8
|
import { renderDefaultWorkspaceInviteEmail } from "./defaultWorkspaceInviteEmail.js";
|
|
8
9
|
|
|
10
|
+
const DEFAULT_LOCALE = "en";
|
|
11
|
+
|
|
12
|
+
function roleNotAssignableError() {
|
|
13
|
+
return createValidationError({
|
|
14
|
+
roleSid: { code: "workspaces.member.roleNotAssignable", message: "Role is not assignable." }
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
function createService({
|
|
10
19
|
workspaceMembershipsRepository,
|
|
11
20
|
workspaceInvitesRepository,
|
|
@@ -14,7 +23,10 @@ function createService({
|
|
|
14
23
|
workspaceInvitationsEnabled = true,
|
|
15
24
|
inviteUrlBuilder = null,
|
|
16
25
|
workspaceInviteMailer = null,
|
|
17
|
-
workspaceInviteEmailTemplate = renderDefaultWorkspaceInviteEmail
|
|
26
|
+
workspaceInviteEmailTemplate = renderDefaultWorkspaceInviteEmail,
|
|
27
|
+
i18n = createWorkspacesServerI18n(),
|
|
28
|
+
userProfilesRepository = null,
|
|
29
|
+
userSettingsRepository = null
|
|
18
30
|
} = {}) {
|
|
19
31
|
if (!workspaceMembershipsRepository || !workspaceInvitesRepository) {
|
|
20
32
|
throw new Error("workspaceMembersService requires membership and invite repositories.");
|
|
@@ -43,7 +55,7 @@ function createService({
|
|
|
43
55
|
if (resolvedWorkspaceInvitationsEnabled) {
|
|
44
56
|
return;
|
|
45
57
|
}
|
|
46
|
-
throw new AppError(403, "Workspace invitations are disabled.");
|
|
58
|
+
throw new AppError(403, "Workspace invitations are disabled.", { code: "workspaces.invite.disabled" });
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
function withRoleCatalog(payload = {}) {
|
|
@@ -100,17 +112,36 @@ function createService({
|
|
|
100
112
|
};
|
|
101
113
|
}
|
|
102
114
|
|
|
115
|
+
// The saved locale of an existing account with the invited email, else English.
|
|
116
|
+
// A failed lookup must not stop the invitation, so it also falls back to English.
|
|
117
|
+
async function resolveRecipientLocale(email, options = {}) {
|
|
118
|
+
if (typeof userProfilesRepository?.findByEmail !== "function" ||
|
|
119
|
+
typeof userSettingsRepository?.findByUserId !== "function") {
|
|
120
|
+
return DEFAULT_LOCALE;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const profile = await userProfilesRepository.findByEmail(email, options);
|
|
124
|
+
const settings = profile?.id ? await userSettingsRepository.findByUserId(profile.id, options) : null;
|
|
125
|
+
return normalizeText(settings?.locale) || DEFAULT_LOCALE;
|
|
126
|
+
} catch {
|
|
127
|
+
return DEFAULT_LOCALE;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
103
131
|
async function deliverInviteEmail({ workspace, user, invite, inviteUrl } = {}, options = {}) {
|
|
104
132
|
if (!resolvedWorkspaceInviteMailer || typeof resolvedWorkspaceInviteMailer.sendWorkspaceInvite !== "function") {
|
|
105
133
|
return {
|
|
106
134
|
status: "mailer_unconfigured",
|
|
107
135
|
message: "No workspace invite mailer is configured.",
|
|
136
|
+
messageCode: "workspaces.invite.mailerUnconfigured",
|
|
137
|
+
messageParams: {},
|
|
108
138
|
providerMessageId: ""
|
|
109
139
|
};
|
|
110
140
|
}
|
|
111
141
|
|
|
112
142
|
try {
|
|
113
143
|
const roleLabel = resolvedRoleCatalog.roles?.find((role) => role.id === invite.roleSid)?.label;
|
|
144
|
+
const locale = await resolveRecipientLocale(invite.email, options);
|
|
114
145
|
const message = await resolvedWorkspaceInviteEmailTemplate({
|
|
115
146
|
email: invite.email,
|
|
116
147
|
inviteUrl,
|
|
@@ -118,7 +149,9 @@ function createService({
|
|
|
118
149
|
inviter: user || null,
|
|
119
150
|
roleSid: invite.roleSid,
|
|
120
151
|
...(roleLabel ? { roleLabel } : {}),
|
|
121
|
-
expiresAt: invite.expiresAt
|
|
152
|
+
expiresAt: invite.expiresAt,
|
|
153
|
+
locale,
|
|
154
|
+
t: (key, params) => i18n.t(locale, key, params)
|
|
122
155
|
});
|
|
123
156
|
const result = await resolvedWorkspaceInviteMailer.sendWorkspaceInvite({
|
|
124
157
|
email: invite.email,
|
|
@@ -127,6 +160,7 @@ function createService({
|
|
|
127
160
|
inviter: user || null,
|
|
128
161
|
roleSid: invite.roleSid,
|
|
129
162
|
expiresAt: invite.expiresAt,
|
|
163
|
+
locale,
|
|
130
164
|
message
|
|
131
165
|
}, options);
|
|
132
166
|
return normalizeInviteDeliveryResult(result || { status: "sent" });
|
|
@@ -135,6 +169,8 @@ function createService({
|
|
|
135
169
|
status: "failed",
|
|
136
170
|
// Provider errors can contain the secret invitation URL.
|
|
137
171
|
message: "Unable to send workspace invite email.",
|
|
172
|
+
messageCode: "workspaces.invite.sendFailed",
|
|
173
|
+
messageParams: {},
|
|
138
174
|
providerMessageId: ""
|
|
139
175
|
};
|
|
140
176
|
}
|
|
@@ -164,24 +200,18 @@ function createService({
|
|
|
164
200
|
const memberUserId = normalizeRecordId(payload.memberUserId, { fallback: null });
|
|
165
201
|
const roleSid = normalizeLowerText(payload.roleSid || "");
|
|
166
202
|
if (!memberUserId) {
|
|
167
|
-
throw new AppError(400, "Validation failed.");
|
|
203
|
+
throw new AppError(400, "Validation failed.", { code: "validation_failed" });
|
|
168
204
|
}
|
|
169
205
|
if (!assignableRoleIds.includes(roleSid)) {
|
|
170
|
-
throw
|
|
171
|
-
details: {
|
|
172
|
-
fieldErrors: {
|
|
173
|
-
roleSid: "Role is not assignable."
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
});
|
|
206
|
+
throw roleNotAssignableError();
|
|
177
207
|
}
|
|
178
208
|
|
|
179
209
|
const existingMembership = await workspaceMembershipsRepository.findByWorkspaceIdAndUserId(workspace.id, memberUserId, options);
|
|
180
210
|
if (!existingMembership || existingMembership.status !== "active") {
|
|
181
|
-
throw new AppError(404, "Member not found.");
|
|
211
|
+
throw new AppError(404, "Member not found.", { code: "workspaces.member.notFound" });
|
|
182
212
|
}
|
|
183
213
|
if (memberUserId === normalizeRecordId(workspace.ownerUserId, { fallback: null }) || existingMembership.roleSid === OWNER_ROLE_ID) {
|
|
184
|
-
throw new AppError(409, "Cannot change workspace owner role.");
|
|
214
|
+
throw new AppError(409, "Cannot change workspace owner role.", { code: "workspaces.member.ownerRoleLocked" });
|
|
185
215
|
}
|
|
186
216
|
|
|
187
217
|
await workspaceMembershipsRepository.upsertMembership(
|
|
@@ -200,15 +230,15 @@ function createService({
|
|
|
200
230
|
async function removeMember(workspace, payload = {}, options = {}) {
|
|
201
231
|
const memberUserId = normalizeRecordId(payload.memberUserId, { fallback: null });
|
|
202
232
|
if (!memberUserId) {
|
|
203
|
-
throw new AppError(400, "Validation failed.");
|
|
233
|
+
throw new AppError(400, "Validation failed.", { code: "validation_failed" });
|
|
204
234
|
}
|
|
205
235
|
|
|
206
236
|
const existingMembership = await workspaceMembershipsRepository.findByWorkspaceIdAndUserId(workspace.id, memberUserId, options);
|
|
207
237
|
if (!existingMembership || existingMembership.status !== "active") {
|
|
208
|
-
throw new AppError(404, "Member not found.");
|
|
238
|
+
throw new AppError(404, "Member not found.", { code: "workspaces.member.notFound" });
|
|
209
239
|
}
|
|
210
240
|
if (memberUserId === normalizeRecordId(workspace.ownerUserId, { fallback: null }) || existingMembership.roleSid === OWNER_ROLE_ID) {
|
|
211
|
-
throw new AppError(409, "Cannot remove workspace owner.");
|
|
241
|
+
throw new AppError(409, "Cannot remove workspace owner.", { code: "workspaces.member.ownerNotRemovable" });
|
|
212
242
|
}
|
|
213
243
|
|
|
214
244
|
await workspaceMembershipsRepository.upsertMembership(
|
|
@@ -246,13 +276,7 @@ function createService({
|
|
|
246
276
|
const email = normalizeLowerText(payload.email);
|
|
247
277
|
const roleSid = normalizeLowerText(payload.roleSid || "member") || "member";
|
|
248
278
|
if (!assignableRoleIds.includes(roleSid)) {
|
|
249
|
-
throw
|
|
250
|
-
details: {
|
|
251
|
-
fieldErrors: {
|
|
252
|
-
roleSid: "Role is not assignable."
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
});
|
|
279
|
+
throw roleNotAssignableError();
|
|
256
280
|
}
|
|
257
281
|
|
|
258
282
|
const token = buildInviteToken();
|
|
@@ -291,16 +315,18 @@ function createService({
|
|
|
291
315
|
{ ...options, includeWorkspace: true, lock: false }
|
|
292
316
|
);
|
|
293
317
|
if (!invite) {
|
|
294
|
-
throw new AppError(404, "Invite not found or already handled.");
|
|
318
|
+
throw new AppError(404, "Invite not found or already handled.", { code: "workspaces.invite.notFoundOrHandled" });
|
|
295
319
|
}
|
|
296
320
|
if (invite.expiresAt && new Date(invite.expiresAt).getTime() < Date.now()) {
|
|
297
|
-
throw new AppError(409, "Invitation has expired.");
|
|
321
|
+
throw new AppError(409, "Invitation has expired.", { code: "workspaces.invite.expired" });
|
|
298
322
|
}
|
|
299
323
|
const token = Object.hasOwn(prepared, "inviteTokenPreview")
|
|
300
324
|
? normalizeText(prepared.inviteTokenPreview)
|
|
301
325
|
: encodeInviteTokenHash(invite.tokenHash);
|
|
302
326
|
if (!token || resolveInviteTokenHash(token) !== invite.tokenHash) {
|
|
303
|
-
throw new AppError(400, "Invitation token does not match the saved invitation."
|
|
327
|
+
throw new AppError(400, "Invitation token does not match the saved invitation.", {
|
|
328
|
+
code: "workspaces.invite.tokenMismatch"
|
|
329
|
+
});
|
|
304
330
|
}
|
|
305
331
|
const savedWorkspace = {
|
|
306
332
|
...workspace,
|
|
@@ -336,7 +362,7 @@ function createService({
|
|
|
336
362
|
ensureWorkspaceInvitationsEnabled();
|
|
337
363
|
const normalizedInviteId = normalizeRecordId(inviteId, { fallback: null });
|
|
338
364
|
if (!normalizedInviteId) {
|
|
339
|
-
throw new AppError(400, "Validation failed.");
|
|
365
|
+
throw new AppError(400, "Validation failed.", { code: "validation_failed" });
|
|
340
366
|
}
|
|
341
367
|
|
|
342
368
|
const revoke = async (trx) => {
|
|
@@ -347,7 +373,7 @@ function createService({
|
|
|
347
373
|
transactionOptions
|
|
348
374
|
);
|
|
349
375
|
if (!invite) {
|
|
350
|
-
throw new AppError(404, "Invite not found.");
|
|
376
|
+
throw new AppError(404, "Invite not found.", { code: "workspaces.invite.notFound" });
|
|
351
377
|
}
|
|
352
378
|
await workspaceInvitesRepository.revokeById(normalizedInviteId, transactionOptions);
|
|
353
379
|
return normalizeRecordId(invite.id, { fallback: null });
|
|
@@ -52,14 +52,14 @@ function createService({
|
|
|
52
52
|
}));
|
|
53
53
|
// Commit expiry revocation before reporting the rejection.
|
|
54
54
|
if (result.expired) {
|
|
55
|
-
throw Object.assign(new AppError(409, "Invitation has expired."), { transactionOutcome: "committed" });
|
|
55
|
+
throw Object.assign(new AppError(409, "Invitation has expired.", { code: "workspaces.invite.expired" }), { transactionOutcome: "committed" });
|
|
56
56
|
}
|
|
57
57
|
return result;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
function requireAuthenticatedInviteUser(user) {
|
|
61
61
|
if (!normalizeRecordId(user?.id, { fallback: null })) {
|
|
62
|
-
throw new AppError(401, "Authentication required.");
|
|
62
|
+
throw new AppError(401, "Authentication required.", { code: "workspaces.auth.required" });
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
return user;
|
|
@@ -68,12 +68,12 @@ function createService({
|
|
|
68
68
|
function requireInviteTokenHash(token) {
|
|
69
69
|
const normalizedToken = normalizeText(token);
|
|
70
70
|
if (!normalizedToken) {
|
|
71
|
-
throw new AppError(400, "Invite token is required.");
|
|
71
|
+
throw new AppError(400, "Invite token is required.", { code: "workspaces.invite.tokenRequired" });
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
const tokenHash = resolveInviteTokenHash(normalizedToken);
|
|
75
75
|
if (!tokenHash) {
|
|
76
|
-
throw new AppError(400, "Invite token is invalid.");
|
|
76
|
+
throw new AppError(400, "Invite token is invalid.", { code: "workspaces.invite.tokenInvalid" });
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
return tokenHash;
|
|
@@ -170,11 +170,15 @@ function createService({
|
|
|
170
170
|
|
|
171
171
|
const invite = await workspaceInvitesRepository.findPendingByTokenHash(tokenHash, options);
|
|
172
172
|
if (!invite) {
|
|
173
|
-
throw new AppError(404, "Invitation not found or already handled."
|
|
173
|
+
throw new AppError(404, "Invitation not found or already handled.", {
|
|
174
|
+
code: "workspaces.invite.invitationNotFoundOrHandled"
|
|
175
|
+
});
|
|
174
176
|
}
|
|
175
177
|
|
|
176
178
|
if (normalizeLowerText(invite.email) !== normalizeLowerText(actor?.email)) {
|
|
177
|
-
throw new AppError(403, "Invitation email does not match authenticated user."
|
|
179
|
+
throw new AppError(403, "Invitation email does not match authenticated user.", {
|
|
180
|
+
code: "workspaces.invite.emailMismatchUser"
|
|
181
|
+
});
|
|
178
182
|
}
|
|
179
183
|
|
|
180
184
|
return {
|
|
@@ -206,10 +210,12 @@ function createService({
|
|
|
206
210
|
async function resolveInviteContextForAuth({ token, email } = {}, options = {}) {
|
|
207
211
|
const resolved = await resolveInviteByToken(token, options);
|
|
208
212
|
if (resolved.status !== "pending") {
|
|
209
|
-
throw new AppError(409, "Invitation is not available.");
|
|
213
|
+
throw new AppError(409, "Invitation is not available.", { code: "workspaces.invite.notAvailable" });
|
|
210
214
|
}
|
|
211
215
|
if (normalizeLowerText(resolved.email) !== normalizeLowerText(email)) {
|
|
212
|
-
throw new AppError(403, "Invitation email does not match account email."
|
|
216
|
+
throw new AppError(403, "Invitation email does not match account email.", {
|
|
217
|
+
code: "workspaces.invite.emailMismatchAccount"
|
|
218
|
+
});
|
|
213
219
|
}
|
|
214
220
|
|
|
215
221
|
return {
|
|
@@ -105,6 +105,15 @@ const workspaceInviteCreateOutputSchema = createSchema({
|
|
|
105
105
|
type: "string",
|
|
106
106
|
required: true
|
|
107
107
|
},
|
|
108
|
+
messageCode: {
|
|
109
|
+
type: "string",
|
|
110
|
+
required: false
|
|
111
|
+
},
|
|
112
|
+
messageParams: {
|
|
113
|
+
type: "object",
|
|
114
|
+
required: false,
|
|
115
|
+
additionalProperties: true
|
|
116
|
+
},
|
|
108
117
|
providerMessageId: {
|
|
109
118
|
type: "string",
|
|
110
119
|
required: true
|
|
@@ -19,7 +19,9 @@ test("workspaces-core exports are explicit and aligned with production usage", (
|
|
|
19
19
|
"./server/validators/routeParamsValidator",
|
|
20
20
|
"./server/support/workspaceRouteInput",
|
|
21
21
|
"./shared/resources/workspaceResource",
|
|
22
|
-
"./shared/resources/workspaceSettingsResource"
|
|
22
|
+
"./shared/resources/workspaceSettingsResource",
|
|
23
|
+
// The generated client bootstrap registers it so clients render these server codes.
|
|
24
|
+
"./locales/en.json"
|
|
23
25
|
]
|
|
24
26
|
});
|
|
25
27
|
|
|
@@ -73,6 +73,7 @@ test("WorkspacesFeature assembles workspace behavior and explicit integrations",
|
|
|
73
73
|
const runtime = createCapabilityRuntime({
|
|
74
74
|
inputs: {
|
|
75
75
|
"auth.extensions": authExtensions,
|
|
76
|
+
"runtime.app-root": process.cwd(),
|
|
76
77
|
"runtime.bootstrap": bootstrap,
|
|
77
78
|
"runtime.config": runtimeConfig(),
|
|
78
79
|
"runtime.env": {},
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { evaluateI18nCatalogContract } from "../../../tooling/test-support/i18nCatalogContract.mjs";
|
|
6
|
+
|
|
7
|
+
const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const REPO_ROOT = path.resolve(TEST_DIRECTORY, "..", "..", "..");
|
|
9
|
+
|
|
10
|
+
test("workspaces-core English catalog matches its source", () => {
|
|
11
|
+
const { problems } = evaluateI18nCatalogContract({
|
|
12
|
+
repoRoot: REPO_ROOT,
|
|
13
|
+
packageDir: path.join(REPO_ROOT, "packages", "workspaces-core")
|
|
14
|
+
});
|
|
15
|
+
assert.deepEqual(problems, []);
|
|
16
|
+
});
|
|
@@ -182,7 +182,7 @@ describe(`users and workspaces repositories on migrated ${databaseLabel}`, () =>
|
|
|
182
182
|
locale: " EN-AU ", currencyCode: "aud", timeZone: " Australia/Perth ",
|
|
183
183
|
avatarSize: 96, theme: " dark ", ignoredField: "must not reach storage"
|
|
184
184
|
});
|
|
185
|
-
assert.equal(preferences.locale, "en-
|
|
185
|
+
assert.equal(preferences.locale, "en-AU");
|
|
186
186
|
assert.equal(preferences.currencyCode, "AUD");
|
|
187
187
|
assert.equal(preferences.timeZone, "Australia/Perth");
|
|
188
188
|
assert.equal(preferences.avatarSize, 96);
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
2
5
|
import test from "node:test";
|
|
6
|
+
import { createWorkspacesServerI18n } from "../src/server/i18n.js";
|
|
3
7
|
import { createService } from "../src/server/workspaceMembers/workspaceMembersService.js";
|
|
4
8
|
import { createWorkspaceRoleCatalog } from "../src/shared/roles.js";
|
|
5
9
|
|
|
@@ -155,6 +159,8 @@ test("workspaceMembersService.createInvite uses configured inviteExpiresInMs", a
|
|
|
155
159
|
assert.deepEqual(response.inviteDelivery, {
|
|
156
160
|
status: "mailer_unconfigured",
|
|
157
161
|
message: "No workspace invite mailer is configured.",
|
|
162
|
+
messageCode: "workspaces.invite.mailerUnconfigured",
|
|
163
|
+
messageParams: {},
|
|
158
164
|
providerMessageId: ""
|
|
159
165
|
});
|
|
160
166
|
});
|
|
@@ -245,7 +251,9 @@ test("workspaceMembersService.createInvite sends generated invite URLs through t
|
|
|
245
251
|
assert.equal(templateCalls[0].email, "invitee@example.com");
|
|
246
252
|
assert.equal(templateCalls[0].workspace.name, "Acme");
|
|
247
253
|
assert.equal(templateCalls[0].inviteUrl, response.inviteUrl);
|
|
254
|
+
assert.equal(templateCalls[0].locale, "en");
|
|
248
255
|
assert.equal(mailerCalls.length, 1);
|
|
256
|
+
assert.equal(mailerCalls[0].payload.locale, "en");
|
|
249
257
|
assert.equal(mailerCalls[0].payload.email, "invitee@example.com");
|
|
250
258
|
assert.equal(mailerCalls[0].payload.inviteUrl, response.inviteUrl);
|
|
251
259
|
assert.equal(mailerCalls[0].payload.message.subject, "Join Acme");
|
|
@@ -529,3 +537,141 @@ test("workspaceMembersService.removeMember rejects removing the owner", async ()
|
|
|
529
537
|
/Cannot remove workspace owner/
|
|
530
538
|
);
|
|
531
539
|
});
|
|
540
|
+
|
|
541
|
+
function createMailingService({ accounts = {}, appRoot = "", mailer } = {}) {
|
|
542
|
+
let savedInvite;
|
|
543
|
+
return createService({
|
|
544
|
+
workspaceMembershipsRepository: {
|
|
545
|
+
async listActiveByWorkspaceId() {
|
|
546
|
+
return [];
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
workspaceInvitesRepository: {
|
|
550
|
+
async withTransaction(work) {
|
|
551
|
+
return work({});
|
|
552
|
+
},
|
|
553
|
+
async lockWorkspaceForInvitations() {},
|
|
554
|
+
async expirePendingByWorkspaceIdAndEmail() {},
|
|
555
|
+
async insert(payload) {
|
|
556
|
+
savedInvite = { ...payload, id: "88", workspaceSlug: "acme", workspaceName: "Acme & Co" };
|
|
557
|
+
return savedInvite;
|
|
558
|
+
},
|
|
559
|
+
async listPendingByWorkspaceIdWithWorkspace() {
|
|
560
|
+
return [];
|
|
561
|
+
},
|
|
562
|
+
async findPendingByIdForWorkspace() {
|
|
563
|
+
return savedInvite;
|
|
564
|
+
}
|
|
565
|
+
},
|
|
566
|
+
inviteExpiresInMs: 30 * 60 * 1000,
|
|
567
|
+
roleCatalog: createRoleCatalog(),
|
|
568
|
+
inviteUrlBuilder: ({ token }) => `https://app.example.test/invite/${token}`,
|
|
569
|
+
workspaceInviteMailer: mailer,
|
|
570
|
+
i18n: createWorkspacesServerI18n({ appRoot }),
|
|
571
|
+
userProfilesRepository: {
|
|
572
|
+
async findByEmail(email) {
|
|
573
|
+
return accounts[email] ? { id: accounts[email].id } : null;
|
|
574
|
+
}
|
|
575
|
+
},
|
|
576
|
+
userSettingsRepository: {
|
|
577
|
+
async findByUserId(userId) {
|
|
578
|
+
const account = Object.values(accounts).find((entry) => entry.id === userId);
|
|
579
|
+
if (account?.fail) {
|
|
580
|
+
throw new Error("settings unavailable");
|
|
581
|
+
}
|
|
582
|
+
return account ? { locale: account.locale } : null;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
test("workspaceMembersService renders the default invitation email in the recipient's saved locale", async (t) => {
|
|
589
|
+
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), "workspaces-invite-i18n-"));
|
|
590
|
+
t.after(() => fs.rmSync(appRoot, { recursive: true, force: true }));
|
|
591
|
+
fs.mkdirSync(path.join(appRoot, "src", "locales", "it"), { recursive: true });
|
|
592
|
+
fs.writeFileSync(path.join(appRoot, "src", "locales", "it", "jskit.json"), JSON.stringify({
|
|
593
|
+
jskit: {
|
|
594
|
+
workspaces: {
|
|
595
|
+
inviteEmail: {
|
|
596
|
+
subject: "Sei invitato in {workspace}",
|
|
597
|
+
introFromInviter: "{inviter} ti ha invitato in {workspace} come {role}.",
|
|
598
|
+
accept: "Accetta l'invito"
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}));
|
|
603
|
+
const sent = [];
|
|
604
|
+
const service = createMailingService({
|
|
605
|
+
appRoot,
|
|
606
|
+
accounts: {
|
|
607
|
+
"rossi@example.com": { id: "21", locale: "it-it" },
|
|
608
|
+
"ada@example.com": { id: "22", locale: "en" },
|
|
609
|
+
"broken@example.com": { id: "23", fail: true }
|
|
610
|
+
},
|
|
611
|
+
mailer: {
|
|
612
|
+
async sendWorkspaceInvite(payload) {
|
|
613
|
+
sent.push(payload);
|
|
614
|
+
return { status: "sent", providerMessageId: `msg-${sent.length}` };
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
const workspace = { id: "7", slug: "acme", name: "Acme & Co", ownerUserId: "9", avatarUrl: "" };
|
|
619
|
+
const owner = { id: "9", displayName: "Tony <Owner>" };
|
|
620
|
+
const invite = (email) => service.createInvite(workspace, owner, { email, roleSid: "member" }, authorizedOptions());
|
|
621
|
+
|
|
622
|
+
await invite("rossi@example.com");
|
|
623
|
+
assert.equal(sent[0].locale, "it-it");
|
|
624
|
+
assert.equal(sent[0].message.subject, "Sei invitato in Acme & Co");
|
|
625
|
+
assert.match(sent[0].message.text, /^Tony <Owner> ti ha invitato in Acme & Co come member\./u);
|
|
626
|
+
assert.match(sent[0].message.text, /Open this link to accept the invitation:/u, "missing translations stay English");
|
|
627
|
+
assert.match(sent[0].message.html, /<p>Tony <Owner> ti ha invitato in Acme & Co come member\.<\/p>/u);
|
|
628
|
+
assert.match(sent[0].message.html, />Accetta l'invito<\/a>/u);
|
|
629
|
+
|
|
630
|
+
for (const email of ["ada@example.com", "stranger@example.com", "broken@example.com"]) {
|
|
631
|
+
await invite(email);
|
|
632
|
+
const { locale, message } = sent.at(-1);
|
|
633
|
+
assert.equal(locale, "en", email);
|
|
634
|
+
assert.equal(message.subject, "You're invited to Acme & Co", email);
|
|
635
|
+
assert.match(message.text, /^Tony <Owner> invited you to join Acme & Co as member\./u, email);
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("workspaceMembersService reports a failed delivery with a message code", async () => {
|
|
640
|
+
const service = createMailingService({
|
|
641
|
+
mailer: {
|
|
642
|
+
async sendWorkspaceInvite() {
|
|
643
|
+
throw new Error("provider rejected https://app.example.test/invite/secret");
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
const response = await service.createInvite(
|
|
648
|
+
{ id: "7", slug: "acme", name: "Acme", ownerUserId: "9", avatarUrl: "" },
|
|
649
|
+
{ id: "9" },
|
|
650
|
+
{ email: "invitee@example.com", roleSid: "member" },
|
|
651
|
+
authorizedOptions()
|
|
652
|
+
);
|
|
653
|
+
assert.deepEqual(response.inviteDelivery, {
|
|
654
|
+
status: "failed",
|
|
655
|
+
message: "Unable to send workspace invite email.",
|
|
656
|
+
messageCode: "workspaces.invite.sendFailed",
|
|
657
|
+
messageParams: {},
|
|
658
|
+
providerMessageId: ""
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test("workspaceMembersService codes a role that cannot be assigned", async () => {
|
|
663
|
+
const { service, workspace } = createFixture();
|
|
664
|
+
await assert.rejects(
|
|
665
|
+
() => service.updateMemberRole(workspace, { memberUserId: "11", roleSid: "owner" }, authorizedOptions()),
|
|
666
|
+
(error) => {
|
|
667
|
+
assert.equal(error.status, 400);
|
|
668
|
+
assert.equal(error.message, "Validation failed.");
|
|
669
|
+
assert.equal(error.code, "validation_failed");
|
|
670
|
+
assert.deepEqual(error.details, {
|
|
671
|
+
fieldErrors: { roleSid: "Role is not assignable." },
|
|
672
|
+
fieldErrorCodes: { roleSid: { code: "workspaces.member.roleNotAssignable", params: {} } }
|
|
673
|
+
});
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
);
|
|
677
|
+
});
|