smoodly 0.0.23 → 0.0.24
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/README.md +11 -4
- package/dist/admin/auth.d.ts +10 -5
- package/dist/admin/auth.js +14 -13
- package/dist/admin/client-ops.js +1 -1
- package/dist/admin/editor/PageSettings.js +6 -0
- package/dist/admin/index.d.ts +2 -0
- package/dist/admin/next/index.d.ts +2 -0
- package/dist/admin/ops-impl.js +69 -1
- package/dist/admin/ops.d.ts +13 -2
- package/dist/admin/shell/AdminApp.js +26 -3
- package/dist/admin/shell/EntryForm.js +17 -0
- package/dist/admin/shell/IconRail.d.ts +3 -1
- package/dist/admin/shell/IconRail.js +4 -3
- package/dist/admin/shell/InviteModal.d.ts +6 -0
- package/dist/admin/shell/InviteModal.js +24 -0
- package/dist/admin/shell/LoginView.js +34 -5
- package/dist/admin/shell/MembersPage.d.ts +2 -1
- package/dist/admin/shell/MembersPage.js +72 -25
- package/dist/admin/shell/PagesList.js +7 -0
- package/dist/admin/shell/SetPasswordView.d.ts +7 -0
- package/dist/admin/shell/SetPasswordView.js +41 -0
- package/dist/admin/shell/SettingsView.d.ts +5 -1
- package/dist/admin/shell/SettingsView.js +25 -4
- package/dist/admin/shell/live-url.d.ts +9 -0
- package/dist/admin/shell/live-url.js +10 -0
- package/dist/admin/shell/members-list.d.ts +13 -1
- package/dist/admin/shell/members-list.js +19 -7
- package/dist/admin/shell/session.d.ts +6 -0
- package/dist/admin/shell/session.js +17 -2
- package/dist/admin/supabase-auth.js +6 -5
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/member-store.d.ts +30 -1
- package/dist/member-store.js +37 -6
- package/dist/permissions.d.ts +7 -0
- package/dist/permissions.js +19 -0
- package/dist/supabase-member-store.d.ts +8 -1
- package/dist/supabase-member-store.js +62 -2
- package/package.json +1 -1
|
@@ -1,36 +1,83 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
// packages/smoodly/src/admin/shell/MembersPage.tsx
|
|
4
|
-
// The Members page (spec 2026-09-25 §7): the admission
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// The Members page (spec 2026-09-25 §7; management §4): the admission
|
|
5
|
+
// table with Invite, an in-place role control and Remove — all off when
|
|
6
|
+
// the control plane manages the space (the console URL banner stays).
|
|
7
|
+
// Own row and the last admin render their controls disabled with the
|
|
8
|
+
// guard's reason, so the rule is visible before it is hit.
|
|
8
9
|
import { useEffect, useState } from "react";
|
|
9
|
-
import {
|
|
10
|
+
import { MEMBER_ROLES } from "../../permissions.js";
|
|
11
|
+
import { ActionMenu } from "../ui/ActionMenu.js";
|
|
12
|
+
import { Banner, Button, Segmented, StatusBadge } from "../ui/primitives.js";
|
|
10
13
|
import { ErrorPane } from "./ErrorPane.js";
|
|
14
|
+
import { InviteModal } from "./InviteModal.js";
|
|
11
15
|
import { ListView } from "./ListView.js";
|
|
12
|
-
import { memberRows } from "./members-list.js";
|
|
13
|
-
export function MembersPage({ ops, initial = null }) {
|
|
16
|
+
import { GUARD_TITLE, memberRows, roleLabel } from "./members-list.js";
|
|
17
|
+
export function MembersPage({ ops, userId, initial = null }) {
|
|
14
18
|
const [data, setData] = useState(initial);
|
|
15
19
|
const [error, setError] = useState(null);
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
const [notice, setNotice] = useState(null);
|
|
21
|
+
const [inviting, setInviting] = useState(false);
|
|
22
|
+
const load = () => ops.members.list()
|
|
23
|
+
.then((r) => (r.ok ? setData(r.data) : setError(r.message)))
|
|
24
|
+
.catch(() => setError("Couldn't reach the server."));
|
|
25
|
+
useEffect(() => { if (!initial)
|
|
26
|
+
void load(); }, [ops, initial]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
27
|
+
const invite = async (input) => {
|
|
28
|
+
// The admin's own origin and mount: /settings/… stripped, so the invite lands on the shell.
|
|
29
|
+
const redirectTo = window.location.origin + window.location.pathname.replace(/\/settings(\/.*)?$/, "");
|
|
30
|
+
const r = await ops.members.invite({ ...input, redirectTo });
|
|
31
|
+
if (!r.ok)
|
|
32
|
+
return r.message;
|
|
33
|
+
setInviting(false);
|
|
34
|
+
setNotice({
|
|
35
|
+
tone: "ok",
|
|
36
|
+
text: r.data.invited ? `Invite sent to ${input.email}.` : `${input.email} added — they can sign in with their existing account.`,
|
|
37
|
+
});
|
|
38
|
+
void load();
|
|
39
|
+
return null;
|
|
40
|
+
};
|
|
41
|
+
const setRole = async (row, role) => {
|
|
42
|
+
if (role === row.value)
|
|
18
43
|
return;
|
|
19
|
-
ops.members.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
44
|
+
const r = await ops.members.setRole(row.key, role);
|
|
45
|
+
if (!r.ok) {
|
|
46
|
+
setNotice({ tone: "error", text: r.message });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
setData((d) => d && { ...d, members: d.members.map((m) => (m.userId === row.key ? r.data : m)) });
|
|
50
|
+
};
|
|
51
|
+
const remove = async (row) => {
|
|
52
|
+
if (!window.confirm(`Remove ${row.email} from this site? They keep their account and lose access to the admin.`))
|
|
53
|
+
return;
|
|
54
|
+
const r = await ops.members.remove(row.key);
|
|
55
|
+
if (!r.ok) {
|
|
56
|
+
setNotice({ tone: "error", text: r.message });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
void load();
|
|
60
|
+
};
|
|
23
61
|
if (error)
|
|
24
62
|
return _jsx(ErrorPane, { message: error });
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
63
|
+
const managed = Boolean(data?.consoleUrl);
|
|
64
|
+
const rows = data ? memberRows(data.members, { me: userId }) : [];
|
|
65
|
+
const guardTitle = (r) => (r.own ? GUARD_TITLE.own : r.lastAdmin ? GUARD_TITLE.lastAdmin : undefined);
|
|
66
|
+
return (_jsxs(_Fragment, { children: [_jsx(ListView, { title: "Members", actions: !managed && data && _jsx(Button, { onClick: () => setInviting(true), children: "Invite" }), notice: _jsxs(_Fragment, { children: [data?.consoleUrl && (_jsxs(Banner, { tone: "ok", children: ["Members are managed at smoodly.io.", " ", _jsx("a", { href: data.consoleUrl, target: "_blank", rel: "noreferrer", children: "Open the console" })] })), notice && _jsx(Banner, { tone: notice.tone, onDismiss: () => setNotice(null), children: notice.text })] }), columns: [
|
|
67
|
+
{ label: "Email", width: "minmax(200px,1fr)" },
|
|
68
|
+
{ label: "Role", width: managed ? "120px" : "180px" },
|
|
69
|
+
...(managed ? [] : [{ label: "", width: "48px" }]),
|
|
70
|
+
], rows: rows.map((r) => ({
|
|
71
|
+
key: r.key,
|
|
72
|
+
cells: [
|
|
73
|
+
r.fallback ? _jsx("span", { style: { color: "var(--text-muted)" }, children: r.email }) : r.email,
|
|
74
|
+
managed
|
|
75
|
+
? _jsx(StatusBadge, { status: r.role })
|
|
76
|
+
: _jsx(Segmented, { options: MEMBER_ROLES, value: r.value, onChange: (v) => void setRole(r, v), label: roleLabel, disabled: r.own || r.lastAdmin, option: () => (guardTitle(r) ? { title: guardTitle(r) } : undefined) }),
|
|
77
|
+
...(managed ? [] : [
|
|
78
|
+
// ActionMenu has no title of its own when disabled: the span carries the guard's reason.
|
|
79
|
+
_jsx("span", { title: guardTitle(r), children: _jsx(ActionMenu, { label: "Member actions", small: true, disabled: r.own || r.lastAdmin, items: [{ label: "Remove", danger: true, run: () => void remove(r) }] }) }),
|
|
80
|
+
]),
|
|
81
|
+
],
|
|
82
|
+
})), empty: "No members yet" }), inviting && _jsx(InviteModal, { onClose: () => setInviting(false), onInvite: invite })] }));
|
|
36
83
|
}
|
|
@@ -17,6 +17,7 @@ import { useSortable } from "../ui/useSortable.js";
|
|
|
17
17
|
import { useAdminHref } from "./base.js";
|
|
18
18
|
import { navigate } from "./router.js";
|
|
19
19
|
import { ErrorPane } from "./ErrorPane.js";
|
|
20
|
+
import { confirmLiveUrl } from "./live-url.js";
|
|
20
21
|
const LOCALE_KEY = "smoodly.pages.locale";
|
|
21
22
|
/** The selected locale survives a reload within the session; a stored
|
|
22
23
|
* value that is no longer supported falls back to the default. */
|
|
@@ -74,6 +75,10 @@ export function PagesList({ ops, registry }) {
|
|
|
74
75
|
return;
|
|
75
76
|
if (projection.parentId === row.parentId && projection.index === row.index)
|
|
76
77
|
return;
|
|
78
|
+
// A published page under a new parent is a new live URL (members management §5).
|
|
79
|
+
const current = row.record.locales[locale];
|
|
80
|
+
if (!confirmLiveUrl({ status: current?.status, from: row.parentId ?? "", to: projection.parentId ?? "" }))
|
|
81
|
+
return;
|
|
77
82
|
const ok = await run(() => ops.pages.move(row.id, { parentId: projection.parentId, index: projection.index }));
|
|
78
83
|
if (!ok)
|
|
79
84
|
throw new Error("move refused");
|
|
@@ -153,6 +158,8 @@ export function PagesList({ ops, registry }) {
|
|
|
153
158
|
const slug = window.prompt("Slug (the URL segment):", current.slug);
|
|
154
159
|
if (slug === null)
|
|
155
160
|
return;
|
|
161
|
+
if (!confirmLiveUrl({ status: current.status, from: current.slug, to: slug }))
|
|
162
|
+
return;
|
|
156
163
|
void run(() => ops.pages.rename(row.id, { locale, title, slug }));
|
|
157
164
|
};
|
|
158
165
|
const destroy = (row) => {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
+
import type { Arrival } from "./session.ts";
|
|
3
|
+
export declare function SetPasswordView({ client, mode, onDone }: {
|
|
4
|
+
client: SupabaseClient;
|
|
5
|
+
mode: Arrival;
|
|
6
|
+
onDone(): void;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// packages/smoodly/src/admin/shell/SetPasswordView.tsx
|
|
4
|
+
// The arrival screen (spec 2026-09-25 members management §6): an invite
|
|
5
|
+
// link or a reset link signed the person in; now they choose a password.
|
|
6
|
+
// An invite cannot skip it (the account has none yet); a recovery may.
|
|
7
|
+
import { useState } from "react";
|
|
8
|
+
import { Banner, Button, Field, Logo } from "../ui/primitives.js";
|
|
9
|
+
export function SetPasswordView({ client, mode, onDone }) {
|
|
10
|
+
const [password, setPassword] = useState("");
|
|
11
|
+
const [again, setAgain] = useState("");
|
|
12
|
+
const [error, setError] = useState(null);
|
|
13
|
+
const [busy, setBusy] = useState(false);
|
|
14
|
+
const submit = async (e) => {
|
|
15
|
+
e.preventDefault();
|
|
16
|
+
if (password !== again) {
|
|
17
|
+
setError("The two passwords differ.");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
setBusy(true);
|
|
21
|
+
setError(null);
|
|
22
|
+
try {
|
|
23
|
+
const res = await client.auth.updateUser({ password });
|
|
24
|
+
if (res.error)
|
|
25
|
+
setError(res.error.message);
|
|
26
|
+
else
|
|
27
|
+
onDone();
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
setBusy(false);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
return (_jsx("div", { style: { minHeight: "100vh", display: "grid", placeItems: "center", background: "var(--bg)" }, children: _jsxs("form", { onSubmit: submit, style: {
|
|
37
|
+
width: 320, display: "flex", flexDirection: "column", gap: 14,
|
|
38
|
+
background: "var(--surface)", padding: 28, borderRadius: "var(--radius-card-lg)",
|
|
39
|
+
border: "var(--menu-border)", boxShadow: "var(--shadow-page)",
|
|
40
|
+
}, children: [_jsx(Logo, { height: 20 }), _jsx("span", { className: "sm-title", children: mode === "invite" ? "Set your password" : "Choose a new password" }), _jsx(Field, { label: "Password", children: _jsx("input", { className: "sm-input", type: "password", required: true, minLength: 8, autoComplete: "new-password", value: password, onChange: (e) => setPassword(e.target.value) }) }), _jsx(Field, { label: "Again", children: _jsx("input", { className: "sm-input", type: "password", required: true, autoComplete: "new-password", value: again, onChange: (e) => setAgain(e.target.value) }) }), error && _jsx(Banner, { tone: "error", children: error }), _jsx("button", { type: "submit", disabled: busy, className: "sm-btn sm-btn--primary sm-btn--lg", children: busy ? "Saving…" : "Save" }), mode === "recovery" && _jsx(Button, { variant: "secondary", size: "lg", onClick: onDone, children: "Skip" })] }) }));
|
|
41
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { type MemberRole } from "../../permissions.ts";
|
|
1
2
|
import type { AdminOps } from "../ops.ts";
|
|
2
|
-
export declare function SettingsView({ ops, tail }: {
|
|
3
|
+
export declare function SettingsView({ ops, tail, role, userId }: {
|
|
3
4
|
ops: AdminOps;
|
|
4
5
|
tail: string | undefined;
|
|
6
|
+
/** The caller's role from `members.me`; null while it loads. */
|
|
7
|
+
role: MemberRole | null;
|
|
8
|
+
userId: string;
|
|
5
9
|
}): import("react").JSX.Element;
|
|
@@ -4,8 +4,11 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
4
4
|
// The Settings area (spec 2026-09-25 §5): a 200px secondary nav at the
|
|
5
5
|
// left of the main column, then the page with its own top bar. The head
|
|
6
6
|
// segment alone replaces itself with the default page, so Back skips the
|
|
7
|
-
// empty URL. An unknown page is the shell's Not found pane.
|
|
7
|
+
// empty URL. An unknown page is the shell's Not found pane. Admins only
|
|
8
|
+
// (members management §2): an editor gets the error pane, and until the
|
|
9
|
+
// role is known the area is loading.
|
|
8
10
|
import { useEffect } from "react";
|
|
11
|
+
import { can } from "../../permissions.js";
|
|
9
12
|
import { TopBar } from "../ui/primitives.js";
|
|
10
13
|
import { useAdminHref } from "./base.js";
|
|
11
14
|
import { ErrorPane } from "./ErrorPane.js";
|
|
@@ -13,16 +16,34 @@ import { MembersPage } from "./MembersPage.js";
|
|
|
13
16
|
import { RouteLink } from "./RouteLink.js";
|
|
14
17
|
import { navigate } from "./router.js";
|
|
15
18
|
import { DEFAULT_SETTINGS_PAGE, SETTINGS_PAGES, settingsPage, settingsTitle } from "./settings.js";
|
|
16
|
-
export function SettingsView({ ops, tail }) {
|
|
19
|
+
export function SettingsView({ ops, tail, role, userId }) {
|
|
17
20
|
const adminHref = useAdminHref();
|
|
18
21
|
const page = settingsPage(tail);
|
|
19
22
|
useEffect(() => {
|
|
20
23
|
if (tail === undefined)
|
|
21
24
|
navigate(adminHref(`/settings/${DEFAULT_SETTINGS_PAGE}`), { replace: true });
|
|
22
25
|
}, [tail, adminHref]);
|
|
26
|
+
if (role === null)
|
|
27
|
+
return _jsx("div", { className: "sm-empty", children: "Loading\u2026" });
|
|
28
|
+
if (!can(role, "settings"))
|
|
29
|
+
return _jsx(ErrorPane, { message: "Settings are for admins." });
|
|
23
30
|
if (!page)
|
|
24
31
|
return _jsx(ErrorPane, { message: "Not found." });
|
|
25
32
|
return (_jsxs("div", { className: "sm-settings", children: [_jsx("nav", { className: "sm-settings__nav", "aria-label": "Settings", children: SETTINGS_PAGES.map((p) => (_jsx(RouteLink, { className: "sm-settings__link", href: adminHref(`/settings/${p.id}`), "aria-current": p.id === page ? "page" : undefined, children: p.title }, p.id))) }), _jsx("section", { className: "sm-settings__page", children: page === "members"
|
|
26
|
-
? _jsx(MembersPage, { ops: ops })
|
|
27
|
-
:
|
|
33
|
+
? _jsx(MembersPage, { ops: ops, userId: userId })
|
|
34
|
+
: page === "roles"
|
|
35
|
+
? _jsx(RolesPage, {})
|
|
36
|
+
: _jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx("span", { className: "sm-title", children: settingsTitle(page) }) }), _jsx("div", { className: "sm-listwrap" })] }) })] }));
|
|
37
|
+
}
|
|
38
|
+
// The two roles, read-only (members management §1): what each may do.
|
|
39
|
+
// A row per capability group, not per Capability — the table explains,
|
|
40
|
+
// permissions.ts decides.
|
|
41
|
+
const ROLE_ROWS = [
|
|
42
|
+
["Edit, publish, unpublish and delete content; upload and delete media", true, true],
|
|
43
|
+
["Open Settings and every page under it", true, false],
|
|
44
|
+
["Invite, re-role and remove members", true, false],
|
|
45
|
+
];
|
|
46
|
+
function RolesPage() {
|
|
47
|
+
const grid = { gridTemplateColumns: "minmax(240px,1fr) 100px 100px" };
|
|
48
|
+
return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx("span", { className: "sm-title", children: "Roles" }) }), _jsx("div", { className: "sm-listwrap", children: _jsxs("div", { className: "sm-table", children: [_jsxs("div", { className: "sm-table__head", style: grid, children: [_jsx("span", { children: "What a role may do" }), _jsx("span", { children: "Admin" }), _jsx("span", { children: "Editor" })] }), _jsx("div", { className: "sm-table__body", children: ROLE_ROWS.map(([what, admin, editor]) => (_jsxs("div", { className: "sm-row", style: grid, children: [_jsx("span", { className: "sm-cell", children: what }), _jsx("span", { className: "sm-cell", children: admin ? "yes" : "no" }), _jsx("span", { className: "sm-cell", children: editor ? "yes" : "no" })] }, what))) })] }) })] }));
|
|
28
49
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const LIVE_URL_MESSAGE = "This changes the live URL. Old links stop working until a redirect exists (there are none yet). Continue?";
|
|
2
|
+
/** `from` null = there was no URL part to change (a new row, a collection without a path). */
|
|
3
|
+
export declare function liveUrlChanges(input: {
|
|
4
|
+
status: "draft" | "published" | undefined;
|
|
5
|
+
from: string | null;
|
|
6
|
+
to: string | null;
|
|
7
|
+
}): boolean;
|
|
8
|
+
/** True when the change may go ahead: not a live change, or confirmed. */
|
|
9
|
+
export declare const confirmLiveUrl: (input: Parameters<typeof liveUrlChanges>[0]) => boolean;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The one confirm on anything that changes a live URL (spec 2026-09-25
|
|
2
|
+
// members management §5): a published page's or entry's slug, or a
|
|
3
|
+
// published page's parent. Whoever the role — the mistake is the same.
|
|
4
|
+
export const LIVE_URL_MESSAGE = "This changes the live URL. Old links stop working until a redirect exists (there are none yet). Continue?";
|
|
5
|
+
/** `from` null = there was no URL part to change (a new row, a collection without a path). */
|
|
6
|
+
export function liveUrlChanges(input) {
|
|
7
|
+
return input.status === "published" && input.from !== null && input.to !== input.from;
|
|
8
|
+
}
|
|
9
|
+
/** True when the change may go ahead: not a live change, or confirmed. */
|
|
10
|
+
export const confirmLiveUrl = (input) => !liveUrlChanges(input) || window.confirm(LIVE_URL_MESSAGE);
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import type { MemberRecord } from "../../member-store.ts";
|
|
2
|
+
import { type MemberRole } from "../../permissions.ts";
|
|
2
3
|
export type MemberRow = {
|
|
3
4
|
key: string;
|
|
4
5
|
email: string;
|
|
5
6
|
fallback: boolean;
|
|
7
|
+
/** Display label. */
|
|
6
8
|
role: string;
|
|
9
|
+
/** The normalized role, what the control holds. */
|
|
10
|
+
value: MemberRole;
|
|
11
|
+
own: boolean;
|
|
12
|
+
lastAdmin: boolean;
|
|
7
13
|
};
|
|
8
14
|
export declare const roleLabel: (role: string) => string;
|
|
9
|
-
export declare function memberRows(members: MemberRecord[]
|
|
15
|
+
export declare function memberRows(members: MemberRecord[], opts: {
|
|
16
|
+
me: string;
|
|
17
|
+
}): MemberRow[];
|
|
18
|
+
export declare const GUARD_TITLE: {
|
|
19
|
+
readonly own: "You cannot change your own membership.";
|
|
20
|
+
readonly lastAdmin: "This site needs at least one admin.";
|
|
21
|
+
};
|
|
@@ -1,9 +1,21 @@
|
|
|
1
|
+
import { normalizeRole } from "../../permissions.js";
|
|
1
2
|
export const roleLabel = (role) => (role ? role[0].toUpperCase() + role.slice(1) : "");
|
|
2
|
-
export function memberRows(members) {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
export function memberRows(members, opts) {
|
|
4
|
+
const admins = members.filter((m) => normalizeRole(m.role) === "admin").length;
|
|
5
|
+
return members.map((m) => {
|
|
6
|
+
const value = normalizeRole(m.role);
|
|
7
|
+
return {
|
|
8
|
+
key: m.userId,
|
|
9
|
+
email: m.email ?? m.userId,
|
|
10
|
+
fallback: m.email === null,
|
|
11
|
+
role: roleLabel(value),
|
|
12
|
+
value,
|
|
13
|
+
own: m.userId === opts.me,
|
|
14
|
+
lastAdmin: value === "admin" && admins <= 1,
|
|
15
|
+
};
|
|
16
|
+
});
|
|
9
17
|
}
|
|
18
|
+
export const GUARD_TITLE = {
|
|
19
|
+
own: "You cannot change your own membership.",
|
|
20
|
+
lastAdmin: "This site needs at least one admin.",
|
|
21
|
+
};
|
|
@@ -4,6 +4,9 @@ export type AuthConfig = {
|
|
|
4
4
|
key: string;
|
|
5
5
|
providers?: ("google" | "github")[];
|
|
6
6
|
};
|
|
7
|
+
export type Arrival = "invite" | "recovery";
|
|
8
|
+
/** What an auth email link says it is: GoTrue's implicit flow puts `type=` in the fragment; a query form is accepted too. */
|
|
9
|
+
export declare function arrivalOf(hash: string, search: string): Arrival | null;
|
|
7
10
|
export type SessionState = {
|
|
8
11
|
status: "loading";
|
|
9
12
|
} | {
|
|
@@ -16,4 +19,7 @@ export declare function useAdminSession(auth: AuthConfig): {
|
|
|
16
19
|
client: SupabaseClient;
|
|
17
20
|
state: SessionState;
|
|
18
21
|
token: () => string | null;
|
|
22
|
+
/** The email link this visit arrived on, until the Set password screen is done. */
|
|
23
|
+
arrival: Arrival | null;
|
|
24
|
+
clearArrival: () => void;
|
|
19
25
|
};
|
|
@@ -3,10 +3,21 @@
|
|
|
3
3
|
// project (smoodly.io's in the cloud, the customer's own in self-host —
|
|
4
4
|
// the app passes the URL and publishable key). The token getter reads a
|
|
5
5
|
// ref so the ops facade, created once, always sends the current token.
|
|
6
|
+
// An auth email link (invite, reset) says what it is in the URL before
|
|
7
|
+
// the client consumes the fragment; the shell shows Set password for it
|
|
8
|
+
// (spec 2026-09-25 members management §6).
|
|
6
9
|
import { useEffect, useRef, useState } from "react";
|
|
7
10
|
import { createClient } from "@supabase/supabase-js";
|
|
11
|
+
/** What an auth email link says it is: GoTrue's implicit flow puts `type=` in the fragment; a query form is accepted too. */
|
|
12
|
+
export function arrivalOf(hash, search) {
|
|
13
|
+
const fromHash = new URLSearchParams(hash.replace(/^#/, "")).get("type");
|
|
14
|
+
const type = fromHash ?? new URLSearchParams(search).get("type");
|
|
15
|
+
return type === "invite" || type === "recovery" ? type : null;
|
|
16
|
+
}
|
|
8
17
|
export function useAdminSession(auth) {
|
|
9
18
|
const browser = typeof window !== "undefined";
|
|
19
|
+
// Read BEFORE the client exists: it consumes and strips the fragment as soon as it is constructed.
|
|
20
|
+
const [arrival, setArrival] = useState(() => browser ? arrivalOf(window.location.hash, window.location.search) : null);
|
|
10
21
|
const [client] = useState(() => createClient(auth.url, auth.key, {
|
|
11
22
|
// Own storage key: a self-hoster's own supabase-js client in the same
|
|
12
23
|
// browser must never share the admin's session slot (DESIGN.md §1).
|
|
@@ -29,11 +40,15 @@ export function useAdminSession(auth) {
|
|
|
29
40
|
return;
|
|
30
41
|
apply(null);
|
|
31
42
|
});
|
|
32
|
-
const { data } = client.auth.onAuthStateChange((
|
|
43
|
+
const { data } = client.auth.onAuthStateChange((event, session) => {
|
|
44
|
+
if (event === "PASSWORD_RECOVERY")
|
|
45
|
+
setArrival("recovery");
|
|
46
|
+
apply(session);
|
|
47
|
+
});
|
|
33
48
|
return () => {
|
|
34
49
|
active = false;
|
|
35
50
|
data.subscription.unsubscribe();
|
|
36
51
|
};
|
|
37
52
|
}, [client]);
|
|
38
|
-
return { client, state, token: () => current.current?.access_token ?? null };
|
|
53
|
+
return { client, state, token: () => current.current?.access_token ?? null, arrival, clearArrival: () => setArrival(null) };
|
|
39
54
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// The Supabase-backed AdminAuth: the session is verified against the
|
|
2
2
|
// AUTH project (smoodly.io's in the cloud, the customer's own in self-
|
|
3
3
|
// host — same code, different SMOODLY_AUTH_URL), the members row is read
|
|
4
|
-
// from the CONTENT project with the write key
|
|
5
|
-
// policy set scopes the write key, and self-host
|
|
4
|
+
// from the CONTENT project with the write key, its role onto the session.
|
|
5
|
+
// No space filter: the policy set scopes the write key, and self-host
|
|
6
|
+
// has one space.
|
|
6
7
|
import { createClient } from "@supabase/supabase-js";
|
|
7
8
|
export function supabaseAdminAuth(input) {
|
|
8
9
|
const auth = createClient(input.authUrl, input.authKey, {
|
|
@@ -13,11 +14,11 @@ export function supabaseAdminAuth(input) {
|
|
|
13
14
|
const res = await auth.auth.getUser(token);
|
|
14
15
|
return res.data.user ? { userId: res.data.user.id, email: res.data.user.email ?? null } : null;
|
|
15
16
|
},
|
|
16
|
-
async
|
|
17
|
-
const res = await input.db.from("members").select("
|
|
17
|
+
async memberOf(userId) {
|
|
18
|
+
const res = await input.db.from("members").select("role").eq("user_id", userId).limit(1);
|
|
18
19
|
if (res.error)
|
|
19
20
|
throw new Error(`smoodly: ${res.error.message}`);
|
|
20
|
-
return res.data.length > 0;
|
|
21
|
+
return res.data.length > 0 ? String(res.data[0].role) : null;
|
|
21
22
|
},
|
|
22
23
|
};
|
|
23
24
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -33,9 +33,11 @@ export type { AssetStore, UploadTicket, FinishInput, AssetQuery, AssetPage, Begi
|
|
|
33
33
|
export { imageDimensions } from "./image-dimensions.ts";
|
|
34
34
|
export { SupabasePageStore } from "./supabase-store.ts";
|
|
35
35
|
export { SupabaseAssetStore } from "./supabase-asset-store.ts";
|
|
36
|
-
export { MemoryMemberStore, sortMembers } from "./member-store.ts";
|
|
37
|
-
export type { MemberStore, MemberRecord } from "./member-store.ts";
|
|
36
|
+
export { MemoryMemberStore, sortMembers, memberErrors } from "./member-store.ts";
|
|
37
|
+
export type { MemberStore, MemberRecord, InviteInput, InviteResult } from "./member-store.ts";
|
|
38
38
|
export { SupabaseMemberStore } from "./supabase-member-store.ts";
|
|
39
|
+
export { MEMBER_ROLES, can, normalizeRole, isMemberRole } from "./permissions.ts";
|
|
40
|
+
export type { MemberRole, Capability } from "./permissions.ts";
|
|
39
41
|
export { collectEntryRefs, collectTreeRefs } from "./refs.ts";
|
|
40
42
|
export type { RefEdge, SectionLike } from "./refs.ts";
|
|
41
43
|
export { richtextBlocks, elementSchemas, IMAGE_NODE, ELEMENT_NODE } from "./richtext-walk.ts";
|
package/dist/index.js
CHANGED
|
@@ -26,8 +26,9 @@ export { MemoryAssetStore, assetErrors, kindOf, extensionFor, objectPath, withDo
|
|
|
26
26
|
export { imageDimensions } from "./image-dimensions.js";
|
|
27
27
|
export { SupabasePageStore } from "./supabase-store.js";
|
|
28
28
|
export { SupabaseAssetStore } from "./supabase-asset-store.js";
|
|
29
|
-
export { MemoryMemberStore, sortMembers } from "./member-store.js";
|
|
29
|
+
export { MemoryMemberStore, sortMembers, memberErrors } from "./member-store.js";
|
|
30
30
|
export { SupabaseMemberStore } from "./supabase-member-store.js";
|
|
31
|
+
export { MEMBER_ROLES, can, normalizeRole, isMemberRole } from "./permissions.js";
|
|
31
32
|
export { collectEntryRefs, collectTreeRefs } from "./refs.js";
|
|
32
33
|
export { richtextBlocks, elementSchemas, IMAGE_NODE, ELEMENT_NODE } from "./richtext-walk.js";
|
|
33
34
|
export { MemoryRefIndex } from "./ref-index.js";
|
package/dist/member-store.d.ts
CHANGED
|
@@ -1,20 +1,49 @@
|
|
|
1
|
+
import type { MemberRole } from "./permissions.ts";
|
|
1
2
|
export type MemberRecord = {
|
|
2
3
|
userId: string;
|
|
3
4
|
email: string | null;
|
|
4
5
|
role: string;
|
|
5
6
|
};
|
|
7
|
+
export type InviteInput = {
|
|
8
|
+
email: string;
|
|
9
|
+
role: MemberRole;
|
|
10
|
+
redirectTo: string;
|
|
11
|
+
};
|
|
12
|
+
/** `invited` false = the email already had a confirmed account, so no mail was sent; the row was added. (A pending invitee is re-invited: same account, mail again, `invited` true.) */
|
|
13
|
+
export type InviteResult = {
|
|
14
|
+
member: MemberRecord;
|
|
15
|
+
invited: boolean;
|
|
16
|
+
};
|
|
6
17
|
export interface MemberStore {
|
|
7
18
|
/** Every member of the current space, by email then user id. */
|
|
8
19
|
list(): Promise<MemberRecord[]>;
|
|
9
20
|
/** The space's console URL when the control plane manages its members; null in self-host. */
|
|
10
21
|
consoleUrl(): Promise<string | null>;
|
|
22
|
+
/** Creates or finds the Auth user for the email, mails the invite when it created one, upserts the row (spec 2026-09-25 members management §3). */
|
|
23
|
+
invite(input: InviteInput): Promise<InviteResult>;
|
|
24
|
+
setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
|
|
25
|
+
/** Deletes the member row only; the Auth account stays. */
|
|
26
|
+
remove(userId: string): Promise<void>;
|
|
11
27
|
}
|
|
28
|
+
export declare const memberErrors: {
|
|
29
|
+
notFound: (userId: string) => Error;
|
|
30
|
+
noAccount: (email: string) => Error;
|
|
31
|
+
};
|
|
12
32
|
/** Email first (case-insensitive), rows without one last, ties by user id. */
|
|
13
33
|
export declare function sortMembers(rows: MemberRecord[]): MemberRecord[];
|
|
14
34
|
export declare class MemoryMemberStore implements MemberStore {
|
|
15
35
|
private rows;
|
|
16
36
|
private url;
|
|
17
|
-
|
|
37
|
+
/** Auth accounts that exist without a member row: email → user id. */
|
|
38
|
+
private accounts;
|
|
39
|
+
/** Emails an invite was "sent" to, in order — what a test asserts. */
|
|
40
|
+
readonly sent: string[];
|
|
41
|
+
constructor(rows?: MemberRecord[], url?: string | null,
|
|
42
|
+
/** Auth accounts that exist without a member row: email → user id. */
|
|
43
|
+
accounts?: Record<string, string>);
|
|
18
44
|
list(): Promise<MemberRecord[]>;
|
|
19
45
|
consoleUrl(): Promise<string | null>;
|
|
46
|
+
invite(input: InviteInput): Promise<InviteResult>;
|
|
47
|
+
setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
|
|
48
|
+
remove(userId: string): Promise<void>;
|
|
20
49
|
}
|
package/dist/member-store.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
// the rows (a cloud space) — no row, no URL, self-host.
|
|
1
|
+
export const memberErrors = {
|
|
2
|
+
notFound: (userId) => new Error(`smoodly: no member "${userId}".`),
|
|
3
|
+
noAccount: (email) => new Error(`smoodly: no Auth account for "${email}" although the invite said it exists.`),
|
|
4
|
+
};
|
|
6
5
|
/** Email first (case-insensitive), rows without one last, ties by user id. */
|
|
7
6
|
export function sortMembers(rows) {
|
|
8
7
|
return [...rows].sort((a, b) => {
|
|
@@ -16,9 +15,14 @@ export function sortMembers(rows) {
|
|
|
16
15
|
});
|
|
17
16
|
}
|
|
18
17
|
export class MemoryMemberStore {
|
|
19
|
-
constructor(rows = [], url = null
|
|
18
|
+
constructor(rows = [], url = null,
|
|
19
|
+
/** Auth accounts that exist without a member row: email → user id. */
|
|
20
|
+
accounts = {}) {
|
|
20
21
|
this.rows = rows;
|
|
21
22
|
this.url = url;
|
|
23
|
+
this.accounts = accounts;
|
|
24
|
+
/** Emails an invite was "sent" to, in order — what a test asserts. */
|
|
25
|
+
this.sent = [];
|
|
22
26
|
}
|
|
23
27
|
async list() {
|
|
24
28
|
return sortMembers(this.rows);
|
|
@@ -26,4 +30,31 @@ export class MemoryMemberStore {
|
|
|
26
30
|
async consoleUrl() {
|
|
27
31
|
return this.url;
|
|
28
32
|
}
|
|
33
|
+
async invite(input) {
|
|
34
|
+
const key = input.email.toLowerCase();
|
|
35
|
+
let userId = this.accounts[key] ?? this.rows.find((r) => r.email?.toLowerCase() === key)?.userId;
|
|
36
|
+
let invited = false;
|
|
37
|
+
if (!userId) {
|
|
38
|
+
userId = crypto.randomUUID();
|
|
39
|
+
this.accounts[key] = userId;
|
|
40
|
+
this.sent.push(input.email);
|
|
41
|
+
invited = true;
|
|
42
|
+
}
|
|
43
|
+
const member = { userId, email: input.email, role: input.role };
|
|
44
|
+
this.rows = [...this.rows.filter((r) => r.userId !== userId), member];
|
|
45
|
+
return { member, invited };
|
|
46
|
+
}
|
|
47
|
+
async setRole(userId, role) {
|
|
48
|
+
const row = this.rows.find((r) => r.userId === userId);
|
|
49
|
+
if (!row)
|
|
50
|
+
throw memberErrors.notFound(userId);
|
|
51
|
+
const next = { ...row, role };
|
|
52
|
+
this.rows = this.rows.map((r) => (r.userId === userId ? next : r));
|
|
53
|
+
return next;
|
|
54
|
+
}
|
|
55
|
+
async remove(userId) {
|
|
56
|
+
if (!this.rows.some((r) => r.userId === userId))
|
|
57
|
+
throw memberErrors.notFound(userId);
|
|
58
|
+
this.rows = this.rows.filter((r) => r.userId !== userId);
|
|
59
|
+
}
|
|
29
60
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const MEMBER_ROLES: readonly ["admin", "editor"];
|
|
2
|
+
export type MemberRole = (typeof MEMBER_ROLES)[number];
|
|
3
|
+
export type Capability = "content.write" | "settings" | "members.manage";
|
|
4
|
+
export declare function can(role: MemberRole, capability: Capability): boolean;
|
|
5
|
+
export declare const isMemberRole: (v: unknown) => v is MemberRole;
|
|
6
|
+
/** What the table holds → a role: `owner` (the pre-2026-09-25 word) is admin; anything unknown reads as editor, the safer side. */
|
|
7
|
+
export declare function normalizeRole(stored: string): MemberRole;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Roles and what each may do (spec 2026-09-25 members management §1–§2).
|
|
2
|
+
// The ONE place a role string is read: the ops and the shell ask can(),
|
|
3
|
+
// the gate normalizes what the table holds. A third role is one row in
|
|
4
|
+
// GRANTS; a stored matrix, if ever wanted, replaces GRANTS behind can().
|
|
5
|
+
export const MEMBER_ROLES = ["admin", "editor"];
|
|
6
|
+
const GRANTS = {
|
|
7
|
+
admin: ["content.write", "settings", "members.manage"],
|
|
8
|
+
editor: ["content.write"],
|
|
9
|
+
};
|
|
10
|
+
export function can(role, capability) {
|
|
11
|
+
return GRANTS[role].includes(capability);
|
|
12
|
+
}
|
|
13
|
+
export const isMemberRole = (v) => typeof v === "string" && MEMBER_ROLES.includes(v);
|
|
14
|
+
/** What the table holds → a role: `owner` (the pre-2026-09-25 word) is admin; anything unknown reads as editor, the safer side. */
|
|
15
|
+
export function normalizeRole(stored) {
|
|
16
|
+
if (stored === "owner")
|
|
17
|
+
return "admin";
|
|
18
|
+
return isMemberRole(stored) ? stored : "editor";
|
|
19
|
+
}
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
-
import { type MemberRecord, type MemberStore } from "./member-store.ts";
|
|
2
|
+
import { type InviteInput, type InviteResult, type MemberRecord, type MemberStore } from "./member-store.ts";
|
|
3
|
+
import type { MemberRole } from "./permissions.ts";
|
|
3
4
|
export declare class SupabaseMemberStore implements MemberStore {
|
|
4
5
|
private db;
|
|
5
6
|
constructor(db: SupabaseClient);
|
|
6
7
|
list(): Promise<MemberRecord[]>;
|
|
7
8
|
consoleUrl(): Promise<string | null>;
|
|
9
|
+
private toRecord;
|
|
10
|
+
/** The admin API has no lookup by email: page through the users (tens, not thousands). */
|
|
11
|
+
private userIdByEmail;
|
|
12
|
+
invite(input: InviteInput): Promise<InviteResult>;
|
|
13
|
+
setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
|
|
14
|
+
remove(userId: string): Promise<void>;
|
|
8
15
|
}
|