@raketa-cloud/admin 1.10.8 → 1.10.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@raketa-cloud/admin",
3
- "version": "1.10.8",
3
+ "version": "1.10.10",
4
4
  "main": "src/index.js",
5
+ "engines": {
6
+ "node": ">=22"
7
+ },
5
8
  "files": [
6
9
  "src/",
7
10
  "dist/"
@@ -26,7 +29,7 @@
26
29
  "@tailwindcss/cli": "4.3.3",
27
30
  "@tailwindcss/forms": "0.5.11",
28
31
  "@tailwindcss/typography": "0.5.20",
29
- "@types/node": "24.10.1",
32
+ "@types/node": "22.20.5",
30
33
  "babel-jest": "30.5.2",
31
34
  "jest": "30.5.2",
32
35
  "jest-environment-node": "30.5.2",
@@ -34,11 +37,12 @@
34
37
  "typescript": "5.9.3"
35
38
  },
36
39
  "dependencies": {
37
- "@hono/node-server": "2.0.12",
40
+ "@hono/node-server": "2.1.5",
38
41
  "chart.js": "4.5.1",
39
42
  "ejs": "6.0.1",
43
+ "google-auth-library": "11.3.1",
40
44
  "graphql-request": "7.4.0",
41
- "hono": "4.13.7",
45
+ "hono": "4.13.13",
42
46
  "lru-cache": "11.5.2"
43
47
  },
