@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/dist/index.html +1 -1
- package/dist/main.43fpw937.js +405 -0
- package/package.json +1 -1
- package/src/app.tsx +265 -7
- package/dist/main.gxsrrsct.js +0 -405
package/package.json
CHANGED
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<
|
|
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={() =>
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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'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'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
|
+
}
|