@budibase/worker 3.24.1 → 3.24.3

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.
@@ -4,6 +4,7 @@ const pro = {
4
4
  features: {
5
5
  ...actual.features,
6
6
  isSSOEnforced: jest.fn(),
7
+ isTranslationsEnabled: jest.fn(() => Promise.resolve(true)),
7
8
  },
8
9
  licensing: {
9
10
  keys: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@budibase/worker",
3
3
  "email": "hi@budibase.com",
4
- "version": "3.24.1",
4
+ "version": "3.24.3",
5
5
  "description": "Budibase background service",
6
6
  "main": "src/index.ts",
7
7
  "repository": {
@@ -70,7 +70,7 @@
70
70
  "server-destroy": "1.0.1",
71
71
  "undici": "^7.16.0",
72
72
  "uuid": "^8.3.2",
73
- "yaml": "^2.8.0"
73
+ "yaml": "^2.8.2"
74
74
  },
75
75
  "devDependencies": {
76
76
  "@types/jsonwebtoken": "9.0.3",
@@ -109,5 +109,5 @@
109
109
  }
110
110
  }
111
111
  },
112
- "gitHead": "6838c955ce1adef31581d699de2c9a6c81d37de5"
112
+ "gitHead": "a85e0ba10b7d19e204062fb63dc306953284e277"
113
113
  }
@@ -10,7 +10,10 @@ import {
10
10
  tenancy,
11
11
  } from "@budibase/backend-core"
12
12
  import * as pro from "@budibase/pro"
