@jskit-ai/workspaces-core 0.2.6 → 0.2.8

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 CHANGED
@@ -1,5 +1,79 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - The workspace, settings, member and invitation field messages are sent in the coded form `{ code, message, params }`, so their field errors translate. The English is unchanged.
6
+ - The resources' save, delete and validation feedback names catalog keys under `jskit.workspaces.feedback.*` instead of plain English; JSKIT's forms render them with `text()`, so the English shown is unchanged.
7
+ - The invitation email writes its expiry date with `Intl` in the recipient's locale and saved time zone (UTC without one), for example "November 2, 2026 at 12:00 AM UTC", instead of the raw ISO timestamp. An app's own `workspaces.invite-email-template` also receives the recipient's `timeZone` next to `locale`.
8
+
9
+ ### New codes (21)
10
+
11
+ <details><summary>Code list</summary>
12
+
13
+ - `workspaces.workspace.avatarUrlInvalid`
14
+ - `workspaces.invite.decisionRequired`
15
+ - `workspaces.invite.decisionInvalid`
16
+ - `workspaces.settings.lightPrimaryColor.required`
17
+ - `workspaces.settings.lightPrimaryColor.invalid`
18
+ - `workspaces.settings.lightSecondaryColor.required`
19
+ - `workspaces.settings.lightSecondaryColor.invalid`
20
+ - `workspaces.settings.lightSurfaceColor.required`
21
+ - `workspaces.settings.lightSurfaceColor.invalid`
22
+ - `workspaces.settings.lightSurfaceVariantColor.required`
23
+ - `workspaces.settings.lightSurfaceVariantColor.invalid`
24
+ - `workspaces.settings.darkPrimaryColor.required`
25
+ - `workspaces.settings.darkPrimaryColor.invalid`
26
+ - `workspaces.settings.darkSecondaryColor.required`
27
+ - `workspaces.settings.darkSecondaryColor.invalid`
28
+ - `workspaces.settings.darkSurfaceColor.required`
29
+ - `workspaces.settings.darkSurfaceColor.invalid`
30
+ - `workspaces.settings.darkSurfaceVariantColor.required`
31
+ - `workspaces.settings.darkSurfaceVariantColor.invalid`
32
+ - `workspaces.settings.invitesEnabled.required`
33
+ - `workspaces.settings.invitesEnabled.invalid`
34
+
35
+ </details>
36
+
37
+ ### New message keys (33)
38
+
39
+ <details><summary>Key list</summary>
40
+
41
+ - `jskit.workspaces.workspace.avatarUrlInvalid`
42
+ - `jskit.workspaces.invite.decisionRequired`
43
+ - `jskit.workspaces.invite.decisionInvalid`
44
+ - `jskit.workspaces.feedback.validationFailed`
45
+ - `jskit.workspaces.feedback.fixInvalidValues`
46
+ - `jskit.workspaces.feedback.recordSaved`
47
+ - `jskit.workspaces.feedback.recordSaveFailed`
48
+ - `jskit.workspaces.feedback.recordDeleted`
49
+ - `jskit.workspaces.feedback.recordDeleteFailed`
50
+ - `jskit.workspaces.feedback.workspaceInvalid`
51
+ - `jskit.workspaces.feedback.workspaceUpdated`
52
+ - `jskit.workspaces.feedback.workspaceUpdateFailed`
53
+ - `jskit.workspaces.feedback.settingsInvalid`
54
+ - `jskit.workspaces.feedback.settingsUpdated`
55
+ - `jskit.workspaces.feedback.settingsUpdateFailed`
56
+ - `jskit.workspaces.settings.lightPrimaryColor.required`
57
+ - `jskit.workspaces.settings.lightPrimaryColor.invalid`
58
+ - `jskit.workspaces.settings.lightSecondaryColor.required`
59
+ - `jskit.workspaces.settings.lightSecondaryColor.invalid`
60
+ - `jskit.workspaces.settings.lightSurfaceColor.required`
61
+ - `jskit.workspaces.settings.lightSurfaceColor.invalid`
62
+ - `jskit.workspaces.settings.lightSurfaceVariantColor.required`
63
+ - `jskit.workspaces.settings.lightSurfaceVariantColor.invalid`
64
+ - `jskit.workspaces.settings.darkPrimaryColor.required`
65
+ - `jskit.workspaces.settings.darkPrimaryColor.invalid`
66
+ - `jskit.workspaces.settings.darkSecondaryColor.required`
67
+ - `jskit.workspaces.settings.darkSecondaryColor.invalid`
68
+ - `jskit.workspaces.settings.darkSurfaceColor.required`
69
+ - `jskit.workspaces.settings.darkSurfaceColor.invalid`
70
+ - `jskit.workspaces.settings.darkSurfaceVariantColor.required`
71
+ - `jskit.workspaces.settings.darkSurfaceVariantColor.invalid`
72
+ - `jskit.workspaces.settings.invitesEnabled.required`
73
+ - `jskit.workspaces.settings.invitesEnabled.invalid`
74
+
75
+ </details>
76
+
3
77
  ## 0.2.0 - 2026-10-29
4
78
 
5
79
  - 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".
package/locales/en.json CHANGED
@@ -12,7 +12,8 @@
12
12
  "contextDisabled": "Workspace context is disabled.",
13
13
  "slugRequired": "workspaceSlug is required.",
