@pramen/cms-editor 0.0.24 → 0.0.26
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/config.js +5 -0
- package/dist/index.html +4 -1
- package/dist/{main.nh6tk2qq.js → main.ywh6bxc0.js} +64 -64
- package/package.json +1 -1
- package/src/api.ts +33 -1
- package/src/app-context.tsx +56 -8
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -20,6 +20,20 @@ export class ApiError extends Error {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** Decode a JWT's `exp` claim (seconds) and report whether it has passed. A non-JWT token or
|
|
24
|
+
* one without `exp` is treated as NOT expired (fail open) — the server is the real authority.
|
|
25
|
+
* Used to bounce an expired session to sign-in before/instead of firing doomed requests. */
|
|
26
|
+
export function isTokenExpired(token: string): boolean {
|
|
27
|
+
try {
|
|
28
|
+
const seg = String(token).split(".")[1];
|
|
29
|
+
if (!seg) return false;
|
|
30
|
+
const payload = JSON.parse(atob(seg.replace(/-/g, "+").replace(/_/g, "/")));
|
|
31
|
+
return typeof payload.exp === "number" && Date.now() >= payload.exp * 1000;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
23
37
|
const LS = "pramen.cmsEditor";
|
|
24
38
|
export function loadConfig(): Config {
|
|
25
39
|
try {
|
|
@@ -33,9 +47,21 @@ export function loadConfig(): Config {
|
|
|
33
47
|
export function saveConfig(cfg: Config): void {
|
|
34
48
|
localStorage.setItem(LS, JSON.stringify(cfg));
|
|
35
49
|
}
|
|
50
|
+
/** Drop the persisted session (used when handing off to an external sign-in page). */
|
|
51
|
+
export function clearConfig(): void {
|
|
52
|
+
try {
|
|
53
|
+
localStorage.removeItem(LS);
|
|
54
|
+
} catch {
|
|
55
|
+
/* ignore */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
36
58
|
|
|
37
59
|
export class Api {
|
|
38
|
-
|
|
60
|
+
/** `onExpired` fires when a call is attempted with a locally-expired token — the app wires
|
|
61
|
+
* it to redirect to sign-in. We can't key off HTTP status: an expired token is rejected as
|
|
62
|
+
* anonymous and a role-gated handler then returns 403, indistinguishable from a valid token
|
|
63
|
+
* that merely lacks the role. So expiry is detected client-side from the token's `exp`. */
|
|
64
|
+
constructor(private cfg: Config, private onExpired?: () => void) {}
|
|
39
65
|
|
|
40
66
|
setConfig(cfg: Config): void {
|
|
41
67
|
this.cfg = cfg;
|
|
@@ -47,6 +73,12 @@ export class Api {
|
|
|
47
73
|
|
|
48
74
|
/** Call a CMS RPC handler. Throws ApiError on a non-`ok` envelope. */
|
|
49
75
|
async call<T = unknown>(name: string, input?: unknown): Promise<T> {
|
|
76
|
+
// Expired token: hand off to sign-in instead of firing a request that will 403 into an
|
|
77
|
+
// error banner. The returned promise never settles — navigation is already underway.
|
|
78
|
+
if (this.onExpired && this.cfg.token && isTokenExpired(this.cfg.token)) {
|
|
79
|
+
this.onExpired();
|
|
80
|
+
return new Promise<T>(() => {});
|
|
81
|
+
}
|
|
50
82
|
const res = await fetch(`${this.base()}/rpc/${name}`, {
|
|
51
83
|
method: "POST",
|
|
52
84
|
headers: {
|
package/src/app-context.tsx
CHANGED
|
@@ -5,7 +5,29 @@
|
|
|
5
5
|
|
|
6
6
|
import { Button, Input } from "@podoba/react";
|
|
7
7
|
import { createContext, use, useEffect, useMemo, useState } from "react";
|
|
8
|
-
import { Api, loadConfig, saveConfig, type Config } from "./api";
|
|
8
|
+
import { Api, clearConfig, isTokenExpired, loadConfig, saveConfig, type Config } from "./api";
|
|
9
|
+
|
|
10
|
+
declare global {
|
|
11
|
+
interface Window {
|
|
12
|
+
/** Runtime config set by the host's /config.js (see @pramen/cms-editor build). */
|
|
13
|
+
PRAMEN_CMS_EDITOR?: { signInUrl?: string };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** External sign-in URL, if the host configured one via /config.js. When set, an
|
|
18
|
+
* unauthenticated OR expired session is redirected here instead of the built-in Setup
|
|
19
|
+
* screen — for deployments whose auth (magic-link, SSO, …) lives on a separate page.
|
|
20
|
+
*
|
|
21
|
+
* `?setup=1` forces the built-in Setup screen even when a sign-in URL is configured — the
|
|
22
|
+
* bootstrap escape hatch for pasting a first-admin JWT before any account exists. */
|
|
23
|
+
const SIGN_IN_URL: string | undefined =
|
|
24
|
+
typeof window !== "undefined" && !new URLSearchParams(window.location.search).has("setup") ? window.PRAMEN_CMS_EDITOR?.signInUrl : undefined;
|
|
25
|
+
|
|
26
|
+
/** Drop the stale session and hand off to the external sign-in page. */
|
|
27
|
+
function redirectToSignIn(): void {
|
|
28
|
+
clearConfig();
|
|
29
|
+
location.replace(SIGN_IN_URL!);
|
|
30
|
+
}
|
|
9
31
|
|
|
10
32
|
export interface Me {
|
|
11
33
|
userId?: string;
|
|
@@ -36,17 +58,40 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|
|
36
58
|
const [cfg, setCfg] = useState<Config>(loadConfig());
|
|
37
59
|
const [me, setMe] = useState<Me | null>(null);
|
|
38
60
|
const [error, setError] = useState("");
|
|
39
|
-
|
|
40
|
-
|
|
61
|
+
// A usable session = a base URL + a token that is NOT expired. An expired token counts as
|
|
62
|
+
// no session: otherwise the editor mounts and every RPC 403s into an error banner.
|
|
63
|
+
const authValid = Boolean(cfg.baseUrl && cfg.token) && !isTokenExpired(cfg.token);
|
|
64
|
+
const api = useMemo(() => new Api(cfg, SIGN_IN_URL ? redirectToSignIn : undefined), [cfg]);
|
|
65
|
+
|
|
66
|
+
// When an external sign-in page is configured, bounce there on boot AND the moment the
|
|
67
|
+
// token expires mid-session (poll + on tab focus), so an idle editor never sits on a dead
|
|
68
|
+
// session showing errors. Keyed on the token's own `exp` — the server can't distinguish an
|
|
69
|
+
// expired token (rejected as anonymous → 403) from a valid token lacking a role.
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!SIGN_IN_URL) return;
|
|
72
|
+
const check = () => { if (!cfg.token || isTokenExpired(cfg.token)) redirectToSignIn(); };
|
|
73
|
+
check();
|
|
74
|
+
const onVisible = () => { if (!document.hidden) check(); };
|
|
75
|
+
const id = window.setInterval(check, 30000);
|
|
76
|
+
window.addEventListener("visibilitychange", onVisible);
|
|
77
|
+
window.addEventListener("focus", check);
|
|
78
|
+
return () => {
|
|
79
|
+
window.clearInterval(id);
|
|
80
|
+
window.removeEventListener("visibilitychange", onVisible);
|
|
81
|
+
window.removeEventListener("focus", check);
|
|
82
|
+
};
|
|
83
|
+
}, [cfg.token]);
|
|
41
84
|
|
|
42
85
|
useEffect(() => {
|
|
43
|
-
if (!
|
|
86
|
+
if (!authValid) return;
|
|
44
87
|
// `me` gates the Users tab + drives Settings — a failing call is fine (leaves it {}).
|
|
45
88
|
api.call<Me>("me").then(setMe).catch(() => setMe({}));
|
|
46
|
-
}, [api,
|
|
89
|
+
}, [api, authValid]);
|
|
47
90
|
|
|
48
|
-
if (!
|
|
49
|
-
|
|
91
|
+
if (!authValid) {
|
|
92
|
+
// Configured external sign-in → hand off (the boot effect above navigates). Otherwise
|
|
93
|
+
// fall back to the built-in Setup screen (paste a JWT).
|
|
94
|
+
return SIGN_IN_URL ? null : <Setup cfg={cfg} onSave={(c) => { saveConfig(c); setCfg(c); }} />;
|
|
50
95
|
}
|
|
51
96
|
|
|
52
97
|
const value: AppContextValue = {
|
|
@@ -56,7 +101,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|
|
56
101
|
isAdmin: (me?.roles ?? []).includes("admin"),
|
|
57
102
|
error,
|
|
58
103
|
setError,
|
|
59
|
-
reconfigure: () => {
|
|
104
|
+
reconfigure: () => {
|
|
105
|
+
if (SIGN_IN_URL) { redirectToSignIn(); return; }
|
|
106
|
+
setMe(null); setError(""); setCfg({ ...cfg, token: "" });
|
|
107
|
+
},
|
|
60
108
|
};
|
|
61
109
|
return <AppContext value={value}>{children}</AppContext>;
|
|
62
110
|
}
|