13
- import { BUILDER_URLS } from "@budibase/shared-core"
13
+ import {
14
+ BUILDER_URLS,
15
+ filterValidTranslationOverrides,
16
+ } from "@budibase/shared-core"
14
17
  import {
15
18
  AIInnerConfig,
16
19
  Config,
@@ -21,13 +24,16 @@ import {
21
24
  FindConfigResponse,
22
25
  GetPublicOIDCConfigResponse,
23
26
  GetPublicSettingsResponse,
27
+ GetPublicTranslationsResponse,
24
28
  GoogleInnerConfig,
29
+ TranslationOverrides,
25
30
  isAIConfig,
26
31
  isGoogleConfig,
27
32
  isOIDCConfig,
28
33
  isRecaptchaConfig,
29
34
  isSettingsConfig,
30
35
  isSMTPConfig,
36
+ isTranslationsConfig,
31
37
  OIDCConfigs,
32
38
  OIDCLogosConfig,
33
39
  PASSWORD_REPLACEMENT,
@@ -39,9 +45,12 @@ import {
39
45
  SMTPInnerConfig,
40
46
  SSOConfig,
41
47
  SSOConfigType,
48
+ TranslationsConfigInner,
42
49
  UploadConfigFileResponse,
43
50
  UserCtx,
44
51
  } from "@budibase/types"
52
+
53
+ const PUBLIC_TRANSLATION_PREFIXES = ["login.", "forgotPassword."]
45
54
  import env from "../../../environment"
46
55
  import * as email from "../../../utilities/email"
47
56
  import { checkAnyUserExists } from "../../../utilities/users"
@@ -299,6 +308,77 @@ export async function processRecaptchaConfig(
299
308
  }
300
309
  }
301
310
 
311
+ function prepareTranslationsConfig(
312
+ ctx: UserCtx,
313
+ config?: TranslationsConfigInner
314
+ ): TranslationsConfigInner {
315
+ const defaultLocale = config?.defaultLocale || "en"
316
+ const locales: TranslationsConfigInner["locales"] = {}
317
+ const now = new Date().toISOString()
318
+ const updatedBy = ctx.user?._id
319
+
320
+ Object.entries(config?.locales || {}).forEach(([locale, localeConfig]) => {
321
+ locales[locale] = {
322
+ label: localeConfig?.label,
323
+ overrides: filterValidTranslationOverrides(localeConfig?.overrides),
324
+ updatedAt: localeConfig?.updatedAt || now,
325
+ updatedBy: localeConfig?.updatedBy || updatedBy,
326
+ }
327
+ })
328
+
329
+ if (!locales[defaultLocale]) {
330
+ locales[defaultLocale] = {
331
+ label: defaultLocale === "en" ? "English" : defaultLocale,
332
+ overrides: {},
333
+ updatedAt: now,
334
+ updatedBy,
335
+ }
336
+ }
337
+
338
+ return {
339
+ defaultLocale,
340
+ locales,
341
+ }
342
+ }
343
+
344
+ function filterPublicTranslations(
345
+ config: TranslationsConfigInner
346
+ ): TranslationsConfigInner {
347
+ const locales: TranslationsConfigInner["locales"] = {}
348
+ for (const [locale, localeConfig] of Object.entries(config.locales || {})) {
349
+ const overrides = Object.entries(localeConfig?.overrides || {}).reduce(
350
+ (acc, [key, value]) => {
351
+ if (
352
+ PUBLIC_TRANSLATION_PREFIXES.some(prefix => key.startsWith(prefix))
353
+ ) {
354
+ acc[key] = value
355
+ }
356
+ return acc
357
+ },
358
+ {} as TranslationOverrides
359
+ )
360
+ locales[locale] = {
361
+ ...localeConfig,
362
+ overrides,
363
+ }
364
+ }
365
+ return {
366
+ defaultLocale: config.defaultLocale,
367
+ locales,
368
+ }
369
+ }
370
+
371
+ async function processTranslationsConfig(
372
+ ctx: UserCtx,
373
+ config: TranslationsConfigInner
374
+ ) {
375
+ const enabled = await pro.features.isTranslationsEnabled()
376
+ if (!enabled) {
377
+ throw new ForbiddenError("License does not allow translations")
378
+ }
379
+ ctx.request.body.config = prepareTranslationsConfig(ctx, config)
380
+ }
381
+
302
382
  export async function save(
303
383
  ctx: UserCtx<SaveConfigRequest, SaveConfigResponse>
304
384
  ) {
@@ -335,6 +415,9 @@ export async function save(
335
415
  case ConfigType.RECAPTCHA:
336
416
  await processRecaptchaConfig(config, existingConfig?.config)
337
417
  break
418
+ case ConfigType.TRANSLATIONS:
419
+ await processTranslationsConfig(ctx, config)
420
+ break
338
421
  }
339
422
  } catch (err: any) {
340
423
  ctx.throw(400, err)
@@ -436,11 +519,11 @@ export async function find(ctx: UserCtx<void, FindConfigResponse>) {
436
519
  break
437
520
  }
438
521
 
439
- stripSecrets(config)
522
+ stripSecrets(config, ctx)
440
523
  ctx.body = config
441
524
  }
442
525
 
443
- function stripSecrets(config: Config) {
526
+ function stripSecrets(config: Config, ctx?: UserCtx) {
444
527
  if (isAIConfig(config)) {
445
528
  for (const key in config.config) {
446
529
  if (config.config[key].apiKey) {
@@ -459,6 +542,11 @@ function stripSecrets(config: Config) {
459
542
  }
460
543
  } else if (isRecaptchaConfig(config)) {
461
544
  config.config.secretKey = PASSWORD_REPLACEMENT
545
+ } else if (isTranslationsConfig(config)) {
546
+ config.config = prepareTranslationsConfig(
547
+ ctx || ({} as UserCtx),
548
+ config.config
549
+ )
462
550
  }
463
551
  }
464
552
 
@@ -582,6 +670,17 @@ export async function publicSettings(
582
670
  }
583
671
  }
584
672
 
673
+ export async function publicTranslations(
674
+ ctx: Ctx<void, GetPublicTranslationsResponse>
675
+ ) {
676
+ try {
677
+ const configDoc = await configs.getTranslationsConfigDoc()
678
+ ctx.body = filterPublicTranslations(configDoc.config)
679
+ } catch (err: any) {
680
+ ctx.throw(err.status, err)
681
+ }
682
+ }
683
+
585
684
  export async function upload(ctx: UserCtx<void, UploadConfigFileResponse>) {
586
685
  if (ctx.request.files == null || Array.isArray(ctx.request.files.file)) {
587
686
  ctx.throw(400, "One file must be uploaded.")
@@ -87,6 +87,25 @@ function recaptchaValidation() {
87
87
  }).required()
88
88
  }
89
89
 
90
+ function translationsValidation() {
91
+ return Joi.object({
92
+ defaultLocale: Joi.string().required(),
93
+ locales: Joi.object()
94
+ .pattern(
95
+ Joi.string(),
96
+ Joi.object({
97
+ label: Joi.string().optional().allow("", null),
98
+ overrides: Joi.object()
99
+ .pattern(Joi.string(), Joi.string().allow(""))
100
+ .default({}),
101
+ updatedAt: Joi.string().optional(),
102
+ updatedBy: Joi.string().optional(),
103
+ }).required()
104
+ )
105
+ .required(),
106
+ })
107
+ }
108
+
90
109
  function buildConfigSaveValidation() {
91
110
  // prettier-ignore
92
111
  return auth.joiValidator.body(Joi.object({
@@ -107,6 +126,7 @@ function buildConfigSaveValidation() {
107
126
  { is: ConfigType.SCIM, then: scimValidation() },
108
127
  { is: ConfigType.AI, then: aiValidation() },
109
128
  { is: ConfigType.RECAPTCHA, then: recaptchaValidation() },
129
+ { is: ConfigType.TRANSLATIONS, then: translationsValidation() },
110
130
  ],
111
131
  }),
112
132
  }).required().unknown(true),
@@ -141,4 +161,5 @@ loggedInRoutes
141
161
  .get("/api/global/configs/checklist", controller.configChecklist)
142
162
  .get("/api/global/configs/public", controller.publicSettings)
143
163
  .get("/api/global/configs/public/oidc", controller.publicOidc)
164
+ .get("/api/global/configs/public/translations", controller.publicTranslations)
144
165
  .get("/api/global/configs/:type", buildConfigGetValidation(), controller.find)
@@ -5,12 +5,15 @@ import {
5
5
  ConfigType,
6
6
  GetPublicSettingsResponse,
7
7
  PKCEMethod,
8
+ TranslationsConfig,
8
9
  } from "@budibase/types"
9
10
  import { TestConfiguration, mocks, structures } from "../../../../tests"
11
+ import { resolveTranslationGroup } from "@budibase/shared-core"
12
+ import { processStringSync } from "@budibase/string-templates"
10
13
 
11
14
  mocks.email.mock()
12
15
 
13
- const { google, smtp, settings, oidc } = structures.configs
16
+ const { google, smtp, settings, oidc, translations } = structures.configs
14
17
 
15
18
  describe("configs", () => {
16
19
  const config = new TestConfiguration()
@@ -321,6 +324,84 @@ describe("configs", () => {
321
324
  })
322
325
  })
323
326
 
327
+ describe("translations", () => {
328
+ beforeEach(async () => {
329
+ await config.deleteConfig(ConfigType.TRANSLATIONS)
330
+ mocks.licenses.useTranslations()
331
+ mocks.pro.features.isTranslationsEnabled.mockResolvedValue(true)
332
+ })
333
+
334
+ afterEach(async () => {
335
+ await config.deleteConfig(ConfigType.TRANSLATIONS)
336
+ mocks.pro.features.isTranslationsEnabled.mockReset()
337
+ })
338
+
339
+ it("should save translations when feature enabled", async () => {
340
+ await saveConfig(translations({ "login.emailLabel": "Profile test" }))
341
+ const saved = await config.api.configs.getConfig(ConfigType.TRANSLATIONS)
342
+ expect(saved.config.locales.en.overrides["login.emailLabel"]).toEqual(
343
+ "Profile test"
344
+ )
345
+ })
346
+
347
+ it("should support non-default locales", async () => {
348
+ const spanishConfig: TranslationsConfig = {
349
+ type: ConfigType.TRANSLATIONS,
350
+ config: {
351
+ defaultLocale: "es",
352
+ locales: {
353
+ es: {
354
+ label: "Spanish",
355
+ overrides: {
356
+ "login.emailLabel": "Correo",
357
+ },
358
+ },
359
+ en: {
360
+ label: "English",
361
+ overrides: {
362
+ "login.emailLabel": "Email",
363
+ },
364
+ },
365
+ },
366
+ },
367
+ }
368
+
369
+ await saveConfig(spanishConfig)
370
+ const saved = await config.api.configs.getConfig(ConfigType.TRANSLATIONS)
371
+ expect(saved.config.locales.es.overrides["login.emailLabel"]).toEqual(
372
+ "Correo"
373
+ )
374
+ })
375
+
376
+ it("should filter invalid override keys", async () => {
377
+ await saveConfig(translations({ invalid: "value" }))
378
+ const saved = await config.api.configs.getConfig(ConfigType.TRANSLATIONS)
379
+ expect(saved.config.locales.en.overrides).toEqual({})
380
+ })
381
+
382
+ it("should reject when translations feature disabled", async () => {
383
+ mocks.pro.features.isTranslationsEnabled.mockResolvedValue(false)
384
+ await expect(
385
+ saveConfig(translations({ "login.emailLabel": "Profile test" }))
386
+ ).rejects.toThrow("License does not allow translations")
387
+ })
388
+
389
+ it("should resolve bindings inside translation overrides", async () => {
390
+ await saveConfig(
391
+ translations({ "portal.greeting": "Welcome {{ name }}" })
392
+ )
393
+ const saved = await config.api.configs.getConfig(ConfigType.TRANSLATIONS)
394
+ const portalLabels = resolveTranslationGroup(
395
+ "portal",
396
+ saved.config.locales.en.overrides
397
+ )
398
+ const boundValue = processStringSync(portalLabels.greeting, {
399
+ name: "Budibuddy",
400
+ })
401
+ expect(boundValue).toEqual("Welcome Budibuddy")
402
+ })
403
+ })
404
+
324
405
  describe("GET /api/global/configs/checklist", () => {
325
406
  it("should return the correct checklist", async () => {
326
407
  await config.saveSmtpConfig()
@@ -372,4 +453,44 @@ describe("configs", () => {
372
453
  expect(body).toEqual(expected)
373
454
  })
374
455
  })
456
+
457
+ describe("GET /api/global/configs/public/translations", () => {
458
+ beforeEach(async () => {
459
+ await config.deleteConfig(ConfigType.TRANSLATIONS)
460
+ mocks.licenses.useTranslations()
461
+ mocks.pro.features.isTranslationsEnabled.mockResolvedValue(true)
462
+ })
463
+
464
+ afterEach(async () => {
465
+ await config.deleteConfig(ConfigType.TRANSLATIONS)
466
+ mocks.pro.features.isTranslationsEnabled.mockReset()
467
+ })
468
+
469
+ it("should return translation overrides", async () => {
470
+ await saveConfig(translations({ "login.emailLabel": "Hello" }))
471
+ const res = await config.api.configs.getPublicTranslations()
472
+ expect(res.body.defaultLocale).toEqual("en")
473
+ expect(res.body.locales.en.overrides["login.emailLabel"]).toEqual("Hello")
474
+ })
475
+
476
+ it("should expose login and forgot labels without authentication", async () => {
477
+ await saveConfig(
478
+ translations({
479
+ "login.emailLabel": "Public email",
480
+ "forgotPassword.heading": "Reset password",
481
+ })
482
+ )
483
+ const res = await config
484
+ .getRequest()
485
+ .get(
486
+ `/api/global/configs/public/translations?tenantId=${config.getTenantId()}`
487
+ )
488
+ .expect(200)
489
+ .expect("Content-Type", /json/)
490
+
491
+ const overrides = res.body.locales.en.overrides
492
+ expect(overrides["login.emailLabel"]).toEqual("Public email")
493
+ expect(overrides["forgotPassword.heading"]).toEqual("Reset password")
494
+ })
495
+ })
375
496
  })
@@ -23,6 +23,15 @@ export class ConfigAPI extends TestAPI {
23
23
  .expect("Content-Type", /json/)
24
24
  }
25
25
 
26
+ getPublicTranslations = (tenantId?: string) => {
27
+ const query = tenantId ? `?tenantId=${tenantId}` : ""
28
+ return this.request
29
+ .get(`/api/global/configs/public/translations${query}`)
30
+ .set(this.config.defaultHeaders())
31
+ .expect(200)
32
+ .expect("Content-Type", /json/)
33
+ }
34
+
26
35
  getAIConfig = async () => {
27
36
  return await this.getConfig(ConfigType.AI)
28
37
  }
@@ -9,6 +9,7 @@ import {
9
9
  OIDCInnerConfig,
10
10
  SMTPInnerConfig,
11
11
  SettingsInnerConfig,
12
+ TranslationsConfig,
12
13
  } from "@budibase/types"
13
14
 
14
15
  export function oidc(conf?: Partial<OIDCInnerConfig>): OIDCConfig {
@@ -86,3 +87,20 @@ export function settings(conf?: Partial<SettingsInnerConfig>): SettingsConfig {
86
87
  },
87
88
  }
88
89
  }
90
+
91
+ export function translations(
92
+ overrides: Record<string, string> = {}
93
+ ): TranslationsConfig {
94
+ return {
95
+ type: ConfigType.TRANSLATIONS,
96
+ config: {
97
+ defaultLocale: "en",
98
+ locales: {
99
+ en: {
100
+ label: "English",
101
+ overrides,
102
+ },
103
+ },
104
+ },
105
+ }
106
+ }