14
14
  "notFound": "Workspace not found.",
15
- "accessDenied": "You do not have access to this workspace."
15
+ "accessDenied": "You do not have access to this workspace.",
16
+ "avatarUrlInvalid": "Workspace avatar URL must be a valid absolute URL (http:// or https://)."
16
17
  },
17
18
  "member": {
18
19
  "roleNotAssignable": "Role is not assignable.",
@@ -36,7 +37,9 @@
36
37
  "emailMismatchAccount": "Invitation email does not match account email.",
37
38
  "notAvailable": "Invitation is not available.",
38
39
  "mailerUnconfigured": "No workspace invite mailer is configured.",
39
- "sendFailed": "Unable to send workspace invite email."
40
+ "sendFailed": "Unable to send workspace invite email.",
41
+ "decisionRequired": "Decision is required.",
42
+ "decisionInvalid": "Decision must be accept or refuse."
40
43
  },
41
44
  "inviteEmail": {
42
45
  "subject": "You're invited to {workspace}",
@@ -46,6 +49,58 @@
46
49
  "openLink": "Open this link to accept the invitation:",
47
50
  "accept": "Accept invitation",
48
51
  "workspaceFallback": "the workspace"
52
+ },
53
+ "feedback": {
54
+ "validationFailed": "Validation failed.",
55
+ "fixInvalidValues": "Fix invalid values and try again.",
56
+ "recordSaved": "Record saved.",
57
+ "recordSaveFailed": "Unable to save record.",
58
+ "recordDeleted": "Record deleted.",
59
+ "recordDeleteFailed": "Unable to delete record.",
60
+ "workspaceInvalid": "Fix invalid workspace values and try again.",
61
+ "workspaceUpdated": "Workspace updated.",
62
+ "workspaceUpdateFailed": "Unable to update workspace.",
63
+ "settingsInvalid": "Fix invalid workspace settings values and try again.",
64
+ "settingsUpdated": "Workspace settings updated.",
65
+ "settingsUpdateFailed": "Unable to update workspace settings."
66
+ },
67
+ "settings": {
68
+ "lightPrimaryColor": {
69
+ "required": "Light primary color is required.",
70
+ "invalid": "Light primary color must be a hex color like #1867C0."
71
+ },
72
+ "lightSecondaryColor": {
73
+ "required": "Light secondary color is required.",
74
+ "invalid": "Light secondary color must be a hex color like #48A9A6."
75
+ },
76
+ "lightSurfaceColor": {
77
+ "required": "Light surface color is required.",
78
+ "invalid": "Light surface color must be a hex color like #FFFFFF."
79
+ },
80
+ "lightSurfaceVariantColor": {
81
+ "required": "Light surface variant color is required.",
82
+ "invalid": "Light surface variant color must be a hex color like #424242."
83
+ },
84
+ "darkPrimaryColor": {
85
+ "required": "Dark primary color is required.",
86
+ "invalid": "Dark primary color must be a hex color like #2196F3."
87
+ },
88
+ "darkSecondaryColor": {
89
+ "required": "Dark secondary color is required.",
90
+ "invalid": "Dark secondary color must be a hex color like #54B6B2."
91
+ },
92
+ "darkSurfaceColor": {
93
+ "required": "Dark surface color is required.",
94
+ "invalid": "Dark surface color must be a hex color like #212121."
95
+ },
96
+ "darkSurfaceVariantColor": {
97
+ "required": "Dark surface variant color is required.",
98
+ "invalid": "Dark surface variant color must be a hex color like #C8C8C8."
99
+ },
100
+ "invitesEnabled": {
101
+ "required": "invitesEnabled is required.",
102
+ "invalid": "invitesEnabled must be a boolean."
103
+ }
49
104
  }
50
105
  }
51
106
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/workspaces-core",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -22,11 +22,11 @@
22
22
  "./locales/en.json": "./locales/en.json"
23
23
  },
24
24
  "dependencies": {
25
- "@jskit-ai/database-runtime": "0.1.254",
26
- "@jskit-ai/json-rest-api-core": "0.2.5",
27
- "@jskit-ai/resource-core": "0.1.196",
28
- "@jskit-ai/resource-crud-core": "0.1.196",
29
- "@jskit-ai/users-core": "0.2.6",
25
+ "@jskit-ai/database-runtime": "0.1.256",
26
+ "@jskit-ai/json-rest-api-core": "0.2.7",
27
+ "@jskit-ai/resource-core": "0.1.198",
28
+ "@jskit-ai/resource-crud-core": "0.1.198",
29
+ "@jskit-ai/users-core": "0.2.8",
30
30
  "json-rest-schema": "^1.0.17"
31
31
  },
32
32
  "description": "Workspace tenancy runtime plus HTTP routes, role catalog, and workspace config scaffolding.",
@@ -198,8 +198,8 @@
198
198
  }
199
199
  },
200
200
  "peerDependencies": {
201
- "@jskit-ai/auth-core": "0.2.5",
202
- "@jskit-ai/http-runtime": "0.2.5",
203
- "@jskit-ai/kernel": "0.2.5"
201
+ "@jskit-ai/auth-core": "0.2.7",
202
+ "@jskit-ai/http-runtime": "0.2.7",
203
+ "@jskit-ai/kernel": "0.2.7"
204
204
  }
205
205
  }