44
48
  "overrides": {
@@ -42,6 +42,7 @@ export default class Resources extends RaketaClient {
42
42
  previewUrl(resource, locale) {
43
43
  return buildMagicPreviewAddress(
44
44
  {
45
+ id: resource.id,
45
46
  slug: resource.slug,
46
47
  locale,
47
48
  resourceType: resource.resourceType,
@@ -33,6 +33,15 @@ export const ContentTasksDocument = gql`
33
33
  insertedAt
34
34
  updatedAt
35
35
  }
36
+ payhawkWorkflow {
37
+ id
38
+ status
39
+ reference
40
+ partial
41
+ settings
42
+ insertedAt
43
+ updatedAt
44
+ }
36
45
  }
37
46
  pagination {
38
47
  currentPage
@@ -1,7 +1,23 @@
1
+ export const TRANSLATION_WORKFLOWS = [
2
+ "LOKALISE_TT_AI_RT_HUM",
3
+ "LOKALISE_TT_HUM",
4
+ "LOKALISE_TT_AI",
5
+ "PAYHAWK",
6
+ ];
7
+
8
+ export const workflowProvider = (workflow) =>
9
+ workflow.startsWith("LOKALISE_") ? "lokalise" : workflow.toLowerCase();
10
+
11
+ export const enabledTranslationWorkflows = (contentTasks) =>
12
+ TRANSLATION_WORKFLOWS.filter(
13
+ (workflow) => contentTasks?.[workflowProvider(workflow)] === true,
14
+ );
15
+
1
16
  const WORKFLOWS_MAP = {
2
17
  lokalise_tt_ai_rt_hum: "Lokalise: AI translation (Pro AI) & Human review",
3
18
  lokalise_tt_hum: "Lokalise: Human translation",
4
19
  lokalise_tt_ai: "Lokalise: AI translation (Pro AI)",
20
+ payhawk: "Payhawk: AI & Human translation",
5
21
  };
6
22
 
7
23
  export const workflowsMap = (workflowType) => {
@@ -5,9 +5,15 @@
5
5
  // with no human in the loop — sign a JWT with it, swap that for an access token.
6
6
  // Nothing is persisted, so a restart costs one token exchange.
7
7
  //
8
- // The key's client_email needs Viewer access on the GA property.
8
+ // Without a key, config.ga.useAdc falls back to Application Default Credentials
9
+ // via google-auth-library — on Cloud Run / GCE / GKE that is the attached
10
+ // service account through the metadata server. A key, when set, always wins.
11
+ //
12
+ // The key's client_email (or the ADC principal) needs Viewer access on the GA
13
+ // property.
9
14
  import { createSign } from "node:crypto";
10
15
  import { readFileSync } from "node:fs";
16
+ import { GoogleAuth } from "google-auth-library";
11
17
 
12
18
  const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
13
19
  const JWT_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer";
@@ -24,12 +30,16 @@ const keyCache = new Map();
24
30
  const tokenCache = new Map();
25
31
  const pendingTokens = new Map();
26
32
 
33
+ // Single ADC client: it owns its own token cache and refresh.
34
+ let adcAuth = null;
35
+
27
36
  const credentialSource = (ga = {}) => ga.serviceAccount;
37
+ const adcEnabled = (ga = {}) => Boolean(ga.useAdc);
28
38
 
29
39
  // True when a credential is configured — gates the Insights tab, so an
30
40
  // unconfigured admin sees no GA UI at all.
31
41
  export const serviceAccountConfigured = (ga = {}) =>
32
- Boolean(credentialSource(ga));
42
+ Boolean(credentialSource(ga)) || adcEnabled(ga);
33
43
 
34
44
  // GOOGLE_APPLICATION_CREDENTIALS now holds the whole key JSON rather than a path
35
45
  // to it, so a source can be: an already-parsed object, the JSON itself, that
@@ -88,7 +98,7 @@ function serviceAccountKey(ga = {}) {
88
98
 
89
99
  if (!source) {
90
100
  throw new Error(
91
- "Google service-account key is not set. Put the key JSON in GOOGLE_APPLICATION_CREDENTIALS (surfaced via config.ga.serviceAccount).",
101
+ "Google service-account key is not set. Put the key JSON in GOOGLE_APPLICATION_CREDENTIALS (surfaced via config.ga.serviceAccount), or enable Application Default Credentials via config.ga.useAdc.",
92
102
  );
93
103
  }
94
104
 
@@ -139,6 +149,8 @@ function signAssertion(key) {
139
149
  // they would each mint their own token. The in-flight exchange is shared: the
140
150
  // first caller starts it, the rest await the same promise.
141
151
  export async function accessToken(ga) {
152
+ if (!credentialSource(ga) && adcEnabled(ga)) return adcAccessToken();
153
+
142
154
  const key = serviceAccountKey(ga);
143
155
 
144
156
  const cached = tokenCache.get(key.client_email);
@@ -190,9 +202,27 @@ async function requestToken(key) {
190
202
  return json.access_token;
191
203
  }
192
204
 
205
+ async function adcAccessToken() {
206
+ adcAuth ??= new GoogleAuth({ scopes: [SCOPE] });
207
+
208
+ let token;
209
+ try {
210
+ token = await adcAuth.getAccessToken();
211
+ } catch (err) {
212
+ throw new Error(`Google ADC token request failed: ${err.message}`);
213
+ }
214
+
215
+ if (!token) {
216
+ throw new Error("Google ADC token response contained no access_token.");
217
+ }
218
+
219
+ return token;
220
+ }
221
+
193
222
  // Test / credential-rotation helper.
194
223
  export function clearAuthCache() {
195
224
  keyCache.clear();
196
225
  tokenCache.clear();
197
226
  pendingTokens.clear();
227
+ adcAuth = null;
198
228
  }
@@ -2,7 +2,7 @@
2
2
  // Quick Insights for a single page path: summary KPIs with period-over-period
3
3
  // trend plus a daily page-views series, both over the last 30 days.
4
4
  //
5
- // `ga` below is the whole config.ga object — property id plus the key path the
5
+ // `ga` below is the whole config.ga object — property id plus the credentials the
6
6
  // bearer token is minted from (see ./googleAuth).
7
7
  import localCache from "../../localCache";
8
8
  import { accessToken } from "./googleAuth";
@@ -135,7 +135,7 @@ async function fetchQuickInsights(ctx, resource, resourceType) {
135
135
  try {
136
136
  if (!gaEnabled(ctx)) {
137
137
  throw new Error(
138
- "Google Analytics is not configured. Set config.ga.propertyId and config.ga.serviceAccount (GOOGLE_APPLICATION_CREDENTIALS) to enable reports.",
138
+ "Google Analytics is not configured. Set config.ga.propertyId plus config.ga.serviceAccount (GOOGLE_APPLICATION_CREDENTIALS) or config.ga.useAdc to enable reports.",
139
139
  );
140
140
  }
141
141
  const pagePath = gaPagePath(ctx, resource, resourceType);
@@ -33,8 +33,22 @@ function formattedDate(dateTimeString) {
33
33
  });
34
34
  }
35
35
 
36
+ export function isFeatureEnabled(value) {
37
+ if (typeof value === "object" && value !== null) {
38
+ return Object.values(value).some(
39
+ (providerEnabled) => providerEnabled === true,
40
+ );
41
+ }
42
+
43
+ return value === true;
44
+ }
45
+
36
46
  function feature(featureName, context) {
37
- return context?.globalConfig?.features?.[featureName] === true;
47
+ return isFeatureEnabled(context?.globalConfig?.features?.[featureName]);
48
+ }
49
+
50
+ function contentTaskProviderEnabled(provider, context) {
51
+ return context?.globalConfig?.features?.contentTasks?.[provider] === true;
38
52
  }
39
53
 
40
54
  export default {
@@ -42,4 +56,5 @@ export default {
42
56
  formattedDate,
43
57
  formattedDateTime,
44
58
  feature,
59
+ contentTaskProviderEnabled,
45
60
  };
@@ -57,10 +57,11 @@ export function unchunkToken(token, chunkSize = CHUNK_SIZE) {
57
57
  }
58
58
 
59
59
  export function generateMagicPreviewToken(
60
- { slug, resourceType, locale } = {},
60
+ { id, slug, resourceType, locale } = {},
61
61
  config = {},
62
62
  ) {
63
63
  const payload = {
64
+ id,
64
65
  slug,
65
66
  resourceType,
66
67
  locale,
@@ -138,7 +139,7 @@ export function decipherMagicPreviewToken(token, config) {
138
139
  }
139
140
 
140
141
  export function buildMagicPreviewAddress(
141
- { slug, resourceType, locale },
142
+ { id, slug, resourceType, locale },
142
143
  globalConfig,
143
144
  ) {
144
145
  if (!slug || !resourceType || !locale) {
@@ -149,6 +150,7 @@ export function buildMagicPreviewAddress(
149
150
 
150
151
  const token = generateMagicPreviewToken(
151
152
  {
153
+ id,
152
154
  slug,
153
155
  resourceType,
154
156
  locale,
@@ -24,6 +24,7 @@ import URLsHandler from "./handlers/resources/URLsHandler";
24
24
  import routeCollection from "./routeCollection";
25
25
  import routeNestedCollection from "./routeNestedCollection";
26
26
  import routeNestedMember from "./routeNestedMember";
27
+ import { isFeatureEnabled } from "./helpers/utils";
27
28
 
28
29
  function routeResourceModule(
29
30
  adapter,
@@ -94,7 +95,7 @@ function routeResourceModule(
94
95
  { ...resourceConfig, viewsFallback: "resources/translations" },
95
96
  );
96
97
 
97
- if (adapter.globalConfig?.features?.contentTasks) {
98
+ if (isFeatureEnabled(adapter.globalConfig?.features?.contentTasks)) {
98
99
  routeNestedCollection(
99
100
  adapter,
100
101
  baseResource,
@@ -0,0 +1,11 @@
1
+ import { ContentTasksDocument } from "../../cloud/documents/contentTasksDocuments";
2
+
3
+ describe("ContentTasksDocument", () => {
4
+ test("requests Payhawk workflow details", () => {
5
+ const query = ContentTasksDocument;
6
+
7
+ expect(query).toContain("payhawkWorkflow");
8
+ expect(query).toContain("reference");
9
+ expect(query).toContain("partial");
10
+ });
11
+ });
@@ -1,4 +1,7 @@
1
1
  import {
2
+ TRANSLATION_WORKFLOWS,
3
+ enabledTranslationWorkflows,
4
+ workflowProvider,
2
5
  workflowsMap,
3
6
  buildResourceUrl,
4
7
  hasUnattachedKeys,
@@ -18,6 +21,7 @@ describe("content_tasks_helper", () => {
18
21
  expect(workflowsMap("lokalise_tt_ai")).toBe(
19
22
  "Lokalise: AI translation (Pro AI)",
20
23
  );
24
+ expect(workflowsMap("payhawk")).toBe("Payhawk: AI & Human translation");
21
25
  });
22
26
 
23
27
  test("returns 'Unknown workflow' for unknown type", () => {
@@ -25,6 +29,31 @@ describe("content_tasks_helper", () => {
25
29
  });
26
30
  });
27
31
 
32
+ test("exports all translation workflows and only Lokalise due-date workflows", () => {
33
+ expect(TRANSLATION_WORKFLOWS).toEqual([
34
+ "LOKALISE_TT_AI_RT_HUM",
35
+ "LOKALISE_TT_HUM",
36
+ "LOKALISE_TT_AI",
37
+ "PAYHAWK",
38
+ ]);
39
+ expect(workflowProvider("LOKALISE_TT_AI")).toBe("lokalise");
40
+ expect(workflowProvider("PAYHAWK")).toBe("payhawk");
41
+ });
42
+
43
+ describe("enabledTranslationWorkflows", () => {
44
+ test("returns workflows for only the enabled providers", () => {
45
+ expect(
46
+ enabledTranslationWorkflows({ lokalise: true, payhawk: false }),
47
+ ).toEqual(["LOKALISE_TT_AI_RT_HUM", "LOKALISE_TT_HUM", "LOKALISE_TT_AI"]);
48
+ expect(
49
+ enabledTranslationWorkflows({ lokalise: false, payhawk: true }),
50
+ ).toEqual(["PAYHAWK"]);
51
+ expect(
52
+ enabledTranslationWorkflows({ lokalise: true, payhawk: true }),
53
+ ).toEqual(TRANSLATION_WORKFLOWS);
54
+ });
55
+ });
56
+
28
57
  describe("buildResourceUrl", () => {
29
58
  const globalConfig = {
30
59
  resourceRoutes: {
@@ -3,6 +3,7 @@ import { mkdtempSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
+ import { GoogleAuth } from "google-auth-library";
6
7
  import {
7
8
  clearAuthCache,
8
9
  serviceAccountConfigured,
@@ -14,6 +15,14 @@ import {
14
15
  } from "../../../helpers/reports/quick-insights";
15
16
  import localCache from "../../../localCache";
16
17
 
18
+ // Only the ADC path goes through google-auth-library; the key path signs its
19
+ // own JWT against the mocked fetch below, so it never touches this.
20
+ const mockGetAccessToken = jest.fn();
21
+
22
+ jest.mock("google-auth-library", () => ({
23
+ GoogleAuth: jest.fn(() => ({ getAccessToken: mockGetAccessToken })),
24
+ }));
25
+
17
26
  // One throwaway RSA key. The app passes the whole key JSON through
18
27
  // ga.serviceAccount, so that is the form used here; the same key is also written
19
28
  // to a temp file, since that config value also accepts a path.
@@ -280,6 +289,98 @@ describe("quick-insights auth", () => {
280
289
  });
281
290
  });
282
291
 
292
+ describe("quick-insights ADC auth", () => {
293
+ const ADC_GA = { propertyId: "1234", useAdc: true };
294
+
295
+ const ARGS = {
296
+ pagePath: "/pricing",
297
+ startDate: "2026-07-01",
298
+ endDate: "2026-07-01",
299
+ ga: ADC_GA,
300
+ };
301
+
302
+ beforeEach(() => {
303
+ clearAuthCache();
304
+ jest.clearAllMocks();
305
+ mockGetAccessToken.mockResolvedValue("adc-token");
306
+ });
307
+
308
+ afterEach(() => {
309
+ delete global.fetch;
310
+ });
311
+
312
+ test("serviceAccountConfigured accepts ADC in place of a key", () => {
313
+ expect(serviceAccountConfigured({ useAdc: true })).toBe(true);
314
+ expect(serviceAccountConfigured({ useAdc: false })).toBe(false);
315
+ });
316
+
317
+ test("calls the Data API with the ADC token, no JWT exchange", async () => {
318
+ mockGoogle();
319
+
320
+ const result = await runPageReport(ARGS);
321
+
322
+ expect(GoogleAuth).toHaveBeenCalledWith({
323
+ scopes: ["https://www.googleapis.com/auth/analytics.readonly"],
324
+ });
325
+ expect(global.fetch).toHaveBeenCalledTimes(1);
326
+
327
+ const [reportUrl, reportOptions] = global.fetch.mock.calls[0];
328
+ expect(reportUrl).toBe(
329
+ "https://analyticsdata.googleapis.com/v1beta/properties/1234:runReport",
330
+ );
331
+ expect(reportOptions.headers.authorization).toBe("Bearer adc-token");
332
+ expect(result.rows).toEqual([{ date: "2026-07-01", views: 4 }]);
333
+ });
334
+
335
+ test("a configured key wins over ADC", async () => {
336
+ mockGoogle();
337
+
338
+ await runPageReport({
339
+ ...ARGS,
340
+ ga: { ...ADC_GA, serviceAccount: keyJson },
341
+ });
342
+
343
+ expect(GoogleAuth).not.toHaveBeenCalled();
344
+ expect(global.fetch.mock.calls[0][0]).toBe(
345
+ "https://oauth2.googleapis.com/token",
346
+ );
347
+ expect(global.fetch.mock.calls[1][1].headers.authorization).toBe(
348
+ "Bearer at-1",
349
+ );
350
+ });
351
+
352
+ test("reuses one ADC client until the cache is cleared", async () => {
353
+ mockGoogle();
354
+
355
+ await runPageReport(ARGS);
356
+ await runPageReport(ARGS);
357
+ expect(GoogleAuth).toHaveBeenCalledTimes(1);
358
+ expect(mockGetAccessToken).toHaveBeenCalledTimes(2);
359
+
360
+ clearAuthCache();
361
+ await runPageReport(ARGS);
362
+ expect(GoogleAuth).toHaveBeenCalledTimes(2);
363
+ });
364
+
365
+ test("surfaces ADC token failures", async () => {
366
+ mockGoogle();
367
+
368
+ mockGetAccessToken.mockRejectedValueOnce(
369
+ new Error("Could not load the default credentials"),
370
+ );
371
+ await expect(runPageReport(ARGS)).rejects.toThrow(
372
+ /Google ADC token request failed: Could not load the default credentials/,
373
+ );
374
+
375
+ mockGetAccessToken.mockResolvedValueOnce(null);
376
+ await expect(runPageReport(ARGS)).rejects.toThrow(
377
+ /Google ADC token response contained no access_token/,
378
+ );
379
+
380
+ expect(global.fetch).not.toHaveBeenCalled();
381
+ });
382
+ });
383
+
283
384
  describe("runSummary", () => {
284
385
  const metrics = (views, users, engagementSeconds, rate, sessions) => ({
285
386
  metricValues: [views, users, engagementSeconds, rate, sessions].map(
@@ -64,22 +64,25 @@ describe("formattedDate", () => {
64
64
  });
65
65
 
66
66
  describe("feature", () => {
67
- it("returns true when the feature is enabled", () => {
67
+ it("returns true when at least one content task provider is enabled", () => {
68
68
  const context = {
69
69
  globalConfig: {
70
70
  features: {
71
- contentTasks: true,
71
+ contentTasks: { lokalise: true, payhawk: false },
72
72
  },
73
73
  },
74
74
  };
75
+
75
76
  expect(utils.feature("contentTasks", context)).toEqual(true);
77
+ expect(utils.contentTaskProviderEnabled("lokalise", context)).toEqual(true);
78
+ expect(utils.contentTaskProviderEnabled("payhawk", context)).toEqual(false);
76
79
  });
77
80
 
78
- it("returns false when the feature is disabled", () => {
81
+ it("returns false when all content task providers are disabled", () => {
79
82
  const context = {
80
83
  globalConfig: {
81
84
  features: {
82
- contentTasks: false,
85
+ contentTasks: { lokalise: false, payhawk: false },
83
86
  },
84
87
  },
85
88
  };
@@ -31,9 +31,10 @@ describe("magicPreview helper", () => {
31
31
  };
32
32
 
33
33
  describe("buildMagicPreviewAddress", () => {
34
- it("builds full preview URL using remoteConfig.magicPreviewUrl", () => {
34
+ it("builds a preview URL with the resource ID in its token", () => {
35
35
  const address = buildMagicPreviewAddress(
36
36
  {
37
+ id: "resource-42",
37
38
  slug: "gocardless",
38
39
  resourceType: "customer",
39
40
  locale: "en",
@@ -44,6 +45,12 @@ describe("magicPreview helper", () => {
44
45
  expect(address).toMatch(
45
46
  /^https:\/\/example\.com\/magic-preview\/customer\/[A-Za-z0-9_-]+$/,
46
47
  );
48
+ expect(
49
+ decipherMagicPreviewToken(address.split("/").at(-1), config),
50
+ ).toMatchObject({
51
+ id: "resource-42",
52
+ slug: "gocardless",
53
+ });
47
54
  });
48
55
 
49
56
  it("builds localized preview URL for non-default locale", () => {
@@ -96,6 +103,7 @@ describe("magicPreview helper", () => {
96
103
  it("generates an encrypted token and deciphers it back", () => {
97
104
  const token = generateMagicPreviewToken(
98
105
  {
106
+ id: "resource-123",
99
107
  slug: "my-first-ebook",
100
108
  resourceType: "ebook",
101
109
  locale: "en",
@@ -115,6 +123,7 @@ describe("magicPreview helper", () => {
115
123
 
116
124
  const payload = decipherMagicPreviewToken(token, config);
117
125
  expect(payload).not.toBeNull();
126
+ expect(payload.id).toBe("resource-123");
118
127
  expect(payload.slug).toBe("my-first-ebook");
119
128
  expect(payload.resourceType).toBe("ebook");
120
129
  expect(payload.locale).toBe("en");
@@ -211,8 +211,12 @@ describe("routeResourceModule", () => {
211
211
  });
212
212
 
213
213
  test("registers partial translations only when content tasks are enabled", () => {
214
- const enabledAdapter = makeAdapter({ features: { contentTasks: true } });
215
- const disabledAdapter = makeAdapter({ features: { contentTasks: false } });
214
+ const enabledAdapter = makeAdapter({
215
+ features: { contentTasks: { lokalise: true, payhawk: false } },
216
+ });
217
+ const disabledAdapter = makeAdapter({
218
+ features: { contentTasks: { lokalise: false, payhawk: false } },
219
+ });
216
220
 
217
221
  routeResourceModule(enabledAdapter, "article", "articles");
218
222
  routeResourceModule(disabledAdapter, "article", "articles");
@@ -0,0 +1,36 @@
1
+ import { readFileSync } from "node:fs";
2
+ import ejs from "ejs";
3
+
4
+ const template = readFileSync(
5
+ `${process.cwd()}/src/views/_shared/_content_tasks.ejs`,
6
+ "utf8",
7
+ );
8
+
9
+ function renderContentTasks(task) {
10
+ return ejs.render(template, {
11
+ _context: { globalConfig: {}, locales: {} },
12
+ content_tasks: {
13
+ hasUnattachedKeys: () => false,
14
+ workflowsMap: (workflow) => workflow,
15
+ },
16
+ Inflector: { capitalize: (value) => value, humanize: (value) => value },
17
+ showAllDetails: false,
18
+ tasks: [task],
19
+ utils: { formattedDateTime: (value) => value },
20
+ });
21
+ }
22
+
23
+ describe("content tasks", () => {
24
+ test("uses Payhawk workflow details to identify a partial task", () => {
25
+ const rendered = renderContentTasks({
26
+ insertedAt: "2026-10-19T12:30:00Z",
27
+ operatorMeta: {},
28
+ payhawkWorkflow: { partial: true, settings: {} },
29
+ resources: [],
30
+ workflow: "payhawk",
31
+ });
32
+
33
+ expect(rendered).toContain("Partial translation");
34
+ expect(rendered).toContain("payhawk");
35
+ });
36
+ });
@@ -87,6 +87,16 @@ describe("resources shared due date", () => {
87
87
  expect(dueDateInput.value).toBe("");
88
88
  });
89
89
 
90
+ test("shows and preserves the due date for the Payhawk workflow", () => {
91
+ const { dueDateWrapper, dueDateInput } = setupDueDateScript({
92
+ workflow: "PAYHAWK",
93
+ dueDate: "2026-10-19T12:30",
94
+ });
95
+
96
+ expect(dueDateWrapper.style.display).toBe("block");
97
+ expect(dueDateInput.value).toBe("2026-10-19T12:30");
98
+ });
99
+
90
100
  test("shows the due date and defaults it to the minimum weekday", () => {
91
101
  jest.useFakeTimers().setSystemTime(new Date("2026-10-16T10:00:00"));
92
102
 
@@ -9,13 +9,21 @@ const template = readFileSync(
9
9
  function renderPartialTranslations(translations) {
10
10
  return ejs.render(template, {
11
11
  _context: {
12
+ globalConfig: {
13
+ features: { contentTasks: { lokalise: true, payhawk: true } },
14
+ },
12
15
  locale: "en",
13
16
  maxOpenRevisions: 1,
14
17
  locales: { en: { name: "English" }, fr: { name: "French" } },
15
18
  resourceConfig: { resourceTypePlural: "pages" },
16
19
  },
17
- content_tasks: { workflowsMap: (workflow) => workflow },
20
+ content_tasks: {
21
+ TRANSLATION_WORKFLOWS: ["LOKALISE_TT_AI", "PAYHAWK"],
22
+ enabledTranslationWorkflows: () => ["LOKALISE_TT_AI", "PAYHAWK"],
23
+ workflowsMap: (workflow) => workflow,
24
+ },
18
25
  errors: {},
26
+ utils: {},
19
27
  partialTranslation: { locales: [], widgetIds: ["hero-1"], workflow: {} },
20
28
  previewUrl: "https://preview.example/hello",
21
29
  render_partial: () => "",
@@ -49,6 +57,13 @@ describe("partial translations form", () => {
49
57
  expect(rendered).not.toContain("This resource has no translations.");
50
58
  });
51
59
 
60
+ test("includes Payhawk as a translation workflow", () => {
61
+ const rendered = renderPartialTranslations({ fr: { id: "43" } });
62
+
63
+ expect(rendered).toContain('value="PAYHAWK"');
64
+ expect(rendered).toContain(">payhawk</option>");
65
+ });
66
+
52
67
  test("disables translations at the open revision limit", () => {
53
68
  const rendered = renderPartialTranslations({
54
69
  fr: { id: "43", revisionsCount: 1 },
@@ -0,0 +1,68 @@
1
+ import { readFileSync } from "node:fs";
2
+ import ejs from "ejs";
3
+
4
+ const template = readFileSync(
5
+ `${process.cwd()}/src/views/resources/translations/create_form.ejs`,
6
+ "utf8",
7
+ );
8
+
9
+ function renderTranslationsCreateForm(
10
+ workflow = {},
11
+ providers = { lokalise: true, payhawk: true },
12
+ ) {
13
+ return ejs.render(template, {
14
+ _context: {
15
+ defaultLocale: "en",
16
+ globalConfig: {
17
+ features: { contentTasks: { lokalise: true, payhawk: true } },
18
+ },
19
+ locale: "en",
20
+ locales: {
21
+ en: { name: "English" },
22
+ fr: { name: "French" },
23
+ },
24
+ resourceConfig: { resourceType: "page", resourceTypePlural: "pages" },
25
+ },
26
+ blueprint: { id: "42", title: "Hello" },
27
+ content_tasks: {
28
+ TRANSLATION_WORKFLOWS: ["LOKALISE_TT_AI", "PAYHAWK"],
29
+ enabledTranslationWorkflows: () =>
30
+ ["LOKALISE_TT_AI", "PAYHAWK"].filter(
31
+ (workflowType) =>
32
+ providers[
33
+ workflowType.startsWith("LOKALISE_")
34
+ ? "lokalise"
35
+ : workflowType.toLowerCase()
36
+ ] === true,
37
+ ),
38
+ workflowsMap: (type) => type,
39
+ },
40
+ errors: {},
41
+ form_for: (_resource, _errors, callback) => callback({ submit: () => "" }),
42
+ Inflector: { downcase: (value) => value.toLowerCase() },
43
+ render_partial: () => "",
44
+ resource: { languages: [], translations: {}, workflow },
45
+ targetLocale: null,
46
+ utils: { feature: () => true },
47
+ });
48
+ }
49
+
50
+ describe("translations create form", () => {
51
+ test("hides providers disabled in the content task feature configuration", () => {
52
+ const rendered = renderTranslationsCreateForm(
53
+ {},
54
+ { lokalise: true, payhawk: false },
55
+ );
56
+
57
+ expect(rendered).toContain('value="LOKALISE_TT_AI"');
58
+ expect(rendered).not.toContain('value="PAYHAWK"');
59
+ });
60
+
61
+ test("includes Payhawk and preserves its selected value", () => {
62
+ const rendered = renderTranslationsCreateForm({ type: "PAYHAWK" });
63
+
64
+ expect(rendered).toContain('value="PAYHAWK" selected');
65
+ expect(rendered).toMatch(/>\s*payhawk\s*<\/option>/);
66
+ expect(rendered).toContain("Translation (Payhawk)");
67
+ });
68
+ });
@@ -19,6 +19,7 @@
19
19
  <tbody>
20
20
  <% tasks.forEach(task => { %>
21
21
  <% const resources = task.resources || []; %>
22
+ <% const workflow = task.lokaliseWorkflow || task.payhawkWorkflow; %>
22
23
  <% const isMarked = showAllDetails && content_tasks.hasUnattachedKeys(task); %>
23
24
 
24
25
  <tr class="<%= isMarked ? 'bg-orange-100 dark:bg-orange-950/40' : '' %>">
@@ -61,7 +62,7 @@
61
62
 
62
63
  <td>
63
64
  <span class="body-3 flex items-center gap-0.5">
64
- <% if (task.lokaliseWorkflow?.partial) { %>
65
+ <% if (workflow?.partial) { %>
65
66
  <svg viewBox="0 0 20 20" class="size-3.5 shrink-0 text-raketa-primary" role="img" aria-label="Partial translation">
66
67
  <title>Partial translation</title>
67
68
  <path fill="currentColor" d="M10 10V2a8 8 0 1 1-8 8h8Z" />
@@ -128,7 +129,7 @@
128
129
  <% if (showAllDetails) { %>
129
130
  <td class="whitespace-nowrap">
130
131
  <div class="body-3 text-raketa-foreground/50">
131
- <% const dueDate = task.lokaliseWorkflow?.settings?.due_date %>
132
+ <% const dueDate = workflow?.settings?.due_date %>
132
133
 
133
134
  <% if (dueDate) { %>
134
135
  <div class="flex items-center gap-1 mb-0.5">
@@ -52,10 +52,10 @@
52
52
  const minDate = getMinimumDueDate();
53
53
  dueDateInput.min = minDate;
54
54
 
55
- const isDefault = !workflowSelect.value;
56
- dueDateWrapper.style.display = isDefault ? "none" : "block";
55
+ const hasWorkflow = Boolean(workflowSelect.value);
56
+ dueDateWrapper.style.display = hasWorkflow ? "block" : "none";
57
57
 
58
- if (isDefault) {
58
+ if (!hasWorkflow) {
59
59
  dueDateInput.value = "";
60
60
  } else if (!dueDateInput.value) {
61
61
  dueDateInput.value = minDate;
@@ -1,4 +1,4 @@
1
- <div class="actions-toolbar justify-end">
1
+ <div class="justify-end actions-toolbar">
2
2
  <form method="POST" action="<%= `/${_context.locale}/${_context.resourceConfig.resourceTypePlural}/${resource.id}/${_context.resourceConfig.resourceType}_changes/reverts` %>" class="inline-block">
3
3
  <input type="hidden" name="version" value="<%= change.version %>">
4
4
 
@@ -7,7 +7,7 @@
7
7
  Revert
8
8
  </button>
9
9
  <% } else { %>
10
- <button type="submit" disabled class="btn-text opacity-50 cursor-not-allowed">
10
+ <button type="submit" disabled class="opacity-50 cursor-not-allowed btn-text">
11
11
  Revert
12
12
  </button>
13
13
  <% } %>
@@ -28,7 +28,7 @@
28
28
  <label for="workflow" class="font-semibold body-3">Translation workflow</label>
29
29
  <select name="partial_translation[workflow][type]" id="workflow" required>
30
30
  <option value="">Select workflow</option>
31
- <% ['LOKALISE_TT_AI_RT_HUM', 'LOKALISE_TT_HUM', 'LOKALISE_TT_AI'].forEach(workflow => { %>
31
+ <% content_tasks.enabledTranslationWorkflows(_context.globalConfig.features.contentTasks).forEach(workflow => { %>
32
32
  <option value="<%= workflow %>" <%= partialTranslation.workflow?.type === workflow ? 'selected' : '' %>><%= content_tasks.workflowsMap(workflow.toLowerCase()) %></option>
33
33
  <% }) %>
34
34
  </select>
@@ -36,20 +36,14 @@
36
36
  <select name="resource[workflow][type]" id="workflow">
37
37
  <option value="">Default (no workflow)</option>
38
38
 
39
- <option value="LOKALISE_TT_AI_RT_HUM" <%= resource.workflow?.type === 'LOKALISE_TT_AI_RT_HUM' ? 'selected' : '' %>>
40
- <%= content_tasks.workflowsMap("LOKALISE_TT_AI_RT_HUM".toLowerCase()) %>
41
- </option>
42
-
43
- <option value="LOKALISE_TT_HUM" <%= resource.workflow?.type === 'LOKALISE_TT_HUM' ? 'selected' : '' %>>
44
- <%= content_tasks.workflowsMap("LOKALISE_TT_HUM".toLowerCase()) %>
45
- </option>
46
-
47
- <option value="LOKALISE_TT_AI" <%= resource.workflow?.type === 'LOKALISE_TT_AI' ? 'selected' : '' %>>
48
- <%= content_tasks.workflowsMap("LOKALISE_TT_AI".toLowerCase()) %>
39
+ <% content_tasks.enabledTranslationWorkflows(_context.globalConfig.features.contentTasks).forEach(workflow => { %>
40
+ <option value="<%= workflow %>" <%= resource.workflow?.type === workflow ? 'selected' : '' %>>
41
+ <%= content_tasks.workflowsMap(workflow.toLowerCase()) %>
49
42
  </option>
43
+ <% }) %>
50
44
  </select>
51
45
  <p class="text-sm text-raketa-foreground/50 mt-1">
52
- Select a workflow for the translation process. Lokalise workflow initiates a translation task for external translation.
46
+ Select a workflow for the translation process. An external workflow initiates a translation task with the selected provider.
53
47
  </p>
54
48
  </div>
55
49
 
@@ -124,15 +118,18 @@
124
118
  <li>
125
119
  <strong>Human Translation (Lokalise):</strong> A task is sent to Lokalise where a human translator fully translates and reviews the content.
126
120
  </li>
121
+ <li>
122
+ <strong>Translation (Payhawk):</strong> A task is sent to Payhawk for translation in configured target languages.
123
+ </li>
127
124
  </ul>
128
125
 
129
126
  <br />
130
- <strong>Notes:</strong> For all Lokalise workflows (<strong>AI, AI + Human</strong>, and <strong>Human</strong>):
127
+ <strong>Notes:</strong> For all external workflows:
131
128
  <ul>
132
- <li>Content is required to start a Lokalise task.</li>
129
+ <li>Content is required to start an external translation task.</li>
133
130
  <li>The private copy and new revision will be automatically created here in the CMS only after the external task is completed and returned.</li>
134
131
  <li>You can track translation progress in the <a href="<%= `/${_context.locale}/content_tasks` %>" target="_blank">Translation tasks</a> module.</li>
135
- <li>Translations will only be created for languages supported in Lokalise.</li>
132
+ <li>Translations will only be created for languages configured for the selected provider.</li>
136
133
  </ul>
137
134
  </div>
138
135
  </div>