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.
@@ -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
- expect(await res.json()).toEqual({
20
- ok: true,
21
- preview: true
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
- expect(markerCookie).toContain(`${PREVIEW_MARKER_COOKIE}=1`);
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 { PREVIEW_ENDPOINT, hasPreviewMarkerCookie } from '../../preview/constants.js';
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. Undefined (e.g.
38
- // multi-tenant) just drops both gracefully.
39
- const fontdueUrl = fontdueBaseUrl();
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 [previewing, setPreviewing] = useState(false);
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
- setPreviewing(hasPreviewMarkerCookie());
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
- setError('Could not start preview.');
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
- await fetch(previewEndpoint, {
115
- method: 'DELETE'
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("label", {
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(props.config));
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: props.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;