@@ -27,7 +27,7 @@
27
27
  /**
28
28
  * The `workspaces.invite-email-template` capability: renders an invitation
29
29
  * email's `{ subject, text, html }`.
30
- * @typedef {(input: { email: string, inviteUrl: string, workspace: object, inviter: object | null, roleSid: string, roleLabel?: string, expiresAt: string, locale: string, t: (key: string, params?: object) => string }) => object | Promise<object>} WorkspaceInviteEmailTemplate
30
+ * @typedef {(input: { email: string, inviteUrl: string, workspace: object, inviter: object | null, roleSid: string, roleLabel?: string, expiresAt: string, locale: string, timeZone: string, t: (key: string, params?: object) => string }) => object | Promise<object>} WorkspaceInviteEmailTemplate
31
31
  */
32
32
 
33
33
  /**
@@ -7,18 +7,60 @@ function escapeHtml(value) {
7
7
  .replaceAll("'", "&#39;");
8
8
  }
9
9
 
10
+ const EXPIRY_FORMAT = Object.freeze({
11
+ year: "numeric",
12
+ month: "long",
13
+ day: "numeric",
14
+ hour: "numeric",
15
+ minute: "2-digit",
16
+ timeZoneName: "short"
17
+ });
18
+
19
+ /**
20
+ * The expiry date in the recipient's language and time zone. A locale or time zone
21
+ * Intl does not know falls back to English or UTC; a value that is not a date is
22
+ * shown as sent.
23
+ * @param {string} expiresAt
24
+ * @param {string} locale
25
+ * @param {string} timeZone
26
+ */
27
+ function formatInviteExpiry(expiresAt, locale, timeZone) {
28
+ const date = new Date(expiresAt);
29
+ if (!expiresAt || Number.isNaN(date.getTime())) {
30
+ return String(expiresAt || "");
31
+ }
32
+ for (const [tryLocale, tryTimeZone] of [[locale, timeZone], [locale, "UTC"], ["en", "UTC"]]) {
33
+ try {
34
+ return new Intl.DateTimeFormat(tryLocale || "en", { ...EXPIRY_FORMAT, timeZone: tryTimeZone || "UTC" }).format(date);
35
+ } catch {
36
+ // An unknown locale or time zone: try the next fallback.
37
+ }
38
+ }
39
+ return String(expiresAt);
40
+ }
41
+
10
42
  /**
11
43
  * The `workspaces.invite-email-template` default. t renders a message in the
12
44
  * recipient's language. The markup stays here; each translated fragment,
13
45
  * parameters included, is escaped before it enters the HTML.
14
46
  * @type {import("../types.js").WorkspaceInviteEmailTemplate}
15
47
  */
