@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms-editor",
3
- "version": "0.0.24",
3
+ "version": "0.0.26",
4
4
  "description": "Visual block/page editor for @pramen/cms — a standalone React SPA that talks to the CMS handlers over HTTP.",
5
5
  "license": "MIT",
6
6
  "repository": {
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
- constructor(private cfg: Config) {}
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: {
@@ -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
- const api = useMemo(() => new Api(cfg), [cfg]);
40
- const configured = Boolean(cfg.baseUrl && cfg.token);
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 (!configured) return;
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, configured]);
89
+ }, [api, authValid]);
47
90
 
48
- if (!configured) {
49
- return <Setup cfg={cfg} onSave={(c) => { saveConfig(c); setCfg(c); }} />;
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: () => { setMe(null); setError(""); setCfg({ ...cfg, token: "" }); },
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
  }