@raketa-cloud/admin 1.8.23 → 1.9.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/dist/application.css +45 -8
- package/package.json +1 -1
- package/src/cloud/Resources.js +9 -0
- package/src/helpers/AuthHelper.js +6 -1
- package/src/magicPreview.js +103 -0
- package/src/setupAuthentication.js +12 -0
- package/src/styles/application.css +12 -0
- package/src/tests/handlers/resources/TranslationsHandler.test/create.test.js +79 -1
- package/src/tests/helpers/AuthHelper.test.js +10 -0
- package/src/tests/magicPreview.test.js +127 -0
- package/src/tests/setupAuthentication.test.js +55 -0
- package/src/views/resources/_shared/_toolbar.ejs +54 -61
- package/src/views/resources/_shared/_translations_strip.ejs +2 -2
- package/src/views/resources/show.ejs +18 -3
package/dist/application.css
CHANGED
|
@@ -965,6 +965,37 @@ select {
|
|
|
965
965
|
color: rgb(185 28 28 / var(--tw-text-opacity));
|
|
966
966
|
}
|
|
967
967
|
|
|
968
|
+
.btn-icon {
|
|
969
|
+
display: flex;
|
|
970
|
+
width: 1.5rem;
|
|
971
|
+
height: 1.5rem;
|
|
972
|
+
align-items: center;
|
|
973
|
+
justify-content: center;
|
|
974
|
+
border-radius: 9999px;
|
|
975
|
+
background-color: rgba(var(--primary), 0.1);
|
|
976
|
+
--tw-text-opacity: 1;
|
|
977
|
+
color: rgba(var(--primary), var(--tw-text-opacity));
|
|
978
|
+
transition-property: all;
|
|
979
|
+
transition-duration: 300ms;
|
|
980
|
+
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
.btn-icon:hover {
|
|
984
|
+
background-color: rgba(var(--primary), 0.2);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
.btn-icon {
|
|
988
|
+
&:disabled {
|
|
989
|
+
cursor: not-allowed;
|
|
990
|
+
}
|
|
991
|
+
&:disabled {
|
|
992
|
+
opacity: 0.5;
|
|
993
|
+
}
|
|
994
|
+
&:disabled:hover {
|
|
995
|
+
background-color: rgba(var(--primary), 0.1);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
968
999
|
.btn-toolbar {
|
|
969
1000
|
display: flex;
|
|
970
1001
|
align-items: center;
|
|
@@ -1539,6 +1570,20 @@ select {
|
|
|
1539
1570
|
gap: 1rem;
|
|
1540
1571
|
}
|
|
1541
1572
|
|
|
1573
|
+
.toolbar-divider {
|
|
1574
|
+
margin-left: 0.25rem;
|
|
1575
|
+
margin-right: 0.25rem;
|
|
1576
|
+
height: 1.5rem;
|
|
1577
|
+
width: 1px;
|
|
1578
|
+
--tw-bg-opacity: 1;
|
|
1579
|
+
background-color: rgb(208 209 210 / var(--tw-bg-opacity));
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
.toolbar-divider:where(.dark, .dark *) {
|
|
1583
|
+
--tw-bg-opacity: 1;
|
|
1584
|
+
background-color: rgb(64 64 64 / var(--tw-bg-opacity));
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1542
1587
|
/* Dashboard specific */
|
|
1543
1588
|
|
|
1544
1589
|
.card-button {
|
|
@@ -2232,10 +2277,6 @@ select {
|
|
|
2232
2277
|
background-color: rgba(var(--primary), var(--tw-bg-opacity));
|
|
2233
2278
|
}
|
|
2234
2279
|
|
|
2235
|
-
.bg-raketa-primary\/10 {
|
|
2236
|
-
background-color: rgba(var(--primary), 0.1);
|
|
2237
|
-
}
|
|
2238
|
-
|
|
2239
2280
|
.bg-raketa-primary\/5 {
|
|
2240
2281
|
background-color: rgba(var(--primary), 0.05);
|
|
2241
2282
|
}
|
|
@@ -3366,10 +3407,6 @@ body {
|
|
|
3366
3407
|
background-color: rgba(var(--primary), var(--tw-bg-opacity));
|
|
3367
3408
|
}
|
|
3368
3409
|
|
|
3369
|
-
.hover\:bg-raketa-primary\/20:hover {
|
|
3370
|
-
background-color: rgba(var(--primary), 0.2);
|
|
3371
|
-
}
|
|
3372
|
-
|
|
3373
3410
|
.hover\:text-raketa-foreground:hover {
|
|
3374
3411
|
--tw-text-opacity: 1;
|
|
3375
3412
|
color: rgba(var(--foreground), var(--tw-text-opacity));
|
package/package.json
CHANGED
package/src/cloud/Resources.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildMagicPreviewAddress } from "../magicPreview";
|
|
1
2
|
import { toDateInput, toDateTimeInput, toUTCISO8601String } from "../utils";
|
|
2
3
|
import RaketaClient from "./RaketaClient";
|
|
3
4
|
import {
|
|
@@ -162,6 +163,14 @@ export default class Resources extends RaketaClient {
|
|
|
162
163
|
|
|
163
164
|
input.workflow = {
|
|
164
165
|
type: workflow.type,
|
|
166
|
+
previewUrl: buildMagicPreviewAddress(
|
|
167
|
+
{
|
|
168
|
+
slug: input.resource.slug,
|
|
169
|
+
locale: input.locale,
|
|
170
|
+
resourceType: input.resource.resourceType,
|
|
171
|
+
},
|
|
172
|
+
this.getConfig(),
|
|
173
|
+
),
|
|
165
174
|
};
|
|
166
175
|
|
|
167
176
|
if (workflow.due_date) {
|
|
@@ -25,7 +25,12 @@ const AuthHelper = {
|
|
|
25
25
|
isAuthenticated: ({ path, token }) => {
|
|
26
26
|
const ALLOWED_PATHS = ["/login", "/auth/google/callback", "/remote/verify"];
|
|
27
27
|
|
|
28
|
-
if (
|
|
28
|
+
if (
|
|
29
|
+
ALLOWED_PATHS.includes(path) ||
|
|
30
|
+
path?.startsWith("/remote/magic-preview/")
|
|
31
|
+
) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
29
34
|
|
|
30
35
|
if (token && AuthHelper.verifyJWT(token)) return true;
|
|
31
36
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
createHash,
|
|
5
|
+
randomBytes,
|
|
6
|
+
} from "node:crypto";
|
|
7
|
+
|
|
8
|
+
const ALGORITHM = "aes-256-gcm";
|
|
9
|
+
const IV_LENGTH = 12;
|
|
10
|
+
const TAG_LENGTH = 16;
|
|
11
|
+
|
|
12
|
+
function getSecretKey(config = {}) {
|
|
13
|
+
const secret = config.secrets.crypto_secret;
|
|
14
|
+
|
|
15
|
+
if (!secret) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
"crypto_secret needs to be setup in the config for the magic preview feature to work",
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return createHash("sha256").update(secret).digest();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function generateMagicPreviewToken(
|
|
25
|
+
{ slug, resourceType, locale } = {},
|
|
26
|
+
config = {},
|
|
27
|
+
) {
|
|
28
|
+
const payload = {
|
|
29
|
+
slug,
|
|
30
|
+
resourceType,
|
|
31
|
+
locale,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const key = getSecretKey(config);
|
|
35
|
+
const iv = randomBytes(IV_LENGTH);
|
|
36
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
37
|
+
const encrypted = Buffer.concat([
|
|
38
|
+
cipher.update(JSON.stringify(payload), "utf8"),
|
|
39
|
+
cipher.final(),
|
|
40
|
+
]);
|
|
41
|
+
const tag = cipher.getAuthTag();
|
|
42
|
+
|
|
43
|
+
return Buffer.concat([iv, tag, encrypted]).toString("base64url");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function decipherMagicPreviewToken(token, config) {
|
|
47
|
+
if (!token || typeof token !== "string") {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const key = getSecretKey(config);
|
|
53
|
+
const combined = Buffer.from(token, "base64url");
|
|
54
|
+
|
|
55
|
+
if (combined.length < IV_LENGTH + TAG_LENGTH) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const iv = combined.subarray(0, IV_LENGTH);
|
|
60
|
+
const tag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
|
61
|
+
const encrypted = combined.subarray(IV_LENGTH + TAG_LENGTH);
|
|
62
|
+
|
|
63
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
64
|
+
decipher.setAuthTag(tag);
|
|
65
|
+
|
|
66
|
+
const decrypted = Buffer.concat([
|
|
67
|
+
decipher.update(encrypted),
|
|
68
|
+
decipher.final(),
|
|
69
|
+
]).toString("utf8");
|
|
70
|
+
|
|
71
|
+
const payload = JSON.parse(decrypted);
|
|
72
|
+
|
|
73
|
+
if (!payload || typeof payload !== "object" || !payload.slug) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return payload;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function buildMagicPreviewAddress(
|
|
84
|
+
{ slug, resourceType, locale },
|
|
85
|
+
globalConfig,
|
|
86
|
+
) {
|
|
87
|
+
if (!slug || !resourceType || !locale) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
"Can't generate a token without a slug, resourceType or locale!",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const token = generateMagicPreviewToken(
|
|
94
|
+
{
|
|
95
|
+
slug,
|
|
96
|
+
resourceType,
|
|
97
|
+
locale,
|
|
98
|
+
},
|
|
99
|
+
globalConfig,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
return globalConfig.remoteConfig.magicPreviewUrl(token, resourceType, locale);
|
|
103
|
+
}
|
|
@@ -3,6 +3,7 @@ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
|
3
3
|
import currentPath from "./currentPath.cjs";
|
|
4
4
|
import AuthHelper from "./helpers/AuthHelper";
|
|
5
5
|
import cipher from "./cipher";
|
|
6
|
+
import { decipherMagicPreviewToken } from "./magicPreview";
|
|
6
7
|
|
|
7
8
|
const DELIMITER = ",";
|
|
8
9
|
|
|
@@ -149,6 +150,17 @@ const setupAuthentication = (app, config) => {
|
|
|
149
150
|
|
|
150
151
|
return c.json({ token });
|
|
151
152
|
});
|
|
153
|
+
|
|
154
|
+
app.get("/remote/magic-preview/:token", (c) => {
|
|
155
|
+
const token = c.req.param("token") || "";
|
|
156
|
+
const payload = decipherMagicPreviewToken(token, config);
|
|
157
|
+
|
|
158
|
+
if (!payload?.slug) {
|
|
159
|
+
return c.json({ error: "Invalid or expired preview token" }, 404);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return c.json(payload);
|
|
163
|
+
});
|
|
152
164
|
};
|
|
153
165
|
|
|
154
166
|
export default setupAuthentication;
|
|
@@ -110,6 +110,14 @@ body {
|
|
|
110
110
|
@apply text-red-600 hover:text-raketa-foreground dark:text-red-700;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
.btn-icon {
|
|
114
|
+
@apply flex items-center justify-center transition-all duration-300 ease-in-out rounded-full size-6 text-raketa-primary bg-raketa-primary/10 hover:bg-raketa-primary/20;
|
|
115
|
+
|
|
116
|
+
&:disabled {
|
|
117
|
+
@apply opacity-50 cursor-not-allowed hover:bg-raketa-primary/10;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
113
121
|
.btn-toolbar {
|
|
114
122
|
@apply flex items-center gap-1;
|
|
115
123
|
|
|
@@ -318,6 +326,10 @@ body {
|
|
|
318
326
|
@apply flex items-center gap-4;
|
|
319
327
|
}
|
|
320
328
|
|
|
329
|
+
.toolbar-divider {
|
|
330
|
+
@apply w-px h-6 mx-1 bg-mono-50 dark:bg-neutral-700;
|
|
331
|
+
}
|
|
332
|
+
|
|
321
333
|
/* Dashboard specific */
|
|
322
334
|
.card-button {
|
|
323
335
|
@apply flex items-center gap-2 mb-2 max-w-[400px] border p-4 rounded font-semibold text-raketa-primary hover:border-raketa-primary hover:bg-raketa-primary/5;
|
|
@@ -5,8 +5,26 @@ import Factory from "../../../support/factory";
|
|
|
5
5
|
|
|
6
6
|
function buildCtx(params, resource) {
|
|
7
7
|
return {
|
|
8
|
+
globalConfig: {
|
|
9
|
+
i18n: { defaultLocale: "en" },
|
|
10
|
+
remoteConfig: {
|
|
11
|
+
magicPreviewUrl: (token, type, loc) =>
|
|
12
|
+
`https://example.com/${loc}/magic-preview/${type}s/${token}`,
|
|
13
|
+
},
|
|
14
|
+
},
|
|
8
15
|
client: {
|
|
9
|
-
resources: new Resources(
|
|
16
|
+
resources: new Resources(
|
|
17
|
+
() => ({
|
|
18
|
+
secrets: {
|
|
19
|
+
crypto_secret: "super-secret-for-testing-1234",
|
|
20
|
+
},
|
|
21
|
+
remoteConfig: {
|
|
22
|
+
magicPreviewUrl: (token, type, loc) =>
|
|
23
|
+
`https://example.com/${loc}/magic-preview/${type}s/${token}`,
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
"token",
|
|
27
|
+
),
|
|
10
28
|
resourceRevisions: new ResourceRevisions(() => ({}), "token"),
|
|
11
29
|
},
|
|
12
30
|
params: { parent_id: resource.id },
|
|
@@ -327,4 +345,64 @@ describe("Resource Translations Handler", () => {
|
|
|
327
345
|
},
|
|
328
346
|
});
|
|
329
347
|
});
|
|
348
|
+
|
|
349
|
+
test("create generates previewUrl when workflow is provided", async () => {
|
|
350
|
+
const resource = Factory.build("resource", {
|
|
351
|
+
translations: {},
|
|
352
|
+
categories: [{ id: "cat1" }],
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const params = {
|
|
356
|
+
languages: ["fr"],
|
|
357
|
+
workflow: {
|
|
358
|
+
type: "LOKALISE_TT_AI",
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const ctx = buildCtx(params, resource);
|
|
363
|
+
|
|
364
|
+
jest.spyOn(ctx.client.resources, "find").mockImplementation(() => resource);
|
|
365
|
+
const mockQuery = jest
|
|
366
|
+
.spyOn(ctx.client.resources, "query")
|
|
367
|
+
.mockImplementation(() => ({
|
|
368
|
+
translate0: {
|
|
369
|
+
resource: {
|
|
370
|
+
id: "translated-resource-fr",
|
|
371
|
+
slug: "resource-slug",
|
|
372
|
+
visibility: "public",
|
|
373
|
+
},
|
|
374
|
+
errors: null,
|
|
375
|
+
},
|
|
376
|
+
}));
|
|
377
|
+
|
|
378
|
+
const result = await TranslationsHandler.create(ctx);
|
|
379
|
+
|
|
380
|
+
expect(mockQuery).toHaveBeenCalledWith(expect.any(String), {
|
|
381
|
+
input0: {
|
|
382
|
+
locale: "fr",
|
|
383
|
+
resource: {
|
|
384
|
+
categories: ["cat1"],
|
|
385
|
+
resourceType: "article",
|
|
386
|
+
settings:
|
|
387
|
+
'{"layout":"default","meta":{"title":"Open graph","description":"Open graph???","image":{}}}',
|
|
388
|
+
slug: "resource-slug",
|
|
389
|
+
tags: [],
|
|
390
|
+
title: "Resource Title",
|
|
391
|
+
visibility: "private",
|
|
392
|
+
widgets: "[]",
|
|
393
|
+
},
|
|
394
|
+
sourceResourceId: "2",
|
|
395
|
+
workflow: {
|
|
396
|
+
previewUrl: expect.stringMatching(
|
|
397
|
+
/^https:\/\/example\.com\/fr\/magic-preview\/articles\/[A-Za-z0-9_-]+$/,
|
|
398
|
+
),
|
|
399
|
+
type: "LOKALISE_TT_AI",
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
expect(result).toEqual({
|
|
405
|
+
redirect: `/en/articles/${resource.id}/translations?translations=fr:translated-resource-fr`,
|
|
406
|
+
});
|
|
407
|
+
});
|
|
330
408
|
});
|
|
@@ -69,6 +69,16 @@ describe("AuthHelper", () => {
|
|
|
69
69
|
AuthHelper.isAuthenticated({ path: "/auth/google/callback" }),
|
|
70
70
|
).toBe(true);
|
|
71
71
|
expect(AuthHelper.isAuthenticated({ path: "/remote/verify" })).toBe(true);
|
|
72
|
+
expect(
|
|
73
|
+
AuthHelper.isAuthenticated({
|
|
74
|
+
path: "/remote/magic-preview/any-token-here",
|
|
75
|
+
}),
|
|
76
|
+
).toBe(true);
|
|
77
|
+
expect(
|
|
78
|
+
AuthHelper.isAuthenticated({
|
|
79
|
+
path: "/remote/magic-preview/:token",
|
|
80
|
+
}),
|
|
81
|
+
).toBe(true);
|
|
72
82
|
});
|
|
73
83
|
|
|
74
84
|
test("returns true for valid token", () => {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildMagicPreviewAddress,
|
|
3
|
+
decipherMagicPreviewToken,
|
|
4
|
+
generateMagicPreviewToken,
|
|
5
|
+
} from "../magicPreview";
|
|
6
|
+
|
|
7
|
+
describe("magicPreview helper", () => {
|
|
8
|
+
const config = {
|
|
9
|
+
secrets: {
|
|
10
|
+
crypto_secret: "super-secret-key-for-testing-123456",
|
|
11
|
+
},
|
|
12
|
+
i18n: {
|
|
13
|
+
defaultLocale: "en",
|
|
14
|
+
locales: { en: {}, de: {}, fr: {} },
|
|
15
|
+
},
|
|
16
|
+
remoteConfig: {
|
|
17
|
+
magicPreviewUrl: (token, resourceType, locale) => {
|
|
18
|
+
return [
|
|
19
|
+
"https://example.com",
|
|
20
|
+
locale === "en" ? "" : locale,
|
|
21
|
+
"magic-preview",
|
|
22
|
+
resourceType === "page" ? "" : resourceType,
|
|
23
|
+
token,
|
|
24
|
+
]
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.join("/");
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("buildMagicPreviewAddress", () => {
|
|
32
|
+
it("builds full preview URL using remoteConfig.magicPreviewUrl", () => {
|
|
33
|
+
const address = buildMagicPreviewAddress(
|
|
34
|
+
{
|
|
35
|
+
slug: "gocardless",
|
|
36
|
+
resourceType: "customer",
|
|
37
|
+
locale: "en",
|
|
38
|
+
},
|
|
39
|
+
config,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(address).toMatch(
|
|
43
|
+
/^https:\/\/example\.com\/magic-preview\/customer\/[A-Za-z0-9_-]+$/,
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("builds localized preview URL for non-default locale", () => {
|
|
48
|
+
const address = buildMagicPreviewAddress(
|
|
49
|
+
{
|
|
50
|
+
slug: "gocardless",
|
|
51
|
+
resourceType: "customer",
|
|
52
|
+
locale: "de",
|
|
53
|
+
},
|
|
54
|
+
config,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(address).toMatch(
|
|
58
|
+
/^https:\/\/example\.com\/de\/magic-preview\/customer\/[A-Za-z0-9_-]+$/,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("throws an error for missing slug", () => {
|
|
63
|
+
expect(() => {
|
|
64
|
+
buildMagicPreviewAddress(
|
|
65
|
+
{ resourceType: "page", locale: "de" },
|
|
66
|
+
config,
|
|
67
|
+
);
|
|
68
|
+
}).toThrow(
|
|
69
|
+
"Can't generate a token without a slug, resourceType or locale!",
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("throws an error for missing resourceType", () => {
|
|
74
|
+
expect(() => {
|
|
75
|
+
buildMagicPreviewAddress({ slug: "page", locale: "de" }, config);
|
|
76
|
+
}).toThrow(
|
|
77
|
+
"Can't generate a token without a slug, resourceType or locale!",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("throws an error for missing locale", () => {
|
|
82
|
+
expect(() => {
|
|
83
|
+
buildMagicPreviewAddress(
|
|
84
|
+
{ resourceType: "page", slug: "dada" },
|
|
85
|
+
config,
|
|
86
|
+
);
|
|
87
|
+
}).toThrow(
|
|
88
|
+
"Can't generate a token without a slug, resourceType or locale!",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("generateMagicPreviewToken & decipherMagicPreviewToken", () => {
|
|
94
|
+
it("generates an encrypted token and deciphers it back", () => {
|
|
95
|
+
const token = generateMagicPreviewToken(
|
|
96
|
+
{
|
|
97
|
+
slug: "my-first-ebook",
|
|
98
|
+
resourceType: "ebook",
|
|
99
|
+
locale: "en",
|
|
100
|
+
},
|
|
101
|
+
config,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(typeof token).toBe("string");
|
|
105
|
+
expect(token).not.toContain("my-first-ebook");
|
|
106
|
+
|
|
107
|
+
const payload = decipherMagicPreviewToken(token, config);
|
|
108
|
+
expect(payload).not.toBeNull();
|
|
109
|
+
expect(payload.slug).toBe("my-first-ebook");
|
|
110
|
+
expect(payload.resourceType).toBe("ebook");
|
|
111
|
+
expect(payload.locale).toBe("en");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns null on invalid / tampered token", () => {
|
|
115
|
+
expect(decipherMagicPreviewToken("", config)).toBeNull();
|
|
116
|
+
expect(decipherMagicPreviewToken(null, config)).toBeNull();
|
|
117
|
+
expect(decipherMagicPreviewToken("invalid-token", config)).toBeNull();
|
|
118
|
+
|
|
119
|
+
const token = generateMagicPreviewToken(
|
|
120
|
+
{ slug: "secure", resourceType: "page", locale: "en" },
|
|
121
|
+
config,
|
|
122
|
+
);
|
|
123
|
+
const tampered = `${token.slice(0, -4)}XXXX`;
|
|
124
|
+
expect(decipherMagicPreviewToken(tampered, config)).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -50,6 +50,7 @@ function makeCtx(overrides = {}) {
|
|
|
50
50
|
path: "/en/pages",
|
|
51
51
|
url: "https://example.com/en/pages",
|
|
52
52
|
query: jest.fn().mockReturnValue({}),
|
|
53
|
+
param: jest.fn().mockReturnValue(""),
|
|
53
54
|
json: jest.fn().mockResolvedValue({}),
|
|
54
55
|
...overrides.req,
|
|
55
56
|
},
|
|
@@ -72,6 +73,10 @@ const config = {
|
|
|
72
73
|
remoteLogin: "https://remote/login",
|
|
73
74
|
remoteLogout: "https://remote/logout",
|
|
74
75
|
},
|
|
76
|
+
secrets: {
|
|
77
|
+
crypto_secret:
|
|
78
|
+
"cafeb74e0fc1079d384f68620dc0b0919c82bd36351a74fb48f3c96562c1bf672ff5fd4db42180bc9550d06f3621edba5305a443a232337e2945d7bb1271cafe",
|
|
79
|
+
},
|
|
75
80
|
};
|
|
76
81
|
|
|
77
82
|
describe("setupAuthentication", () => {
|
|
@@ -96,6 +101,10 @@ describe("setupAuthentication", () => {
|
|
|
96
101
|
"/remote/verify",
|
|
97
102
|
expect.any(Function),
|
|
98
103
|
);
|
|
104
|
+
expect(app.get).toHaveBeenCalledWith(
|
|
105
|
+
"/remote/magic-preview/:token",
|
|
106
|
+
expect.any(Function),
|
|
107
|
+
);
|
|
99
108
|
});
|
|
100
109
|
|
|
101
110
|
describe("use middleware", () => {
|
|
@@ -407,4 +416,50 @@ describe("setupAuthentication", () => {
|
|
|
407
416
|
expect(result.status).toBe(401);
|
|
408
417
|
});
|
|
409
418
|
});
|
|
419
|
+
|
|
420
|
+
describe("/remote/magic-preview/:token", () => {
|
|
421
|
+
test("returns the token payload for a valid token", async () => {
|
|
422
|
+
const app = makeApp();
|
|
423
|
+
setupAuthentication(app, config);
|
|
424
|
+
|
|
425
|
+
const { generateMagicPreviewToken } = require("../magicPreview");
|
|
426
|
+
const token = generateMagicPreviewToken(
|
|
427
|
+
{ slug: "article-slug", resourceType: "article", locale: "es" },
|
|
428
|
+
config,
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
const ctx = makeCtx({
|
|
432
|
+
req: {
|
|
433
|
+
param: jest.fn().mockReturnValue(token),
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
await app.handlers["GET /remote/magic-preview/:token"](ctx);
|
|
438
|
+
|
|
439
|
+
expect(ctx.json).toHaveBeenCalledWith({
|
|
440
|
+
slug: "article-slug",
|
|
441
|
+
resourceType: "article",
|
|
442
|
+
locale: "es",
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("returns 404 for invalid token", async () => {
|
|
447
|
+
const app = makeApp();
|
|
448
|
+
|
|
449
|
+
setupAuthentication(app, config);
|
|
450
|
+
|
|
451
|
+
const ctx = makeCtx({
|
|
452
|
+
req: {
|
|
453
|
+
param: jest.fn().mockReturnValue("bad-token"),
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
await app.handlers["GET /remote/magic-preview/:token"](ctx);
|
|
458
|
+
|
|
459
|
+
expect(ctx.json).toHaveBeenCalledWith(
|
|
460
|
+
{ error: "Invalid or expired preview token" },
|
|
461
|
+
404,
|
|
462
|
+
);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
410
465
|
});
|
|
@@ -1,75 +1,68 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
!resource.activeContentTask.failedAt;
|
|
7
|
-
%>
|
|
8
|
-
<div class="actions-toolbar">
|
|
9
|
-
<form method="POST" action="<%= `/${_context.locale}/${_context.resourceConfig.resourceTypePlural}/${resource.id}/${_context.resourceConfig.resourceType}_revisions` %>" class="inline-block">
|
|
10
|
-
<% if (resource.revisionsCount >= _context?.maxOpenRevisions || hasActiveContentTask) { %>
|
|
11
|
-
<button type="submit" class="btn-primary" disabled style="opacity: 50%;">
|
|
12
|
-
Start revision
|
|
13
|
-
</button>
|
|
14
|
-
<% } else {%>
|
|
15
|
-
<button type="submit" class="btn-primary">
|
|
16
|
-
Start revision
|
|
17
|
-
</button>
|
|
18
|
-
<% } %>
|
|
19
|
-
</form>
|
|
1
|
+
<%
|
|
2
|
+
const hasActiveContentTask = utils.feature("contentTasks", _context) &&
|
|
3
|
+
resource.activeContentTask &&
|
|
4
|
+
!resource.activeContentTask.completedAt &&
|
|
5
|
+
!resource.activeContentTask.failedAt;
|
|
20
6
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
7
|
+
const resourceName = Inflector.humanize(Inflector.downcase(_context.resourceConfig.resourceType));
|
|
8
|
+
%>
|
|
9
|
+
<div class="actions-toolbar gap-2">
|
|
10
|
+
<%- render_partial('./_shared/_translations_strip.ejs', { render_partial }) %>
|
|
11
|
+
|
|
12
|
+
<span class="toolbar-divider"></span>
|
|
26
13
|
|
|
27
|
-
|
|
14
|
+
<form action="<%= `/${_context.locale}/${_context.resourceConfig.resourceTypePlural}/${resource.id}/clones` %>" method="post" class="inline-flex">
|
|
15
|
+
<button type="submit" class="btn-icon" title="Clone <%= resourceName %>" aria-label="Clone <%= resourceName %>" <%- hasActiveContentTask ? 'disabled' : '' %>>
|
|
16
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4">
|
|
17
|
+
<path d="M7 3.5A1.5 1.5 0 0 1 8.5 2h3.879a1.5 1.5 0 0 1 1.06.44l3.122 3.12A1.5 1.5 0 0 1 17 6.622V12.5a1.5 1.5 0 0 1-1.5 1.5h-1v-3.379a3 3 0 0 0-.879-2.121L10.5 5.379A3 3 0 0 0 8.379 4.5H7v-1Z" />
|
|
18
|
+
<path d="M4.5 6A1.5 1.5 0 0 0 3 7.5v9A1.5 1.5 0 0 0 4.5 18h7a1.5 1.5 0 0 0 1.5-1.5v-5.879a1.5 1.5 0 0 0-.44-1.06L9.44 6.439A1.5 1.5 0 0 0 8.378 6H4.5Z" />
|
|
19
|
+
</svg>
|
|
28
20
|
</button>
|
|
29
|
-
|
|
30
|
-
|
|
21
|
+
</form>
|
|
22
|
+
|
|
23
|
+
<details class="relative unstyled-details">
|
|
24
|
+
<summary class="btn-icon cursor-pointer" title="More actions" aria-label="More actions">
|
|
31
25
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
|
32
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="
|
|
26
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
|
|
33
27
|
</svg>
|
|
28
|
+
</summary>
|
|
34
29
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
<form action="<%= `/${_context.locale}/${_context.resourceConfig.resourceTypePlural}/${resource.id}/clones` %>" method="post" class="inline-block">
|
|
40
|
-
<button type="submit" class="btn-text has-icon" <%= hasActiveContentTask ? 'disabled style="opacity: 50%;"' : '' %>>
|
|
30
|
+
<div class="absolute right-0 z-20 flex flex-col gap-4 p-4 mt-2 border rounded shadow-md bg-raketa-background dark:border-neutral-500">
|
|
31
|
+
<% if (hasActiveContentTask) { %>
|
|
32
|
+
<button type="button" class="btn-text has-icon whitespace-nowrap" disabled style="opacity: 50%; cursor: not-allowed;">
|
|
41
33
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
|
42
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="
|
|
34
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
|
43
35
|
</svg>
|
|
44
36
|
|
|
45
|
-
|
|
37
|
+
Change URL
|
|
46
38
|
</button>
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
|
53
|
-
</svg>
|
|
39
|
+
<% } else { %>
|
|
40
|
+
<a href="<%= `/${_context.locale}/${_context.resourceConfig.resourceTypePlural}/${resource.id}/urls/new` %>" class="btn-text has-icon whitespace-nowrap">
|
|
41
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
|
42
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
|
43
|
+
</svg>
|
|
54
44
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
<a href="<%= `/${_context.locale}/${_context.resourceConfig.resourceTypePlural}/${resource.id}/deletes/new` %>" class="btn-danger has-icon">
|
|
59
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
|
60
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
|
61
|
-
</svg>
|
|
45
|
+
Change URL
|
|
46
|
+
</a>
|
|
47
|
+
<% } %>
|
|
62
48
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
49
|
+
<% if (hasActiveContentTask) { %>
|
|
50
|
+
<button type="button" class="btn-danger has-icon whitespace-nowrap" disabled style="opacity: 50%; cursor: not-allowed;">
|
|
51
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
|
52
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
|
53
|
+
</svg>
|
|
67
54
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
55
|
+
Delete <%= resourceName %>
|
|
56
|
+
</button>
|
|
57
|
+
<% } else { %>
|
|
58
|
+
<a href="<%= `/${_context.locale}/${_context.resourceConfig.resourceTypePlural}/${resource.id}/deletes/new` %>" class="btn-danger has-icon whitespace-nowrap">
|
|
59
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
|
60
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
|
61
|
+
</svg>
|
|
71
62
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
</div>
|
|
63
|
+
Delete <%= resourceName %>
|
|
64
|
+
</a>
|
|
65
|
+
<% } %>
|
|
66
|
+
</div>
|
|
67
|
+
</details>
|
|
68
|
+
</div>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<div class="flex
|
|
1
|
+
<div class="flex items-center gap-1">
|
|
2
2
|
<%
|
|
3
3
|
const availableLocales = Object.keys(_context.locales);
|
|
4
4
|
const totalPossibleTranslations = availableLocales.length - 1;
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
<% }); %>
|
|
50
50
|
|
|
51
51
|
<% if (canBeTranslated) { %>
|
|
52
|
-
<a href="<%= `/${_context.locale}/${_context.resourceConfig.resourceTypePlural}/${resource.id}/translations/new` %>" title="Translate" aria-label="Translate" class="
|
|
52
|
+
<a href="<%= `/${_context.locale}/${_context.resourceConfig.resourceTypePlural}/${resource.id}/translations/new` %>" title="Translate" aria-label="Translate" class="btn-icon">
|
|
53
53
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" class="size-3.5">
|
|
54
54
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
|
55
55
|
</svg>
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
<div class="page-wrapper">
|
|
2
2
|
<div class="page-title">
|
|
3
3
|
<%- render_partial('./_shared/_title.ejs') %>
|
|
4
|
-
<%- render_partial('./_shared/_toolbar.ejs') %>
|
|
4
|
+
<%- render_partial('./_shared/_toolbar.ejs', { render_partial }) %>
|
|
5
5
|
</div>
|
|
6
6
|
|
|
7
|
-
<%- render_partial('./_shared/_translations_strip.ejs') %>
|
|
8
|
-
|
|
9
7
|
<%
|
|
10
8
|
const openedRevisions = []
|
|
11
9
|
const scheduledRevisions = []
|
|
@@ -43,6 +41,23 @@
|
|
|
43
41
|
resource.revisionsCount >= _context?.maxOpenRevisions;
|
|
44
42
|
const canStartRevision = !atRevisionLimit && !hasActiveContentTask;
|
|
45
43
|
%>
|
|
44
|
+
|
|
45
|
+
<div class="pb-4">
|
|
46
|
+
<% if (_context.query?.revisions_count_error) { %>
|
|
47
|
+
<div class="text-red-600">Cannot open a new revision. Current limit is <%= _context.maxOpenRevisions %>. </div>
|
|
48
|
+
<% } %>
|
|
49
|
+
|
|
50
|
+
<% if (hasActiveContentTask) { %>
|
|
51
|
+
<div class="mb-2 text-orange-700 dark:text-orange-300">Actions are disabled while a translation task is in progress.</div>
|
|
52
|
+
<% } %>
|
|
53
|
+
|
|
54
|
+
<form method="POST" action="<%= `/${_context.locale}/${_context.resourceConfig.resourceTypePlural}/${resource.id}/${_context.resourceConfig.resourceType}_revisions` %>" class="inline-block">
|
|
55
|
+
<button type="submit" class="btn-primary" <%- canStartRevision ? '' : 'disabled style="opacity: 50%;"' %> <% if (atRevisionLimit && !hasActiveContentTask) { %> title="Open revision limit reached (<%= _context.maxOpenRevisions %>)" <% } %>>
|
|
56
|
+
Start revision
|
|
57
|
+
</button>
|
|
58
|
+
</form>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
46
61
|
<% if (openedRevisions.length) { %>
|
|
47
62
|
<table class="data-grid alt">
|
|
48
63
|
<thead>
|