@pramen/cms-editor 0.0.19 → 0.0.21

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.19",
3
+ "version": "0.0.21",
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/app.tsx CHANGED
@@ -43,12 +43,16 @@ function Setup({ cfg, onSave }: { cfg: Config; onSave: (c: Config) => void }) {
43
43
  );
44
44
  }
45
45
 
46
+ type View = "pages" | "media" | "users" | "settings";
47
+ interface Me { userId?: string; roles?: string[]; [k: string]: unknown }
48
+
46
49
  function Editor({ api, cfg, onReconfigure }: { api: Api; cfg: Config; onReconfigure: () => void }) {
47
50
  const [pages, setPages] = useState<Page[]>([]);
48
51
  const [blockTypes, setBlockTypes] = useState<BlockType[]>([]);
49
52
  const [current, setCurrent] = useState<Page | null>(null);
50
- const [view, setView] = useState<"pages" | "media">("pages");
53
+ const [view, setView] = useState<View>("pages");
51
54
  const [err, setErr] = useState("");
55
+ const [me, setMe] = useState<Me | null>(null);
52
56
 
53
57
  const refreshPages = useCallback(() => {
54
58
  api.listPages().then(setPages).catch((e) => setErr(errMsg(e)));
@@ -56,8 +60,13 @@ function Editor({ api, cfg, onReconfigure }: { api: Api; cfg: Config; onReconfig
56
60
  useEffect(() => {
57
61
  refreshPages();
58
62
  api.listBlockTypes().then(setBlockTypes).catch((e) => setErr(errMsg(e)));
63
+ // `me` gates the Users tab — a failing call is fine (leaves it undefined).
64
+ api.call<Me>("me").then(setMe).catch(() => setMe({}));
59
65
  }, [api, refreshPages]);
60
66
 
67
+ const isAdmin = (me?.roles ?? []).includes("admin");
68
+ const go = (v: View) => { setView(v); setCurrent(null); };
69
+
61
70
  return (
62
71
  <>
63
72
  <div className="bar">
@@ -71,12 +80,12 @@ function Editor({ api, cfg, onReconfigure }: { api: Api; cfg: Config; onReconfig
71
80
  ) : null}
72
81
  <span className="grow" />
73
82
  <nav className="tabs nav" style={{ margin: 0 }}>
74
- <button className={view === "pages" ? "on" : ""} onClick={() => { setView("pages"); setCurrent(null); }}>
75
- Pages
76
- </button>
77
- <button className={view === "media" ? "on" : ""} onClick={() => setView("media")}>
78
- Media
79
- </button>
83
+ <button className={view === "pages" ? "on" : ""} onClick={() => go("pages")}>Pages</button>
84
+ <button className={view === "media" ? "on" : ""} onClick={() => go("media")}>Media</button>
85
+ {isAdmin ? (
86
+ <button className={view === "users" ? "on" : ""} onClick={() => go("users")}>Users</button>
87
+ ) : null}
88
+ <button className={view === "settings" ? "on" : ""} onClick={() => go("settings")}>Settings</button>
80
89
  </nav>
81
90
  <span className="muted" style={{ marginLeft: 12 }}>{cfg.tenant}</span>
82
91
  <button className="ghost sm" onClick={onReconfigure}>
@@ -86,6 +95,10 @@ function Editor({ api, cfg, onReconfigure }: { api: Api; cfg: Config; onReconfig
86
95
  {err ? <div className="banner err">{err}</div> : null}
87
96
  {view === "media" ? (
88
97
  <MediaLibrary api={api} onError={setErr} />
98
+ ) : view === "users" ? (
99
+ <UsersView api={api} me={me} onError={setErr} />
100
+ ) : view === "settings" ? (
101
+ <SettingsView api={api} cfg={cfg} me={me} onSignOut={onReconfigure} onError={setErr} />
89
102
  ) : current ? (
90
103
  <PageEditor api={api} page={current} blockTypes={blockTypes} onBack={() => { setCurrent(null); refreshPages(); }} onChange={(p) => setCurrent(p)} />
91
104
  ) : (
@@ -709,3 +722,248 @@ function reorderMove(blocks: RenderedBlock[], region: string, i: number, d: numb
709
722
  [ids[i], ids[j]] = [ids[j], ids[i]];
710
723
  reorder(region, ids);
711
724
  }
725
+
726
+ // --- users management (admin) ------------------------------------------------
727
+
728
+ interface UserRow {
729
+ username: string;
730
+ email?: string | null;
731
+ roles?: string[] | string;
732
+ active?: boolean | number | null;
733
+ createdAt?: number | null;
734
+ }
735
+
736
+ function rolesOf(u: UserRow): string[] {
737
+ const r = u.roles;
738
+ if (Array.isArray(r)) return r.filter((x): x is string => typeof x === "string");
739
+ if (typeof r === "string") { try { const j = JSON.parse(r); return Array.isArray(j) ? j : []; } catch { return []; } }
740
+ return [];
741
+ }
742
+
743
+ function UsersView({ api, me, onError }: { api: Api; me: Me | null; onError: (s: string) => void }) {
744
+ const [users, setUsers] = useState<UserRow[]>([]);
745
+ const [inviting, setInviting] = useState(false);
746
+ const [busy, setBusy] = useState<string>("");
747
+
748
+ const refresh = useCallback(() => {
749
+ api.call<UserRow[]>("listUsers", { limit: 200 }).then(setUsers).catch((e) => onError(errMsg(e)));
750
+ }, [api, onError]);
751
+ useEffect(() => { refresh(); }, [refresh]);
752
+
753
+ const setRoles = async (u: UserRow, roles: string[]) => {
754
+ setBusy(u.username);
755
+ try { await api.call("setUserRoles", { username: u.username, roles }); refresh(); }
756
+ catch (e) { onError(errMsg(e)); }
757
+ finally { setBusy(""); }
758
+ };
759
+ const setActive = async (u: UserRow, active: boolean) => {
760
+ setBusy(u.username);
761
+ try { await api.call("setUserActive", { username: u.username, active }); refresh(); }
762
+ catch (e) { onError(errMsg(e)); }
763
+ finally { setBusy(""); }
764
+ };
765
+ const del = async (u: UserRow) => {
766
+ if (!confirm(`Delete user ${u.username}? This cannot be undone.`)) return;
767
+ setBusy(u.username);
768
+ try { await api.call("deleteUser", { username: u.username }); refresh(); }
769
+ catch (e) { onError(errMsg(e)); }
770
+ finally { setBusy(""); }
771
+ };
772
+
773
+ return (
774
+ <>
775
+ <div className="hero">
776
+ <h1 className="hero-h">
777
+ <span className="lead">Users</span>
778
+ <span className="em">{users.length === 0 ? "None yet" : users.length === 1 ? "1 account" : `${users.length} accounts`}</span>
779
+ </h1>
780
+ <div className="cta">
781
+ <span className="cta-text">Let&apos;s <span className="em">invite</span> someone</span>
782
+ <button className="primary" onClick={() => setInviting(true)}>+ Invite</button>
783
+ </div>
784
+ </div>
785
+ <div className="list-wrap">
786
+ <div className="list">
787
+ {users.map((u) => {
788
+ const roles = rolesOf(u);
789
+ const isMe = me?.userId === u.username;
790
+ const active = u.active === undefined || u.active === null ? true : Boolean(Number(u.active));
791
+ return (
792
+ <div className="row" key={u.username} style={{ cursor: "default", alignItems: "flex-start", flexWrap: "wrap" }}>
793
+ <div style={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 200, gap: 2 }}>
794
+ <span style={{ fontWeight: 600 }}>{u.username}{isMe ? <span className="muted" style={{ marginLeft: 6, fontWeight: 400 }}>(you)</span> : null}</span>
795
+ {u.email && u.email !== u.username ? <span className="muted" style={{ fontSize: 12 }}>{u.email}</span> : null}
796
+ {u.createdAt ? <span className="muted" style={{ fontSize: 11 }}>joined {new Date(Number(u.createdAt)).toLocaleDateString()}</span> : null}
797
+ </div>
798
+ <RolesInput value={roles} disabled={busy === u.username} onSave={(next) => setRoles(u, next)} />
799
+ <span className={`pill ${active ? "published" : "archived"}`}>{active ? "active" : "inactive"}</span>
800
+ <button className="sm ghost" disabled={busy === u.username || isMe} onClick={() => setActive(u, !active)}>
801
+ {active ? "Deactivate" : "Activate"}
802
+ </button>
803
+ <button className="sm ghost danger" disabled={busy === u.username || isMe} onClick={() => del(u)}>
804
+ Delete
805
+ </button>
806
+ </div>
807
+ );
808
+ })}
809
+ {users.length === 0 ? <p className="muted">No users yet. Invite someone to get started.</p> : null}
810
+ </div>
811
+ </div>
812
+ {inviting ? <InviteUser api={api} onClose={() => setInviting(false)} onInvited={() => { setInviting(false); refresh(); }} onError={onError} /> : null}
813
+ </>
814
+ );
815
+ }
816
+
817
+ function RolesInput({ value, disabled, onSave }: { value: string[]; disabled: boolean; onSave: (roles: string[]) => void }) {
818
+ const [text, setText] = useState(value.join(", "));
819
+ const [editing, setEditing] = useState(false);
820
+ useEffect(() => { setText(value.join(", ")); }, [value]);
821
+ const commit = () => {
822
+ setEditing(false);
823
+ const next = text.split(",").map((s) => s.trim()).filter(Boolean);
824
+ if (next.length === 0) { setText(value.join(", ")); return; }
825
+ if (next.length === value.length && next.every((r, i) => r === value[i])) return;
826
+ onSave(next);
827
+ };
828
+ if (editing) {
829
+ return (
830
+ <input
831
+ autoFocus
832
+ style={{ width: 220 }}
833
+ value={text}
834
+ disabled={disabled}
835
+ onChange={(e) => setText(e.target.value)}
836
+ onBlur={commit}
837
+ onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") { setText(value.join(", ")); setEditing(false); } }}
838
+ />
839
+ );
840
+ }
841
+ return (
842
+ <span style={{ display: "flex", gap: 4, flexWrap: "wrap", cursor: "pointer" }} onClick={() => setEditing(true)} title="Click to edit">
843
+ {value.length === 0 ? <span className="pill">no roles</span> : value.map((r) => <span key={r} className={`pill ${r === "admin" ? "published" : ""}`}>{r}</span>)}
844
+ </span>
845
+ );
846
+ }
847
+
848
+ function InviteUser({ api, onClose, onInvited, onError }: { api: Api; onClose: () => void; onInvited: () => void; onError: (s: string) => void }) {
849
+ const [email, setEmail] = useState("");
850
+ const [roles, setRoles] = useState("editor");
851
+ const [busy, setBusy] = useState(false);
852
+ const invite = async () => {
853
+ setBusy(true);
854
+ try {
855
+ const rs = roles.split(",").map((s) => s.trim()).filter(Boolean);
856
+ await api.call("inviteUser", { email, roles: rs });
857
+ onInvited();
858
+ } catch (e) {
859
+ onError(errMsg(e));
860
+ } finally {
861
+ setBusy(false);
862
+ }
863
+ };
864
+ return (
865
+ <div className="scrim" onClick={onClose}>
866
+ <div className="modal" onClick={(e) => e.stopPropagation()}>
867
+ <h2>
868
+ Invite an <span className="dim">editor</span> or teammate
869
+ </h2>
870
+ <p className="muted" style={{ marginTop: -8 }}>They&apos;ll get a one-time magic link that logs them in and creates their account.</p>
871
+ <label className="field">
872
+ <span className="lbl">Email</span>
873
+ <input value={email} type="email" autoFocus onChange={(e) => setEmail(e.target.value)} placeholder="them@example.com" />
874
+ </label>
875
+ <label className="field">
876
+ <span className="lbl">Roles <span className="muted" style={{ fontWeight: 400 }}>(comma-separated — e.g. editor, reviewer, admin)</span></span>
877
+ <input value={roles} onChange={(e) => setRoles(e.target.value)} placeholder="editor" />
878
+ </label>
879
+ <div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 16 }}>
880
+ <button className="ghost" onClick={onClose}>Cancel</button>
881
+ <button className="primary" onClick={invite} disabled={busy || !email}>{busy ? "Sending…" : "Send invite"}</button>
882
+ </div>
883
+ </div>
884
+ </div>
885
+ );
886
+ }
887
+
888
+ // --- settings ----------------------------------------------------------------
889
+
890
+ function SettingsView({ api, cfg, me, onSignOut, onError }: { api: Api; cfg: Config; me: Me | null; onSignOut: () => void; onError: (s: string) => void }) {
891
+ return (
892
+ <>
893
+ <div className="hero">
894
+ <h1 className="hero-h">
895
+ <span className="lead">Settings</span>
896
+ <span className="em">{me?.userId ?? "your account"}</span>
897
+ </h1>
898
+ </div>
899
+ <div className="list-wrap" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }}>
900
+ <MyAccountCard api={api} me={me} onError={onError} onSignOut={onSignOut} />
901
+ <AboutCard cfg={cfg} me={me} />
902
+ </div>
903
+ </>
904
+ );
905
+ }
906
+
907
+ function MyAccountCard({ api, me, onError, onSignOut }: { api: Api; me: Me | null; onError: (s: string) => void; onSignOut: () => void }) {
908
+ const [email, setEmail] = useState("");
909
+ const [pwCurrent, setPwCurrent] = useState("");
910
+ const [pwNew, setPwNew] = useState("");
911
+ const [msg, setMsg] = useState("");
912
+ const [busy, setBusy] = useState(false);
913
+ const flash = (m: string) => { setMsg(m); setTimeout(() => setMsg(""), 1800); };
914
+
915
+ const saveEmail = async () => {
916
+ setBusy(true);
917
+ try { await api.call("changeEmail", { email }); setEmail(""); flash("Contact email updated"); }
918
+ catch (e) { onError(errMsg(e)); }
919
+ finally { setBusy(false); }
920
+ };
921
+ const savePassword = async () => {
922
+ setBusy(true);
923
+ try { await api.call("changePassword", { currentPassword: pwCurrent, newPassword: pwNew }); setPwCurrent(""); setPwNew(""); flash("Password updated"); }
924
+ catch (e) { onError(errMsg(e)); }
925
+ finally { setBusy(false); }
926
+ };
927
+
928
+ return (
929
+ <div className="inspect" style={{ background: "var(--surface-2)" }}>
930
+ <div className="sect" style={{ marginTop: 0 }}>My account</div>
931
+ {msg ? <div className="banner ok">{msg}</div> : null}
932
+ <div className="kv" style={{ marginBottom: 16 }}>
933
+ <span>Username</span><span>{me?.userId ?? "—"}</span>
934
+ <span>Roles</span><span>{(me?.roles ?? []).join(", ") || "—"}</span>
935
+ </div>
936
+ <label className="field">
937
+ <span className="lbl">Change contact email</span>
938
+ <input value={email} type="email" onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com" />
939
+ </label>
940
+ <button className="primary" onClick={saveEmail} disabled={busy || !email} style={{ width: "100%" }}>Save email</button>
941
+ <div style={{ height: 20 }} />
942
+ <label className="field">
943
+ <span className="lbl">Current password</span>
944
+ <input value={pwCurrent} type="password" onChange={(e) => setPwCurrent(e.target.value)} autoComplete="current-password" />
945
+ </label>
946
+ <label className="field">
947
+ <span className="lbl">New password <span className="muted" style={{ fontWeight: 400 }}>(at least 8 characters)</span></span>
948
+ <input value={pwNew} type="password" onChange={(e) => setPwNew(e.target.value)} autoComplete="new-password" />
949
+ </label>
950
+ <button className="primary" onClick={savePassword} disabled={busy || pwNew.length < 8 || pwCurrent.length === 0} style={{ width: "100%" }}>Change password</button>
951
+ <div style={{ height: 24 }} />
952
+ <button className="ghost danger" onClick={onSignOut} style={{ width: "100%" }}>Sign out</button>
953
+ </div>
954
+ );
955
+ }
956
+
957
+ function AboutCard({ cfg, me }: { cfg: Config; me: Me | null }) {
958
+ return (
959
+ <div className="inspect" style={{ background: "var(--surface-2)" }}>
960
+ <div className="sect" style={{ marginTop: 0 }}>About</div>
961
+ <div className="kv">
962
+ <span>Tenant</span><span>{cfg.tenant || "main"}</span>
963
+ <span>API</span><span style={{ wordBreak: "break-all" }}>{cfg.baseUrl}</span>
964
+ <span>Signed in as</span><span>{me?.userId ?? "—"}</span>
965
+ <span>Editor</span><span>pramen · cms-editor</span>
966
+ </div>
967
+ </div>
968
+ );
969
+ }