fontdue-js 3.0.0-alpha15 → 3.0.0-alpha16
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/__tests__/preview.test.js +127 -6
- package/dist/__tests__/previewState.test.js +63 -0
- package/dist/components/ConfigContext.d.ts +3 -0
- package/dist/components/ConfigContext.js +3 -2
- package/dist/components/FontdueAdminToolbar/index.js +113 -17
- package/dist/components/FontdueAdminToolbar/previewState.d.ts +7 -0
- package/dist/components/FontdueAdminToolbar/previewState.js +58 -0
- package/dist/components/Root/index.js +16 -2
- package/dist/fontdue.css +21 -0
- package/dist/preview/constants.d.ts +4 -0
- package/dist/preview/constants.js +86 -6
- package/dist/preview/index.d.ts +11 -7
- package/dist/preview/index.js +36 -10
- package/dist/relay/environment.js +1 -1
- package/package.json +1 -1
- package/.playwright-mcp/console-2026-06-15T09-14-00-118Z.log +0 -84
- package/.playwright-mcp/console-2026-06-15T09-25-42-726Z.log +0 -2
- package/.playwright-mcp/console-2026-06-15T09-25-47-707Z.log +0 -1
- package/.playwright-mcp/page-2026-06-15T09-14-01-054Z.yml +0 -13
- package/dist/__generated__/FontdueAdminToolbarStaffQuery.graphql.d.ts +0 -20
- package/dist/__generated__/FontdueAdminToolbarStaffQuery.graphql.js +0 -80
- package/dist/data/unicodeData.d.ts +0 -4
- package/dist/data/unicodeData.js +0 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
2
|
import { handlePreviewRequest, readPreviewToken, previewAuthHeaders, PREVIEW_TOKEN_COOKIE, PREVIEW_MARKER_COOKIE } from '../preview/index.js';
|
|
3
|
+
import { setPreviewMarkerCookie, hasPreviewMarkerCookie, getPreviewExpiry } from '../preview/constants.js';
|
|
3
4
|
function postPreview(token) {
|
|
4
5
|
let url = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'https://site.test/api/preview';
|
|
5
6
|
return handlePreviewRequest(new Request(url, {
|
|
@@ -14,12 +15,14 @@ function postPreview(token) {
|
|
|
14
15
|
}
|
|
15
16
|
describe('handlePreviewRequest', () => {
|
|
16
17
|
it('enters preview: sets an httpOnly token cookie + a readable marker', async () => {
|
|
18
|
+
const before = Date.now();
|
|
17
19
|
const res = await postPreview('admin.tok.123');
|
|
18
20
|
expect(res.status).toBe(200);
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
const body = await res.json();
|
|
22
|
+
expect(body.ok).toBe(true);
|
|
23
|
+
expect(body.preview).toBe(true);
|
|
24
|
+
// No expiresAt in the body → defaults to now + the 1h TTL.
|
|
25
|
+
expect(body.expiresAt).toBeGreaterThanOrEqual(before + 60 * 60 * 1000 - 5_000);
|
|
23
26
|
const cookies = res.headers.getSetCookie();
|
|
24
27
|
const tokenCookie = cookies.find(c => c.startsWith(`${PREVIEW_TOKEN_COOKIE}=`));
|
|
25
28
|
const markerCookie = cookies.find(c => c.startsWith(`${PREVIEW_MARKER_COOKIE}=`));
|
|
@@ -27,10 +30,42 @@ describe('handlePreviewRequest', () => {
|
|
|
27
30
|
expect(tokenCookie).toContain('HttpOnly');
|
|
28
31
|
expect(tokenCookie).toContain('SameSite=Lax');
|
|
29
32
|
expect(tokenCookie).toContain('Path=/');
|
|
30
|
-
|
|
33
|
+
|
|
34
|
+
// The marker now carries the token's expiry (epoch-ms), and outlives the
|
|
35
|
+
// token via a grace window so the toolbar can reach the "expired" warning.
|
|
36
|
+
expect(markerCookie).toContain(`${PREVIEW_MARKER_COOKIE}=${body.expiresAt}`);
|
|
37
|
+
expect(markerCookie).toMatch(/Max-Age=\d+/);
|
|
31
38
|
// The marker must be readable by the client toolbar.
|
|
32
39
|
expect(markerCookie).not.toContain('HttpOnly');
|
|
33
40
|
});
|
|
41
|
+
it('threads an explicit expiresAt (ISO + epoch-ms) into the marker + body', async () => {
|
|
42
|
+
const iso = '2030-01-01T00:00:00.000Z';
|
|
43
|
+
const epoch = Date.parse(iso);
|
|
44
|
+
const fromIso = await handlePreviewRequest(new Request('https://site.test/api/preview', {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: {
|
|
47
|
+
'content-type': 'application/json'
|
|
48
|
+
},
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
token: 't',
|
|
51
|
+
expiresAt: iso
|
|
52
|
+
})
|
|
53
|
+
}));
|
|
54
|
+
expect((await fromIso.json()).expiresAt).toBe(epoch);
|
|
55
|
+
const fromEpoch = await handlePreviewRequest(new Request('https://site.test/api/preview', {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: {
|
|
58
|
+
'content-type': 'application/json'
|
|
59
|
+
},
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
token: 't',
|
|
62
|
+
expiresAt: epoch
|
|
63
|
+
})
|
|
64
|
+
}));
|
|
65
|
+
expect((await fromEpoch.json()).expiresAt).toBe(epoch);
|
|
66
|
+
const marker = fromEpoch.headers.getSetCookie().find(c => c.startsWith(`${PREVIEW_MARKER_COOKIE}=`));
|
|
67
|
+
expect(marker).toContain(`${PREVIEW_MARKER_COOKIE}=${epoch}`);
|
|
68
|
+
});
|
|
34
69
|
it('marks cookies Secure on https and not on http (local dev)', async () => {
|
|
35
70
|
const httpsCookies = (await postPreview('t')).headers.getSetCookie();
|
|
36
71
|
expect(httpsCookies.every(c => c.includes('Secure'))).toBe(true);
|
|
@@ -82,6 +117,64 @@ describe('readPreviewToken', () => {
|
|
|
82
117
|
expect(readPreviewToken(undefined)).toBeUndefined();
|
|
83
118
|
});
|
|
84
119
|
});
|
|
120
|
+
describe('setPreviewMarkerCookie (client-only embeds)', () => {
|
|
121
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
122
|
+
|
|
123
|
+
// Captures the last `document.cookie = ...` write through a setter, the way
|
|
124
|
+
// the browser would receive it.
|
|
125
|
+
function stubBrowser(protocol) {
|
|
126
|
+
const state = {
|
|
127
|
+
written: ''
|
|
128
|
+
};
|
|
129
|
+
vi.stubGlobal('document', {
|
|
130
|
+
set cookie(value) {
|
|
131
|
+
state.written = value;
|
|
132
|
+
},
|
|
133
|
+
get cookie() {
|
|
134
|
+
return state.written;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
vi.stubGlobal('location', {
|
|
138
|
+
protocol
|
|
139
|
+
});
|
|
140
|
+
return state;
|
|
141
|
+
}
|
|
142
|
+
it('is a no-op during server rendering (no document)', () => {
|
|
143
|
+
// The node test env has no `document`, so this must stay inert, not throw.
|
|
144
|
+
expect(() => setPreviewMarkerCookie(true)).not.toThrow();
|
|
145
|
+
});
|
|
146
|
+
it('sets a JS-readable marker carrying the expiry, Secure on https', () => {
|
|
147
|
+
const state = stubBrowser('https:');
|
|
148
|
+
const expiresAt = Date.now() + 60 * 60 * 1000;
|
|
149
|
+
setPreviewMarkerCookie(expiresAt);
|
|
150
|
+
expect(state.written).toContain(`${PREVIEW_MARKER_COOKIE}=${expiresAt}`);
|
|
151
|
+
expect(state.written).toContain('Path=/');
|
|
152
|
+
expect(state.written).toContain('SameSite=Lax');
|
|
153
|
+
expect(state.written).toContain('Secure');
|
|
154
|
+
// A positive Max-Age (not a clear) that outlives the token.
|
|
155
|
+
expect(state.written).toMatch(/Max-Age=\d+/);
|
|
156
|
+
expect(state.written).not.toContain('Max-Age=0');
|
|
157
|
+
});
|
|
158
|
+
it('defaults to the 1h TTL when passed a bare true', () => {
|
|
159
|
+
const state = stubBrowser('https:');
|
|
160
|
+
const before = Date.now();
|
|
161
|
+
setPreviewMarkerCookie(true);
|
|
162
|
+
const match = state.written.match(new RegExp(`${PREVIEW_MARKER_COOKIE}=(\\d+)`));
|
|
163
|
+
expect(match).not.toBeNull();
|
|
164
|
+
expect(Number(match[1])).toBeGreaterThanOrEqual(before + 60 * 60 * 1000 - 5_000);
|
|
165
|
+
});
|
|
166
|
+
it('omits Secure on http so it persists in local dev', () => {
|
|
167
|
+
const state = stubBrowser('http:');
|
|
168
|
+
setPreviewMarkerCookie(Date.now() + 60 * 60 * 1000);
|
|
169
|
+
expect(state.written).not.toContain('Secure');
|
|
170
|
+
});
|
|
171
|
+
it('clears the marker with Max-Age=0 on exit', () => {
|
|
172
|
+
const state = stubBrowser('https:');
|
|
173
|
+
setPreviewMarkerCookie(false);
|
|
174
|
+
expect(state.written).toContain(`${PREVIEW_MARKER_COOKIE}=`);
|
|
175
|
+
expect(state.written).toContain('Max-Age=0');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
85
178
|
describe('previewAuthHeaders', () => {
|
|
86
179
|
it('builds a Bearer header when a token is present', () => {
|
|
87
180
|
expect(previewAuthHeaders('abc')).toEqual({
|
|
@@ -93,4 +186,32 @@ describe('previewAuthHeaders', () => {
|
|
|
93
186
|
expect(previewAuthHeaders(null)).toEqual({});
|
|
94
187
|
expect(previewAuthHeaders('')).toEqual({});
|
|
95
188
|
});
|
|
189
|
+
});
|
|
190
|
+
describe('marker cookie readers (hasPreviewMarkerCookie / getPreviewExpiry)', () => {
|
|
191
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
192
|
+
function stubCookies(cookie) {
|
|
193
|
+
vi.stubGlobal('document', {
|
|
194
|
+
cookie
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
it('reads presence + expiry from an expiry-encoded marker', () => {
|
|
198
|
+
stubCookies(`a=1; ${PREVIEW_MARKER_COOKIE}=1781700000000; b=2`);
|
|
199
|
+
expect(hasPreviewMarkerCookie()).toBe(true);
|
|
200
|
+
expect(getPreviewExpiry()).toBe(1781700000000);
|
|
201
|
+
});
|
|
202
|
+
it('treats a legacy `=1` marker as present, expiry 1ms (always expired)', () => {
|
|
203
|
+
stubCookies(`${PREVIEW_MARKER_COOKIE}=1`);
|
|
204
|
+
expect(hasPreviewMarkerCookie()).toBe(true);
|
|
205
|
+
expect(getPreviewExpiry()).toBe(1);
|
|
206
|
+
});
|
|
207
|
+
it('reports absent when the marker cookie is missing', () => {
|
|
208
|
+
stubCookies('a=1; b=2');
|
|
209
|
+
expect(hasPreviewMarkerCookie()).toBe(false);
|
|
210
|
+
expect(getPreviewExpiry()).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
it('returns undefined expiry for a non-numeric marker value', () => {
|
|
213
|
+
stubCookies(`${PREVIEW_MARKER_COOKIE}=bogus`);
|
|
214
|
+
expect(hasPreviewMarkerCookie()).toBe(true);
|
|
215
|
+
expect(getPreviewExpiry()).toBeUndefined();
|
|
216
|
+
});
|
|
96
217
|
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { derivePreviewState, expiresAtFromTokenResult, isSessionExpiredError } from '../components/FontdueAdminToolbar/previewState.js';
|
|
3
|
+
|
|
4
|
+
// The three-state derivation is the heart of the expired-preview fix, so it's
|
|
5
|
+
// tested in isolation (the full toolbar needs a browser + Relay environment +
|
|
6
|
+
// admin session to render, which isn't available here).
|
|
7
|
+
describe('derivePreviewState', () => {
|
|
8
|
+
const NOW = 1_000_000;
|
|
9
|
+
it('is "off" with no marker cookie', () => {
|
|
10
|
+
expect(derivePreviewState(false, undefined, NOW)).toBe('off');
|
|
11
|
+
expect(derivePreviewState(false, NOW + 1000, NOW)).toBe('off');
|
|
12
|
+
});
|
|
13
|
+
it('is "active" while the marker is present and the token is still valid', () => {
|
|
14
|
+
expect(derivePreviewState(true, NOW + 1, NOW)).toBe('active');
|
|
15
|
+
});
|
|
16
|
+
it('is "expired" once now has reached the stored expiry', () => {
|
|
17
|
+
expect(derivePreviewState(true, NOW, NOW)).toBe('expired');
|
|
18
|
+
expect(derivePreviewState(true, NOW - 1, NOW)).toBe('expired');
|
|
19
|
+
});
|
|
20
|
+
it('is "expired" for a marker with no/garbled expiry (stale legacy cookie)', () => {
|
|
21
|
+
expect(derivePreviewState(true, undefined, NOW)).toBe('expired');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe('expiresAtFromTokenResult', () => {
|
|
25
|
+
it('accepts an epoch-ms number', () => {
|
|
26
|
+
expect(expiresAtFromTokenResult({
|
|
27
|
+
expiresAt: 123_456
|
|
28
|
+
})).toBe(123_456);
|
|
29
|
+
});
|
|
30
|
+
it('accepts an ISO-8601 string', () => {
|
|
31
|
+
const iso = '2030-01-01T00:00:00.000Z';
|
|
32
|
+
expect(expiresAtFromTokenResult({
|
|
33
|
+
expiresAt: iso
|
|
34
|
+
})).toBe(Date.parse(iso));
|
|
35
|
+
});
|
|
36
|
+
it('accepts a numeric string', () => {
|
|
37
|
+
expect(expiresAtFromTokenResult({
|
|
38
|
+
expiresAt: '987654321'
|
|
39
|
+
})).toBe(987654321);
|
|
40
|
+
});
|
|
41
|
+
it('falls back to now + 1h TTL when the field is absent (backend not yet shipped)', () => {
|
|
42
|
+
const before = Date.now();
|
|
43
|
+
expect(expiresAtFromTokenResult({
|
|
44
|
+
token: 'abc'
|
|
45
|
+
})).toBeGreaterThanOrEqual(before + 60 * 60 * 1000 - 5_000);
|
|
46
|
+
expect(expiresAtFromTokenResult(null)).toBeGreaterThanOrEqual(before + 60 * 60 * 1000 - 5_000);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('isSessionExpiredError', () => {
|
|
50
|
+
it('matches the resolver’s "Not authorized" message', () => {
|
|
51
|
+
expect(isSessionExpiredError([{
|
|
52
|
+
message: 'Not authorized: you must be signed in as an admin.'
|
|
53
|
+
}])).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
it('is false for unrelated errors and empty/absent error lists', () => {
|
|
56
|
+
expect(isSessionExpiredError([{
|
|
57
|
+
message: 'Something else failed'
|
|
58
|
+
}])).toBe(false);
|
|
59
|
+
expect(isSessionExpiredError([])).toBe(false);
|
|
60
|
+
expect(isSessionExpiredError(null)).toBe(false);
|
|
61
|
+
expect(isSessionExpiredError(undefined)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -26,6 +26,7 @@ interface TrackingConfig {
|
|
|
26
26
|
interface PreviewConfig {
|
|
27
27
|
endpoint?: string;
|
|
28
28
|
revalidateEndpoint?: string;
|
|
29
|
+
clientSide?: boolean;
|
|
29
30
|
}
|
|
30
31
|
export interface Config {
|
|
31
32
|
form?: FormConfig;
|
|
@@ -111,6 +112,7 @@ export declare const makeConfig: (config?: Config) => {
|
|
|
111
112
|
preview: {
|
|
112
113
|
endpoint: string;
|
|
113
114
|
revalidateEndpoint: string | undefined;
|
|
115
|
+
clientSide: boolean;
|
|
114
116
|
};
|
|
115
117
|
corsErrorModal: boolean;
|
|
116
118
|
cdnUrl: string;
|
|
@@ -188,6 +190,7 @@ declare const _default: React.Context<{
|
|
|
188
190
|
preview: {
|
|
189
191
|
endpoint: string;
|
|
190
192
|
revalidateEndpoint: string | undefined;
|
|
193
|
+
clientSide: boolean;
|
|
191
194
|
};
|
|
192
195
|
corsErrorModal: boolean;
|
|
193
196
|
cdnUrl: string;
|
|
@@ -81,7 +81,7 @@ export const mergeConfig = (base, override) => {
|
|
|
81
81
|
return deepMerge(base, override);
|
|
82
82
|
};
|
|
83
83
|
export const makeConfig = config => {
|
|
84
|
-
var _config$form, _config$storeModal, _config$storeModal2, _config$storeModal3, _config$storeModal4, _config$stripe, _config$tracking, _config$tracking2, _config$tracking3, _config$tracking4, _config$preview, _config$preview2;
|
|
84
|
+
var _config$form, _config$storeModal, _config$storeModal2, _config$storeModal3, _config$storeModal4, _config$stripe, _config$tracking, _config$tracking2, _config$tracking3, _config$tracking4, _config$preview, _config$preview2, _config$preview3;
|
|
85
85
|
return {
|
|
86
86
|
typeTester: makeTypeTesterConfig(config === null || config === void 0 ? void 0 : config.typeTester),
|
|
87
87
|
form: {
|
|
@@ -107,7 +107,8 @@ export const makeConfig = config => {
|
|
|
107
107
|
endpoint: (config === null || config === void 0 ? void 0 : (_config$preview = config.preview) === null || _config$preview === void 0 ? void 0 : _config$preview.endpoint) ?? PREVIEW_ENDPOINT,
|
|
108
108
|
// No default: an unset revalidate endpoint hides the toolbar's refresh
|
|
109
109
|
// button (frameworks without a client-callable purge leave it unset).
|
|
110
|
-
revalidateEndpoint: config === null || config === void 0 ? void 0 : (_config$preview2 = config.preview) === null || _config$preview2 === void 0 ? void 0 : _config$preview2.revalidateEndpoint
|
|
110
|
+
revalidateEndpoint: config === null || config === void 0 ? void 0 : (_config$preview2 = config.preview) === null || _config$preview2 === void 0 ? void 0 : _config$preview2.revalidateEndpoint,
|
|
111
|
+
clientSide: (config === null || config === void 0 ? void 0 : (_config$preview3 = config.preview) === null || _config$preview3 === void 0 ? void 0 : _config$preview3.clientSide) ?? false
|
|
111
112
|
},
|
|
112
113
|
corsErrorModal: (config === null || config === void 0 ? void 0 : config.corsErrorModal) ?? true,
|
|
113
114
|
cdnUrl: (config === null || config === void 0 ? void 0 : config.cdnUrl) ?? FONTDUE_CDN_URL
|
|
@@ -5,7 +5,9 @@ import _FontdueAdminToolbarQuery from "../../__generated__/FontdueAdminToolbarQu
|
|
|
5
5
|
import React, { useContext, useEffect, useState } from 'react';
|
|
6
6
|
import { commitMutation, fetchQuery, graphql, useRelayEnvironment } from 'react-relay';
|
|
7
7
|
import ConfigContext from '../ConfigContext.js';
|
|
8
|
-
import {
|
|
8
|
+
import { useFontdueUrl } from '../UrlContext.js';
|
|
9
|
+
import { PREVIEW_ENDPOINT, DEFAULT_PREVIEW_TTL_MS, setPreviewMarkerCookie } from '../../preview/constants.js';
|
|
10
|
+
import { readPreviewState, expiresAtFromTokenResult, isSessionExpiredError } from './previewState.js';
|
|
9
11
|
import { fontdueBaseUrl, version } from '../../relay/environment.js';
|
|
10
12
|
// Admin-only affordance for logged-in foundry admins: reveal unpublished
|
|
11
13
|
// ("hidden") fonts across the whole storefront. Storefront pages are
|
|
@@ -32,22 +34,30 @@ export default function FontdueAdminToolbar() {
|
|
|
32
34
|
// is opt-in (no default) — see config.preview.revalidateEndpoint.
|
|
33
35
|
const previewEndpoint = (previewConfig === null || previewConfig === void 0 ? void 0 : previewConfig.endpoint) ?? PREVIEW_ENDPOINT;
|
|
34
36
|
const revalidateEndpoint = previewConfig === null || previewConfig === void 0 ? void 0 : previewConfig.revalidateEndpoint;
|
|
37
|
+
// Client-only embeds (the script-tag CDN bundle, a client SPA) have no server
|
|
38
|
+
// preview route to broker a token through, so they enter/exit preview by
|
|
39
|
+
// toggling the readable marker cookie directly — see setPreviewMarkerCookie.
|
|
40
|
+
const clientSidePreview = (previewConfig === null || previewConfig === void 0 ? void 0 : previewConfig.clientSide) ?? false;
|
|
35
41
|
|
|
36
42
|
// Where the admin is signed in: the configured Fontdue origin. Used for the
|
|
37
|
-
// "signed in at" line and the deep link back to the admin.
|
|
38
|
-
//
|
|
39
|
-
|
|
43
|
+
// "signed in at" line and the deep link back to the admin. Falls back to the
|
|
44
|
+
// URL the embed was initialized with (the script-tag bundle sets no
|
|
45
|
+
// FONTDUE_URL env); undefined (e.g. multi-tenant) drops both gracefully.
|
|
46
|
+
const contextUrl = useFontdueUrl();
|
|
47
|
+
const fontdueUrl = fontdueBaseUrl() ?? (contextUrl || undefined);
|
|
40
48
|
const fontdueHost = fontdueUrl ? safeHost(fontdueUrl) : null;
|
|
41
49
|
const adminUrl = fontdueUrl ? `${fontdueUrl.replace(/\/+$/, '')}/admin` : null;
|
|
42
50
|
const [adminName, setAdminName] = useState(null);
|
|
43
51
|
const [ready, setReady] = useState(false);
|
|
44
52
|
const [open, setOpen] = useState(false);
|
|
45
|
-
const [
|
|
53
|
+
const [previewState, setPreviewState] = useState('off');
|
|
46
54
|
const [busy, setBusy] = useState(false);
|
|
47
55
|
const [error, setError] = useState(null);
|
|
48
56
|
const [notice, setNotice] = useState(null);
|
|
57
|
+
const previewing = previewState === 'active';
|
|
58
|
+
const expired = previewState === 'expired';
|
|
49
59
|
useEffect(() => {
|
|
50
|
-
|
|
60
|
+
setPreviewState(readPreviewState());
|
|
51
61
|
// network-only: the provider's preloaded query already wrote a sessionless
|
|
52
62
|
// `viewer` to the store, so we must go to the network (with the session)
|
|
53
63
|
// rather than read that cached, admin-blind copy.
|
|
@@ -65,16 +75,33 @@ export default function FontdueAdminToolbar() {
|
|
|
65
75
|
return () => subscription.unsubscribe();
|
|
66
76
|
}, [environment]);
|
|
67
77
|
|
|
78
|
+
// Re-derive the state when the tab regains focus or becomes visible. A tab
|
|
79
|
+
// left open past the token's expiry won't otherwise notice — there's no
|
|
80
|
+
// event when a cookie's encoded expiry passes — so the warning would never
|
|
81
|
+
// appear until the next reload. Recovery stays user-initiated; we only
|
|
82
|
+
// refresh what state is shown.
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (typeof window === 'undefined') return;
|
|
85
|
+
const recheck = () => setPreviewState(readPreviewState());
|
|
86
|
+
window.addEventListener('focus', recheck);
|
|
87
|
+
document.addEventListener('visibilitychange', recheck);
|
|
88
|
+
return () => {
|
|
89
|
+
window.removeEventListener('focus', recheck);
|
|
90
|
+
document.removeEventListener('visibilitychange', recheck);
|
|
91
|
+
};
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
68
94
|
// Hidden until we've confirmed an admin session. The public always lands here.
|
|
69
95
|
if (!ready || !adminName) return null;
|
|
70
|
-
async function startPreview(token) {
|
|
96
|
+
async function startPreview(token, expiresAt) {
|
|
71
97
|
const res = await fetch(previewEndpoint, {
|
|
72
98
|
method: 'POST',
|
|
73
99
|
headers: {
|
|
74
100
|
'content-type': 'application/json'
|
|
75
101
|
},
|
|
76
102
|
body: JSON.stringify({
|
|
77
|
-
token
|
|
103
|
+
token,
|
|
104
|
+
expiresAt
|
|
78
105
|
})
|
|
79
106
|
});
|
|
80
107
|
if (res.ok) {
|
|
@@ -88,6 +115,15 @@ export default function FontdueAdminToolbar() {
|
|
|
88
115
|
setBusy(true);
|
|
89
116
|
setError(null);
|
|
90
117
|
setNotice(null);
|
|
118
|
+
// Client-only: no server render needs a token, so just mark preview on and
|
|
119
|
+
// reload — the admin's session cookie rides the next fetches and reveals
|
|
120
|
+
// hidden fonts now that the marker makes the client send `fontdue-preview`.
|
|
121
|
+
// There's no token to mint, so the marker just gets the default 1h expiry.
|
|
122
|
+
if (clientSidePreview) {
|
|
123
|
+
setPreviewMarkerCookie(Date.now() + DEFAULT_PREVIEW_TTL_MS);
|
|
124
|
+
window.location.reload();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
91
127
|
commitMutation(environment, {
|
|
92
128
|
mutation: tokenMutation,
|
|
93
129
|
variables: {},
|
|
@@ -96,10 +132,20 @@ export default function FontdueAdminToolbar() {
|
|
|
96
132
|
const token = (_res$createAdminToken = res.createAdminToken) === null || _res$createAdminToken === void 0 ? void 0 : _res$createAdminToken.token;
|
|
97
133
|
if (errors && errors.length > 0 || !token) {
|
|
98
134
|
setBusy(false);
|
|
99
|
-
|
|
135
|
+
// createAdminToken is gated on the admin session (current_user); a
|
|
136
|
+
// "Not authorized" error means the session itself has lapsed, which
|
|
137
|
+
// needs a fresh sign-in, not a retry. Surface that distinctly and
|
|
138
|
+
// clear the now-meaningless marker so the toolbar resets.
|
|
139
|
+
if (isSessionExpiredError(errors)) {
|
|
140
|
+
setPreviewMarkerCookie(false);
|
|
141
|
+
setPreviewState('off');
|
|
142
|
+
setError('Your session expired — sign in again.');
|
|
143
|
+
} else {
|
|
144
|
+
setError('Could not start preview.');
|
|
145
|
+
}
|
|
100
146
|
return;
|
|
101
147
|
}
|
|
102
|
-
startPreview(token);
|
|
148
|
+
startPreview(token, expiresAtFromTokenResult(res.createAdminToken));
|
|
103
149
|
},
|
|
104
150
|
onError: () => {
|
|
105
151
|
setBusy(false);
|
|
@@ -111,12 +157,24 @@ export default function FontdueAdminToolbar() {
|
|
|
111
157
|
setBusy(true);
|
|
112
158
|
setError(null);
|
|
113
159
|
setNotice(null);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
160
|
+
if (clientSidePreview) {
|
|
161
|
+
setPreviewMarkerCookie(false);
|
|
162
|
+
} else {
|
|
163
|
+
await fetch(previewEndpoint, {
|
|
164
|
+
method: 'DELETE'
|
|
165
|
+
});
|
|
166
|
+
}
|
|
117
167
|
window.location.reload();
|
|
118
168
|
}
|
|
119
169
|
|
|
170
|
+
// Recover from the expired state: mint a fresh token and re-enter. This is the
|
|
171
|
+
// same enter flow — a successful POST overwrites the stale token + marker
|
|
172
|
+
// cookies and reloads, so there's nothing to clear first. If the session has
|
|
173
|
+
// also lapsed, enterPreview surfaces the distinct "sign in again" message.
|
|
174
|
+
function reEnterPreview() {
|
|
175
|
+
enterPreview();
|
|
176
|
+
}
|
|
177
|
+
|
|
120
178
|
// Purge the cached storefront so public visitors see freshly published (or
|
|
121
179
|
// newly hidden) content. Targets the configured revalidate route as-is.
|
|
122
180
|
async function refreshCache() {
|
|
@@ -139,6 +197,7 @@ export default function FontdueAdminToolbar() {
|
|
|
139
197
|
return /*#__PURE__*/React.createElement("div", {
|
|
140
198
|
className: "fontdue-admin-toolbar",
|
|
141
199
|
"data-previewing": previewing,
|
|
200
|
+
"data-preview-state": previewState,
|
|
142
201
|
"data-testid": "fontdue-admin-toolbar"
|
|
143
202
|
}, open && /*#__PURE__*/React.createElement("div", {
|
|
144
203
|
className: "fontdue-admin-toolbar__panel"
|
|
@@ -148,7 +207,19 @@ export default function FontdueAdminToolbar() {
|
|
|
148
207
|
className: "fontdue-admin-toolbar__title"
|
|
149
208
|
}, "Fontdue"), /*#__PURE__*/React.createElement("span", {
|
|
150
209
|
className: "fontdue-admin-toolbar__user"
|
|
151
|
-
}, adminName)), /*#__PURE__*/React.createElement("
|
|
210
|
+
}, adminName)), expired ? /*#__PURE__*/React.createElement("div", {
|
|
211
|
+
className: "fontdue-admin-toolbar__expired",
|
|
212
|
+
role: "status",
|
|
213
|
+
"data-testid": "preview-expired"
|
|
214
|
+
}, /*#__PURE__*/React.createElement("p", {
|
|
215
|
+
className: "fontdue-admin-toolbar__expired-text"
|
|
216
|
+
}, /*#__PURE__*/React.createElement(WarningIcon, null), "Preview expired. Hidden fonts are no longer shown."), /*#__PURE__*/React.createElement("button", {
|
|
217
|
+
type: "button",
|
|
218
|
+
className: "fontdue-admin-toolbar__action",
|
|
219
|
+
onClick: reEnterPreview,
|
|
220
|
+
disabled: busy,
|
|
221
|
+
"data-testid": "reenter-preview-button"
|
|
222
|
+
}, "Re-enter preview")) : /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("label", {
|
|
152
223
|
className: "fontdue-admin-toolbar__toggle"
|
|
153
224
|
}, /*#__PURE__*/React.createElement("input", {
|
|
154
225
|
type: "checkbox",
|
|
@@ -158,7 +229,7 @@ export default function FontdueAdminToolbar() {
|
|
|
158
229
|
"data-testid": "preview-toggle"
|
|
159
230
|
}), /*#__PURE__*/React.createElement("span", null, "Preview hidden fonts")), /*#__PURE__*/React.createElement("p", {
|
|
160
231
|
className: "fontdue-admin-toolbar__hint"
|
|
161
|
-
}, previewing ? 'Unpublished fonts are visible across the site — only to you.' : 'Reveal unpublished fonts everywhere, only for you.'), (revalidateEndpoint || adminUrl) && /*#__PURE__*/React.createElement("div", {
|
|
232
|
+
}, previewing ? 'Unpublished fonts are visible across the site — only to you.' : 'Reveal unpublished fonts everywhere, only for you.')), (revalidateEndpoint || adminUrl) && /*#__PURE__*/React.createElement("div", {
|
|
162
233
|
className: "fontdue-admin-toolbar__actions"
|
|
163
234
|
}, revalidateEndpoint && /*#__PURE__*/React.createElement("button", {
|
|
164
235
|
type: "button",
|
|
@@ -186,10 +257,35 @@ export default function FontdueAdminToolbar() {
|
|
|
186
257
|
onClick: () => setOpen(v => !v),
|
|
187
258
|
"aria-expanded": open,
|
|
188
259
|
"data-testid": "fontdue-admin-toolbar-button"
|
|
189
|
-
}, /*#__PURE__*/React.createElement("span", {
|
|
260
|
+
}, expired ? /*#__PURE__*/React.createElement(WarningIcon, null) : /*#__PURE__*/React.createElement("span", {
|
|
190
261
|
className: "fontdue-admin-toolbar__dot",
|
|
191
262
|
"aria-hidden": "true"
|
|
192
|
-
}), "Fontdue", previewing ? ' · previewing' : ''));
|
|
263
|
+
}), "Fontdue", previewing ? ' · previewing' : '', expired ? ' · preview expired' : ''));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Small inline warning glyph (a triangle with an exclamation). Inherits the
|
|
267
|
+
// surrounding text color so it stays monochrome with the toolbar chrome.
|
|
268
|
+
function WarningIcon() {
|
|
269
|
+
return /*#__PURE__*/React.createElement("svg", {
|
|
270
|
+
className: "fontdue-admin-toolbar__warning-icon",
|
|
271
|
+
width: "13",
|
|
272
|
+
height: "13",
|
|
273
|
+
viewBox: "0 0 16 16",
|
|
274
|
+
fill: "none",
|
|
275
|
+
stroke: "currentColor",
|
|
276
|
+
strokeWidth: "1.4",
|
|
277
|
+
"aria-hidden": "true",
|
|
278
|
+
focusable: "false"
|
|
279
|
+
}, /*#__PURE__*/React.createElement("path", {
|
|
280
|
+
d: "M8 1.5 15 14H1L8 1.5Z",
|
|
281
|
+
strokeLinejoin: "miter"
|
|
282
|
+
}), /*#__PURE__*/React.createElement("path", {
|
|
283
|
+
d: "M8 6v3.5",
|
|
284
|
+
strokeLinecap: "square"
|
|
285
|
+
}), /*#__PURE__*/React.createElement("path", {
|
|
286
|
+
d: "M8 11.5v.5",
|
|
287
|
+
strokeLinecap: "square"
|
|
288
|
+
}));
|
|
193
289
|
}
|
|
194
290
|
|
|
195
291
|
// Hostname of a base URL, or the raw value if it can't be parsed (so a
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type PreviewState = 'off' | 'active' | 'expired';
|
|
2
|
+
export declare function derivePreviewState(hasMarker: boolean, expiresAt: number | undefined, now?: number): PreviewState;
|
|
3
|
+
export declare function readPreviewState(): PreviewState;
|
|
4
|
+
export declare function expiresAtFromTokenResult(result: unknown): number;
|
|
5
|
+
export declare function isSessionExpiredError(errors: ReadonlyArray<{
|
|
6
|
+
message?: string | null;
|
|
7
|
+
}> | null | undefined): boolean;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Pure preview-state logic for the admin toolbar, split out from the component
|
|
2
|
+
// so it can be unit-tested without a browser, a Relay environment, or the
|
|
3
|
+
// `graphql` Babel transform (importing the .tsx triggers the tag at load).
|
|
4
|
+
//
|
|
5
|
+
// The preview token is a stateless, time-only-expiring Phoenix.Token. There is
|
|
6
|
+
// no way to learn its validity client-side (it's httpOnly, and the toolbar's
|
|
7
|
+
// admin check rides the longer-lived *session* cookie, not the token), so the
|
|
8
|
+
// toolbar tracks the token's *expiry* instead and derives three states from the
|
|
9
|
+
// readable marker cookie:
|
|
10
|
+
//
|
|
11
|
+
// - 'off' — no marker cookie: not previewing.
|
|
12
|
+
// - 'active' — marker present and now < expiresAt: previewing.
|
|
13
|
+
// - 'expired' — marker present and now >= expiresAt: the token has lapsed,
|
|
14
|
+
// so server renders have silently fallen back to the public
|
|
15
|
+
// catalog. Show a warning and offer "re-enter".
|
|
16
|
+
|
|
17
|
+
import { DEFAULT_PREVIEW_TTL_MS, hasPreviewMarkerCookie, getPreviewExpiry } from '../../preview/constants.js';
|
|
18
|
+
// Pure derivation. `hasMarker` and `expiresAt` come from the marker cookie;
|
|
19
|
+
// `now` is injectable for tests.
|
|
20
|
+
export function derivePreviewState(hasMarker, expiresAt) {
|
|
21
|
+
let now = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : Date.now();
|
|
22
|
+
if (!hasMarker) return 'off';
|
|
23
|
+
// A marker with no/garbled expiry (e.g. a stale legacy `=1` cookie) is
|
|
24
|
+
// treated as expired: safer to prompt a re-enter than to imply it's live.
|
|
25
|
+
if (expiresAt === undefined || now >= expiresAt) return 'expired';
|
|
26
|
+
return 'active';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Read the current preview state straight off the document's cookies.
|
|
30
|
+
export function readPreviewState() {
|
|
31
|
+
return derivePreviewState(hasPreviewMarkerCookie(), getPreviewExpiry());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Pull an expiry out of the createAdminToken response. The backend will grow an
|
|
35
|
+
// `expiresAt` field (ISO-8601 or epoch-ms); until it does, the field is absent
|
|
36
|
+
// and we fall back to now + DEFAULT_PREVIEW_TTL_MS — the canonical 1h TTL lives
|
|
37
|
+
// in the backend `token.ex` `:admin_token` `max_age`. Read defensively so the
|
|
38
|
+
// client and backend halves can ship independently.
|
|
39
|
+
export function expiresAtFromTokenResult(result) {
|
|
40
|
+
const value = result === null || result === void 0 ? void 0 : result.expiresAt;
|
|
41
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
42
|
+
if (typeof value === 'string' && value !== '') {
|
|
43
|
+
const asNumber = Number(value);
|
|
44
|
+
if (Number.isFinite(asNumber)) return asNumber;
|
|
45
|
+
const asDate = Date.parse(value);
|
|
46
|
+
if (!Number.isNaN(asDate)) return asDate;
|
|
47
|
+
}
|
|
48
|
+
return Date.now() + DEFAULT_PREVIEW_TTL_MS;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// True when the createAdminToken mutation failed because the admin *session*
|
|
52
|
+
// (not the preview token) has expired. The resolver returns a "Not authorized"
|
|
53
|
+
// GraphQL error when current_user is nil, so we match that message rather than
|
|
54
|
+
// treating every failure as a recoverable retry.
|
|
55
|
+
export function isSessionExpiredError(errors) {
|
|
56
|
+
if (!errors) return false;
|
|
57
|
+
return errors.some(e => /not authoriz/i.test(e.message ?? ''));
|
|
58
|
+
}
|
|
@@ -71,11 +71,25 @@ const getInitialElements = () => {
|
|
|
71
71
|
return map;
|
|
72
72
|
};
|
|
73
73
|
const Root = props => {
|
|
74
|
+
var _props$config;
|
|
75
|
+
// The script-tag integration is client-only — there is no server render to
|
|
76
|
+
// forward a preview token to — so the admin toolbar must enter/exit preview
|
|
77
|
+
// by toggling the marker cookie in the browser. Force it on here, where the
|
|
78
|
+
// integration is unambiguously client-side. (The admin's Fontdue session
|
|
79
|
+
// rides the credentialed cross-origin GraphQL fetch and gates the reveal.)
|
|
80
|
+
const config = {
|
|
81
|
+
...props.config,
|
|
82
|
+
preview: {
|
|
83
|
+
...((_props$config = props.config) === null || _props$config === void 0 ? void 0 : _props$config.preview),
|
|
84
|
+
clientSide: true
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
74
88
|
// use a Map here instead of an array in case we register the same element
|
|
75
89
|
// more than once (otherwise we run into issues rendering the component
|
|
76
90
|
// twice into the same element). this also gives us a way to set unique keys.
|
|
77
91
|
const [elements, setElements] = useState(getInitialElements());
|
|
78
|
-
const store = useRef(createDefaultStore(
|
|
92
|
+
const store = useRef(createDefaultStore(config));
|
|
79
93
|
useEffect(() => {
|
|
80
94
|
// watch for any new or removed fontdue elements
|
|
81
95
|
|
|
@@ -190,7 +204,7 @@ const Root = props => {
|
|
|
190
204
|
return /*#__PURE__*/React.createElement(FontdueProvider, {
|
|
191
205
|
url: props.url,
|
|
192
206
|
stripeIntegration: props.stripeIntegration,
|
|
193
|
-
config:
|
|
207
|
+
config: config,
|
|
194
208
|
components: props.components,
|
|
195
209
|
store: store.current
|
|
196
210
|
}, /*#__PURE__*/React.createElement(React.Fragment, null, Array.from(elements).map(_ref => {
|
package/dist/fontdue.css
CHANGED
|
@@ -3424,6 +3424,27 @@ textarea.text-field__input {
|
|
|
3424
3424
|
font-size: 12px;
|
|
3425
3425
|
}
|
|
3426
3426
|
|
|
3427
|
+
.fontdue-admin-toolbar__expired {
|
|
3428
|
+
display: flex;
|
|
3429
|
+
flex-direction: column;
|
|
3430
|
+
gap: 10px;
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
.fontdue-admin-toolbar__expired-text {
|
|
3434
|
+
display: flex;
|
|
3435
|
+
align-items: flex-start;
|
|
3436
|
+
gap: 8px;
|
|
3437
|
+
margin: 0;
|
|
3438
|
+
font-size: 12px;
|
|
3439
|
+
color: rgba(255, 255, 255, 0.85);
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3442
|
+
.fontdue-admin-toolbar__warning-icon {
|
|
3443
|
+
flex: none;
|
|
3444
|
+
margin-top: 1px;
|
|
3445
|
+
color: #e0a23c;
|
|
3446
|
+
}
|
|
3447
|
+
|
|
3427
3448
|
.fontdue-admin-toolbar__error {
|
|
3428
3449
|
margin: 10px 0 0;
|
|
3429
3450
|
color: #ff6b6b;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
export declare const PREVIEW_TOKEN_COOKIE = "fontdue_preview_token";
|
|
2
2
|
export declare const PREVIEW_MARKER_COOKIE = "fontdue_preview";
|
|
3
3
|
export declare const PREVIEW_ENDPOINT = "/api/preview";
|
|
4
|
+
export declare const DEFAULT_PREVIEW_TTL_MS: number;
|
|
5
|
+
export declare const PREVIEW_MARKER_GRACE_MS: number;
|
|
4
6
|
export declare const PREVIEW_HEADER = "fontdue-preview";
|
|
5
7
|
export declare function hasPreviewMarkerCookie(): boolean;
|
|
8
|
+
export declare function getPreviewExpiry(): number | undefined;
|
|
9
|
+
export declare function setPreviewMarkerCookie(on: boolean | number): void;
|