16
- function renderDefaultWorkspaceInviteEmail({ inviteUrl, workspace, inviter, roleSid, roleLabel, expiresAt, t }) {
48
+ function renderDefaultWorkspaceInviteEmail({
49
+ inviteUrl,
50
+ workspace,
51
+ inviter,
52
+ roleSid,
53
+ roleLabel,
54
+ expiresAt,
55
+ locale = "en",
56
+ timeZone = "UTC",
57
+ t
58
+ }) {
17
59
  const workspaceName =
18
60
  String(workspace.name || workspace.slug || "").trim() || t("jskit.workspaces.inviteEmail.workspaceFallback");
19
61
  const inviterName = String(inviter?.displayName || inviter?.email || "").trim();
20
62
  const roleName = roleLabel || roleSid;
21
- const expiryText = t("jskit.workspaces.inviteEmail.expiry", { expiresAt });
63
+ const expiryText = t("jskit.workspaces.inviteEmail.expiry", { expiresAt: formatInviteExpiry(expiresAt, locale, timeZone) });
22
64
  const intro = inviterName
23
65
  ? t("jskit.workspaces.inviteEmail.introFromInviter", { inviter: inviterName, workspace: workspaceName, role: roleName })
24
66
  : t("jskit.workspaces.inviteEmail.intro", { workspace: workspaceName, role: roleName });
@@ -38,4 +80,4 @@ function renderDefaultWorkspaceInviteEmail({ inviteUrl, workspace, inviter, role
38
80
  };
39
81
  }
40
82
 
41
- export { renderDefaultWorkspaceInviteEmail };
83
+ export { formatInviteExpiry, renderDefaultWorkspaceInviteEmail };
@@ -11,6 +11,7 @@ import {
11
11
  } from "./unconfiguredWorkspaceInviteMailer.js";
12
12
 
13
13
  const DEFAULT_LOCALE = "en";
14
+ const DEFAULT_TIME_ZONE = "UTC";
14
15
 
15
16
  function roleNotAssignableError() {
16
17
  return createValidationError({
@@ -128,15 +129,19 @@ function createService({
128
129
  };
129
130
  }
130
131
 
131
- // The saved locale of an existing account with the invited email, else English.
132
- // A failed lookup must not stop the invitation, so it also falls back to English.
133
- async function resolveRecipientLocale(email, options = {}) {
132
+ // The saved locale and time zone of an existing account with the invited email,
133
+ // else English and UTC. A failed lookup must not stop the invitation, so it also
134
+ // falls back to them.
135
+ async function resolveRecipientSettings(email, options = {}) {
134
136
  try {
135
137
  const profile = await userProfilesRepository.findByEmail(email, options);
136
138
  const settings = profile?.id ? await userSettingsRepository.findByUserId(profile.id, options) : null;
137
- return normalizeText(settings?.locale) || DEFAULT_LOCALE;
139
+ return {
140
+ locale: normalizeText(settings?.locale) || DEFAULT_LOCALE,
141
+ timeZone: normalizeText(settings?.timeZone) || DEFAULT_TIME_ZONE
142
+ };
138
143
  } catch {
139
- return DEFAULT_LOCALE;
144
+ return { locale: DEFAULT_LOCALE, timeZone: DEFAULT_TIME_ZONE };
140
145
  }
141
146
  }
142
147
 
@@ -148,7 +153,7 @@ function createService({
148
153
  }
149
154
  try {
150
155
  const roleLabel = roleCatalog.roles.find((role) => role.id === invite.roleSid)?.label;
151
- const locale = await resolveRecipientLocale(invite.email, options);
156
+ const { locale, timeZone } = await resolveRecipientSettings(invite.email, options);
152
157
  const message = await workspaceInviteEmailTemplate({
153
158
  email: invite.email,
154
159
  inviteUrl,
@@ -158,6 +163,7 @@ function createService({
158
163
  ...(roleLabel ? { roleLabel } : {}),
159
164
  expiresAt: invite.expiresAt,
160
165
  locale,
166
+ timeZone,
161
167
  t: (key, params) => i18n.t(locale, key, params)
162
168
  });
163
169
  const result = await workspaceInviteMailer.sendWorkspaceInvite({
@@ -1,7 +1,11 @@
1
- /** The messages a resource's operations show when their input fails validation. */
1
+ /**
2
+ * The messages a resource's operations show when their input fails validation:
3
+ * validation is a message key the client renders; apiValidation is the API's
4
+ * English with its code.
5
+ */
2
6
  const OPERATION_MESSAGES = Object.freeze({
3
- validation: "Validation failed.",
4
- apiValidation: "Validation failed."
7
+ validation: "jskit.workspaces.feedback.validationFailed",
8
+ apiValidation: Object.freeze({ code: "validation_failed", message: "Validation failed." })
5
9
  });
6
10
 
7
11
  export { OPERATION_MESSAGES };
@@ -150,11 +150,11 @@ const workspaceInvitesResource = defineCrudResource({
150
150
  }
151
151
  },
152
152
  messages: {
153
- validation: "Fix invalid values and try again.",
154
- saveSuccess: "Record saved.",
155
- saveError: "Unable to save record.",
156
- deleteSuccess: "Record deleted.",
157
- deleteError: "Unable to delete record."
153
+ validation: "jskit.workspaces.feedback.fixInvalidValues",
154
+ saveSuccess: "jskit.workspaces.feedback.recordSaved",
155
+ saveError: "jskit.workspaces.feedback.recordSaveFailed",
156
+ deleteSuccess: "jskit.workspaces.feedback.recordDeleted",
157
+ deleteError: "jskit.workspaces.feedback.recordDeleteFailed"
158
158
  },
159
159
  crudOperations: ["list", "view", "create", "patch"]
160
160
  });
@@ -1,9 +1,18 @@
1
1
  import { RECORD_ID_PATTERN } from "@jskit-ai/kernel/shared/validators";
2
2
  import { createSchema } from "json-rest-schema";
3
+ import { deepFreeze } from "@jskit-ai/kernel/shared/support/deepFreeze";
3
4
  import { defineResource } from "@jskit-ai/resource-core/shared/resource";
4
5
  import { OPERATION_MESSAGES } from "../operationMessages.js";
5
6
  import { workspaceRoleCatalogSchema } from "./workspaceRoleCatalogSchema.js";
6
7
 
8
+ // Each field message: its English and the code a client renders it from in the user's language.
9
+ const WORKSPACE_MEMBERS_FIELD_MESSAGES = deepFreeze({
10
+ inviteTokenRequired: { code: "workspaces.invite.tokenRequired", message: "Invite token is required." },
11
+ inviteTokenInvalid: { code: "workspaces.invite.tokenInvalid", message: "Invite token is invalid." },
12
+ decisionRequired: { code: "workspaces.invite.decisionRequired", message: "Decision is required." },
13
+ decisionInvalid: { code: "workspaces.invite.decisionInvalid", message: "Decision must be accept or refuse." }
14
+ });
15
+
7
16
  const workspaceSummaryOutputSchema = createSchema({
8
17
  id: { type: "string", required: true, minLength: 1, pattern: RECORD_ID_PATTERN },
9
18
  slug: { type: "string", required: true, minLength: 1, maxLength: 120 },
@@ -180,9 +189,9 @@ const redeemInviteBodySchema = createSchema({
180
189
  required: true,
181
190
  minLength: 1,
182
191
  messages: {
183
- required: "Invite token is required.",
184
- minLength: "Invite token is required.",
185
- default: "Invite token is invalid."
192
+ required: WORKSPACE_MEMBERS_FIELD_MESSAGES.inviteTokenRequired,
193
+ minLength: WORKSPACE_MEMBERS_FIELD_MESSAGES.inviteTokenRequired,
194
+ default: WORKSPACE_MEMBERS_FIELD_MESSAGES.inviteTokenInvalid
186
195
  }
187
196
  },
188
197
  decision: {
@@ -190,8 +199,8 @@ const redeemInviteBodySchema = createSchema({
190
199
  required: true,
191
200
  enum: ["accept", "refuse"],
192
201
  messages: {
193
- required: "Decision is required.",
194
- default: "Decision must be accept or refuse."
202
+ required: WORKSPACE_MEMBERS_FIELD_MESSAGES.decisionRequired,
203
+ default: WORKSPACE_MEMBERS_FIELD_MESSAGES.decisionInvalid
195
204
  }
196
205
  }
197
206
  });
@@ -87,11 +87,11 @@ const workspaceMembershipsResource = defineCrudResource({
87
87
  }
88
88
  },
89
89
  messages: {
90
- validation: "Fix invalid values and try again.",
91
- saveSuccess: "Record saved.",
92
- saveError: "Unable to save record.",
93
- deleteSuccess: "Record deleted.",
94
- deleteError: "Unable to delete record."
90
+ validation: "jskit.workspaces.feedback.fixInvalidValues",
91
+ saveSuccess: "jskit.workspaces.feedback.recordSaved",
92
+ saveError: "jskit.workspaces.feedback.recordSaveFailed",
93
+ deleteSuccess: "jskit.workspaces.feedback.recordDeleted",
94
+ deleteError: "jskit.workspaces.feedback.recordDeleteFailed"
95
95
  },
96
96
  crudOperations: ["list", "view", "create", "patch"]
97
97
  });
@@ -1,17 +1,24 @@
1
1
  import { RECORD_ID_PATTERN } from "@jskit-ai/kernel/shared/validators";
2
2
  import { createSchema } from "json-rest-schema";
3
+ import { deepFreeze } from "@jskit-ai/kernel/shared/support/deepFreeze";
3
4
  import { defineResource } from "@jskit-ai/resource-core/shared/resource";
4
5
  import { OPERATION_MESSAGES } from "../operationMessages.js";
5
6
 
7
+ // Each field message: its English and the code a client renders it from in the user's language.
8
+ const WORKSPACE_PENDING_INVITATIONS_FIELD_MESSAGES = deepFreeze({
9
+ inviteTokenRequired: { code: "workspaces.invite.tokenRequired", message: "Invite token is required." },
10
+ inviteTokenInvalid: { code: "workspaces.invite.tokenInvalid", message: "Invite token is invalid." }
11
+ });
12
+
6
13
  const invitationTokenQuerySchema = createSchema({
7
14
  token: {
8
15
  type: "string",
9
16
  required: true,
10
17
  minLength: 1,
11
18
  messages: {
12
- required: "Invite token is required.",
13
- minLength: "Invite token is required.",
14
- default: "Invite token is invalid."
19
+ required: WORKSPACE_PENDING_INVITATIONS_FIELD_MESSAGES.inviteTokenRequired,
20
+ minLength: WORKSPACE_PENDING_INVITATIONS_FIELD_MESSAGES.inviteTokenRequired,
21
+ default: WORKSPACE_PENDING_INVITATIONS_FIELD_MESSAGES.inviteTokenInvalid
15
22
  }
16
23
  }
17
24
  });
@@ -1,8 +1,18 @@
1
1
  import { createSchema } from "json-rest-schema";
2
+ import { deepFreeze } from "@jskit-ai/kernel/shared/support/deepFreeze";
2
3
  import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
4
  import { RECORD_ID_PATTERN } from "@jskit-ai/kernel/shared/validators";
4
5
  import { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudResource";
5
6
  import { createSchemaDefinition } from "@jskit-ai/resource-core/shared/resource";
7
+ import { OPERATION_MESSAGES } from "../operationMessages.js";
8
+
9
+ // Each field message: its English and the code a client renders it from in the user's language.
10
+ const WORKSPACE_FIELD_MESSAGES = deepFreeze({
11
+ avatarUrlInvalid: {
12
+ code: "workspaces.workspace.avatarUrlInvalid",
13
+ message: "Workspace avatar URL must be a valid absolute URL (http:// or https://)."
14
+ }
15
+ });
6
16
 
7
17
  function normalizeWorkspaceAvatarUrl(value) {
8
18
  const avatarUrl = normalizeText(value);
@@ -50,8 +60,8 @@ const workspacePatchBodySchema = createSchema({
50
60
  required: false,
51
61
  pattern: "^(https?://.+)?$",
52
62
  messages: {
53
- pattern: "Workspace avatar URL must be a valid absolute URL (http:// or https://).",
54
- default: "Workspace avatar URL must be a valid absolute URL (http:// or https://)."
63
+ pattern: WORKSPACE_FIELD_MESSAGES.avatarUrlInvalid,
64
+ default: WORKSPACE_FIELD_MESSAGES.avatarUrlInvalid
55
65
  }
56
66
  }
57
67
  });
@@ -133,10 +143,10 @@ const resource = defineCrudResource({
133
143
  }
134
144
  },
135
145
  messages: {
136
- validation: "Fix invalid workspace values and try again.",
137
- saveSuccess: "Workspace updated.",
138
- saveError: "Unable to update workspace.",
139
- apiValidation: "Validation failed."
146
+ validation: "jskit.workspaces.feedback.workspaceInvalid",
147
+ saveSuccess: "jskit.workspaces.feedback.workspaceUpdated",
148
+ saveError: "jskit.workspaces.feedback.workspaceUpdateFailed",
149
+ apiValidation: OPERATION_MESSAGES.apiValidation
140
150
  },
141
151
  crudOperations: ["view", "list", "create", "replace", "patch"],
142
152
  crud: {
@@ -9,6 +9,83 @@ import {
9
9
  DEFAULT_WORKSPACE_LIGHT_PALETTE,
10
10
  WORKSPACE_SETTINGS_COLOR_FIELDS
11
11
  } from "../settings.js";
12
+ import { OPERATION_MESSAGES } from "../operationMessages.js";
13
+
14
+ // Each field message: its English and the code a client renders it from in the user's language.
15
+ const WORKSPACE_SETTINGS_FIELD_MESSAGES = deepFreeze({
16
+ lightPrimaryColorRequired: {
17
+ code: "workspaces.settings.lightPrimaryColor.required",
18
+ message: "Light primary color is required."
19
+ },
20
+ lightPrimaryColorInvalid: {
21
+ code: "workspaces.settings.lightPrimaryColor.invalid",
22
+ message: "Light primary color must be a hex color like #1867C0."
23
+ },
24
+ lightSecondaryColorRequired: {
25
+ code: "workspaces.settings.lightSecondaryColor.required",
26
+ message: "Light secondary color is required."
27
+ },
28
+ lightSecondaryColorInvalid: {
29
+ code: "workspaces.settings.lightSecondaryColor.invalid",
30
+ message: "Light secondary color must be a hex color like #48A9A6."
31
+ },
32
+ lightSurfaceColorRequired: {
33
+ code: "workspaces.settings.lightSurfaceColor.required",
34
+ message: "Light surface color is required."
35
+ },
36
+ lightSurfaceColorInvalid: {
37
+ code: "workspaces.settings.lightSurfaceColor.invalid",
38
+ message: "Light surface color must be a hex color like #FFFFFF."
39
+ },
40
+ lightSurfaceVariantColorRequired: {
41
+ code: "workspaces.settings.lightSurfaceVariantColor.required",
42
+ message: "Light surface variant color is required."
43
+ },
44
+ lightSurfaceVariantColorInvalid: {
45
+ code: "workspaces.settings.lightSurfaceVariantColor.invalid",
46
+ message: "Light surface variant color must be a hex color like #424242."
47
+ },
48
+ darkPrimaryColorRequired: {
49
+ code: "workspaces.settings.darkPrimaryColor.required",
50
+ message: "Dark primary color is required."
51
+ },
52
+ darkPrimaryColorInvalid: {
53
+ code: "workspaces.settings.darkPrimaryColor.invalid",
54
+ message: "Dark primary color must be a hex color like #2196F3."
55
+ },
56
+ darkSecondaryColorRequired: {
57
+ code: "workspaces.settings.darkSecondaryColor.required",
58
+ message: "Dark secondary color is required."
59
+ },
60
+ darkSecondaryColorInvalid: {
61
+ code: "workspaces.settings.darkSecondaryColor.invalid",
62
+ message: "Dark secondary color must be a hex color like #54B6B2."
63
+ },
64
+ darkSurfaceColorRequired: {
65
+ code: "workspaces.settings.darkSurfaceColor.required",
66
+ message: "Dark surface color is required."
67
+ },
68
+ darkSurfaceColorInvalid: {
69
+ code: "workspaces.settings.darkSurfaceColor.invalid",
70
+ message: "Dark surface color must be a hex color like #212121."
71
+ },
72
+ darkSurfaceVariantColorRequired: {
73
+ code: "workspaces.settings.darkSurfaceVariantColor.required",
74
+ message: "Dark surface variant color is required."
75
+ },
76
+ darkSurfaceVariantColorInvalid: {
77
+ code: "workspaces.settings.darkSurfaceVariantColor.invalid",
78
+ message: "Dark surface variant color must be a hex color like #C8C8C8."
79
+ },
80
+ invitesEnabledRequired: {
81
+ code: "workspaces.settings.invitesEnabled.required",
82
+ message: "invitesEnabled is required."
83
+ },
84
+ invitesEnabledInvalid: {
85
+ code: "workspaces.settings.invitesEnabled.invalid",
86
+ message: "invitesEnabled must be a boolean."
87
+ }
88
+ });
12
89
 
13
90
  const WORKSPACE_SETTINGS_FIELD_KEYS = deepFreeze([...WORKSPACE_SETTINGS_COLOR_FIELDS, "invitesEnabled"]);
14
91
 
@@ -20,9 +97,9 @@ const workspaceSettingsBodySchema = createSchema({
20
97
  maxLength: 7,
21
98
  pattern: "^#[0-9A-Fa-f]{6}$",
22
99
  messages: {
23
- required: "Light primary color is required.",
24
- pattern: "Light primary color must be a hex color like #1867C0.",
25
- default: "Light primary color must be a hex color like #1867C0."
100
+ required: WORKSPACE_SETTINGS_FIELD_MESSAGES.lightPrimaryColorRequired,
101
+ pattern: WORKSPACE_SETTINGS_FIELD_MESSAGES.lightPrimaryColorInvalid,
102
+ default: WORKSPACE_SETTINGS_FIELD_MESSAGES.lightPrimaryColorInvalid
26
103
  }
27
104
  },
28
105
  lightSecondaryColor: {
@@ -32,9 +109,9 @@ const workspaceSettingsBodySchema = createSchema({
32
109
  maxLength: 7,
33
110
  pattern: "^#[0-9A-Fa-f]{6}$",
34
111
  messages: {
35
- required: "Light secondary color is required.",
36
- pattern: "Light secondary color must be a hex color like #48A9A6.",
37
- default: "Light secondary color must be a hex color like #48A9A6."
112
+ required: WORKSPACE_SETTINGS_FIELD_MESSAGES.lightSecondaryColorRequired,
113
+ pattern: WORKSPACE_SETTINGS_FIELD_MESSAGES.lightSecondaryColorInvalid,
114
+ default: WORKSPACE_SETTINGS_FIELD_MESSAGES.lightSecondaryColorInvalid
38
115
  }
39
116
  },
40
117
  lightSurfaceColor: {
@@ -44,9 +121,9 @@ const workspaceSettingsBodySchema = createSchema({
44
121
  maxLength: 7,
45
122
  pattern: "^#[0-9A-Fa-f]{6}$",
46
123
  messages: {
47
- required: "Light surface color is required.",
48
- pattern: "Light surface color must be a hex color like #FFFFFF.",
49
- default: "Light surface color must be a hex color like #FFFFFF."
124
+ required: WORKSPACE_SETTINGS_FIELD_MESSAGES.lightSurfaceColorRequired,
125
+ pattern: WORKSPACE_SETTINGS_FIELD_MESSAGES.lightSurfaceColorInvalid,
126
+ default: WORKSPACE_SETTINGS_FIELD_MESSAGES.lightSurfaceColorInvalid
50
127
  }
51
128
  },
52
129
  lightSurfaceVariantColor: {
@@ -56,9 +133,9 @@ const workspaceSettingsBodySchema = createSchema({
56
133
  maxLength: 7,
57
134
  pattern: "^#[0-9A-Fa-f]{6}$",
58
135
  messages: {
59
- required: "Light surface variant color is required.",
60
- pattern: "Light surface variant color must be a hex color like #424242.",
61
- default: "Light surface variant color must be a hex color like #424242."
136
+ required: WORKSPACE_SETTINGS_FIELD_MESSAGES.lightSurfaceVariantColorRequired,
137
+ pattern: WORKSPACE_SETTINGS_FIELD_MESSAGES.lightSurfaceVariantColorInvalid,
138
+ default: WORKSPACE_SETTINGS_FIELD_MESSAGES.lightSurfaceVariantColorInvalid
62
139
  }
63
140
  },
64
141
  darkPrimaryColor: {
@@ -68,9 +145,9 @@ const workspaceSettingsBodySchema = createSchema({
68
145
  maxLength: 7,
69
146
  pattern: "^#[0-9A-Fa-f]{6}$",
70
147
  messages: {
71
- required: "Dark primary color is required.",
72
- pattern: "Dark primary color must be a hex color like #2196F3.",
73
- default: "Dark primary color must be a hex color like #2196F3."
148
+ required: WORKSPACE_SETTINGS_FIELD_MESSAGES.darkPrimaryColorRequired,
149
+ pattern: WORKSPACE_SETTINGS_FIELD_MESSAGES.darkPrimaryColorInvalid,
150
+ default: WORKSPACE_SETTINGS_FIELD_MESSAGES.darkPrimaryColorInvalid
74
151
  }
75
152
  },
76
153
  darkSecondaryColor: {
@@ -80,9 +157,9 @@ const workspaceSettingsBodySchema = createSchema({
80
157
  maxLength: 7,
81
158
  pattern: "^#[0-9A-Fa-f]{6}$",
82
159
  messages: {
83
- required: "Dark secondary color is required.",
84
- pattern: "Dark secondary color must be a hex color like #54B6B2.",
85
- default: "Dark secondary color must be a hex color like #54B6B2."
160
+ required: WORKSPACE_SETTINGS_FIELD_MESSAGES.darkSecondaryColorRequired,
161
+ pattern: WORKSPACE_SETTINGS_FIELD_MESSAGES.darkSecondaryColorInvalid,
162
+ default: WORKSPACE_SETTINGS_FIELD_MESSAGES.darkSecondaryColorInvalid
86
163
  }
87
164
  },
88
165
  darkSurfaceColor: {
@@ -92,9 +169,9 @@ const workspaceSettingsBodySchema = createSchema({
92
169
  maxLength: 7,
93
170
  pattern: "^#[0-9A-Fa-f]{6}$",
94
171
  messages: {
95
- required: "Dark surface color is required.",
96
- pattern: "Dark surface color must be a hex color like #212121.",
97
- default: "Dark surface color must be a hex color like #212121."
172
+ required: WORKSPACE_SETTINGS_FIELD_MESSAGES.darkSurfaceColorRequired,
173
+ pattern: WORKSPACE_SETTINGS_FIELD_MESSAGES.darkSurfaceColorInvalid,
174
+ default: WORKSPACE_SETTINGS_FIELD_MESSAGES.darkSurfaceColorInvalid
98
175
  }
99
176
  },
100
177
  darkSurfaceVariantColor: {
@@ -104,9 +181,9 @@ const workspaceSettingsBodySchema = createSchema({
104
181
  maxLength: 7,
105
182
  pattern: "^#[0-9A-Fa-f]{6}$",
106
183
  messages: {
107
- required: "Dark surface variant color is required.",
108
- pattern: "Dark surface variant color must be a hex color like #C8C8C8.",
109
- default: "Dark surface variant color must be a hex color like #C8C8C8."
184
+ required: WORKSPACE_SETTINGS_FIELD_MESSAGES.darkSurfaceVariantColorRequired,
185
+ pattern: WORKSPACE_SETTINGS_FIELD_MESSAGES.darkSurfaceVariantColorInvalid,
186
+ default: WORKSPACE_SETTINGS_FIELD_MESSAGES.darkSurfaceVariantColorInvalid
110
187
  }
111
188
  },
112
189
  invitesEnabled: {
@@ -114,8 +191,8 @@ const workspaceSettingsBodySchema = createSchema({
114
191
  required: true,
115
192
  strictBoolean: true,
116
193
  messages: {
117
- required: "invitesEnabled is required.",
118
- default: "invitesEnabled must be a boolean."
194
+ required: WORKSPACE_SETTINGS_FIELD_MESSAGES.invitesEnabledRequired,
195
+ default: WORKSPACE_SETTINGS_FIELD_MESSAGES.invitesEnabledInvalid
119
196
  }
120
197
  }
121
198
  });
@@ -273,10 +350,10 @@ const workspaceSettingsResource = defineCrudResource({
273
350
  }
274
351
  },
275
352
  messages: {
276
- validation: "Fix invalid workspace settings values and try again.",
277
- saveSuccess: "Workspace settings updated.",
278
- saveError: "Unable to update workspace settings.",
279
- apiValidation: "Validation failed."
353
+ validation: "jskit.workspaces.feedback.settingsInvalid",
354
+ saveSuccess: "jskit.workspaces.feedback.settingsUpdated",
355
+ saveError: "jskit.workspaces.feedback.settingsUpdateFailed",
356
+ apiValidation: OPERATION_MESSAGES.apiValidation
280
357
  },
281
358
  crudOperations: ["view", "list", "create", "replace", "patch"],
282
359
  crud: {
@@ -11,7 +11,10 @@ import { createWorkspacesServerI18n } from "../src/server/i18n.js";
11
11
  import { noInviteAcceptanceParticipant } from "../src/server/workspacePendingInvitations/noInviteAcceptanceParticipant.js";
12
12
  import { createService as createSettingsService } from "../src/server/workspaceSettings/workspaceSettingsService.js";
13
13
  import { createService as createPendingService } from "../src/server/workspacePendingInvitations/workspacePendingInvitationsService.js";
14
- import { renderDefaultWorkspaceInviteEmail } from "../src/server/workspaceMembers/defaultWorkspaceInviteEmail.js";
14
+ import {
15
+ formatInviteExpiry,
16
+ renderDefaultWorkspaceInviteEmail
17
+ } from "../src/server/workspaceMembers/defaultWorkspaceInviteEmail.js";
15
18
  import { createWorkspaceInviteUrlBuilder } from "../src/server/workspaceMembers/workspaceInviteUrls.js";
16
19
  import { unconfiguredWorkspaceInviteMailer } from "../src/server/workspaceMembers/unconfiguredWorkspaceInviteMailer.js";
17
20
 
@@ -155,3 +158,33 @@ test("default invitation email uses the configured label with HTML escaping and
155
158
  assert.match(labelled.html, /as Member &lt;team&gt;/u);
156
159
  assert.match(renderDefaultWorkspaceInviteEmail(input).text, /as worker/u);
157
160
  });
161
+
162
+ test("default invitation email writes the expiry as a date in the recipient's locale and time zone", () => {
163
+ const i18n = createWorkspacesServerI18n();
164
+ const message = renderDefaultWorkspaceInviteEmail({
165
+ email: "reader@example.test",
166
+ inviteUrl: "/invite/abc",
167
+ workspace: { name: "Acme", slug: "acme" },
168
+ inviter: null,
169
+ roleSid: "worker",
170
+ expiresAt: "2026-11-02T00:00:00.000Z",
171
+ locale: "en",
172
+ timeZone: "UTC",
173
+ t: (key, params) => i18n.t("en", key, params)
174
+ });
175
+ assert.match(message.text, /November 2, 2026/u);
176
+ assert.doesNotMatch(message.text, /2026-11-02T00:00:00\.000Z/u);
177
+
178
+ assert.match(formatInviteExpiry("2026-11-02T00:00:00.000Z", "it", "Europe/Rome"), /2 novembre 2026/u);
179
+ assert.match(formatInviteExpiry("2026-11-02T00:00:00.000Z", "en", "America/New_York"), /November 1, 2026/u);
180
+ // An unknown time zone or locale falls back to UTC or English; a value that is not a date is shown as sent.
181
+ assert.equal(
182
+ formatInviteExpiry("2026-11-02T00:00:00.000Z", "en", "Not/AZone"),
183
+ formatInviteExpiry("2026-11-02T00:00:00.000Z", "en", "UTC")
184
+ );
185
+ assert.equal(
186
+ formatInviteExpiry("2026-11-02T00:00:00.000Z", "not a locale!", "UTC"),
187
+ formatInviteExpiry("2026-11-02T00:00:00.000Z", "en", "UTC")
188
+ );
189
+ assert.equal(formatInviteExpiry("soon", "en", "UTC"), "soon");
190
+ });