smoodly 0.0.22 → 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.
Files changed (48) hide show
  1. package/README.md +38 -3
  2. package/dist/admin/auth.d.ts +10 -5
  3. package/dist/admin/auth.js +14 -13
  4. package/dist/admin/client-ops.js +1 -0
  5. package/dist/admin/editor/PageSettings.js +6 -0
  6. package/dist/admin/index.d.ts +3 -1
  7. package/dist/admin/next/index.d.ts +3 -1
  8. package/dist/admin/next/op-handler.d.ts +3 -0
  9. package/dist/admin/next/op-handler.js +1 -0
  10. package/dist/admin/ops-impl.d.ts +3 -0
  11. package/dist/admin/ops-impl.js +78 -1
  12. package/dist/admin/ops.d.ts +21 -0
  13. package/dist/admin/shell/AdminApp.js +28 -2
  14. package/dist/admin/shell/EntryForm.js +17 -0
  15. package/dist/admin/shell/IconRail.d.ts +3 -1
  16. package/dist/admin/shell/IconRail.js +6 -5
  17. package/dist/admin/shell/InviteModal.d.ts +6 -0
  18. package/dist/admin/shell/InviteModal.js +24 -0
  19. package/dist/admin/shell/ListView.d.ts +3 -1
  20. package/dist/admin/shell/ListView.js +2 -2
  21. package/dist/admin/shell/LoginView.js +34 -5
  22. package/dist/admin/shell/MembersPage.d.ts +6 -0
  23. package/dist/admin/shell/MembersPage.js +83 -0
  24. package/dist/admin/shell/PagesList.js +7 -0
  25. package/dist/admin/shell/SetPasswordView.d.ts +7 -0
  26. package/dist/admin/shell/SetPasswordView.js +41 -0
  27. package/dist/admin/shell/SettingsView.d.ts +9 -0
  28. package/dist/admin/shell/SettingsView.js +49 -0
  29. package/dist/admin/shell/live-url.d.ts +9 -0
  30. package/dist/admin/shell/live-url.js +10 -0
  31. package/dist/admin/shell/members-list.d.ts +21 -0
  32. package/dist/admin/shell/members-list.js +21 -0
  33. package/dist/admin/shell/session.d.ts +6 -0
  34. package/dist/admin/shell/session.js +17 -2
  35. package/dist/admin/shell/settings.d.ts +15 -0
  36. package/dist/admin/shell/settings.js +18 -0
  37. package/dist/admin/supabase-auth.js +7 -6
  38. package/dist/admin/ui/theme.js +13 -0
  39. package/dist/index.d.ts +5 -0
  40. package/dist/index.js +3 -0
  41. package/dist/member-store.d.ts +49 -0
  42. package/dist/member-store.js +60 -0
  43. package/dist/permissions.d.ts +7 -0
  44. package/dist/permissions.js +19 -0
  45. package/dist/sql-space.js +29 -15
  46. package/dist/supabase-member-store.d.ts +15 -0
  47. package/dist/supabase-member-store.js +78 -0
  48. package/package.json +1 -1
@@ -4,10 +4,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // Google/GitHub when the auth project has them. Errors come from
5
5
  // Supabase Auth verbatim. Being an editor is checked by the server on
6
6
  // the first op — a signed-in non-editor sees that message in the shell.
7
+ // Forgot password (members management §6) mails a reset link; the reply
8
+ // is the same whatever the email, so the form reveals no membership.
7
9
  import { useState } from "react";
8
10
  import { Banner, Button, Field, Logo } from "../ui/primitives.js";
9
11
  const LABELS = { google: "Continue with Google", github: "Continue with GitHub" };
12
+ const CARD = {
13
+ width: 320, display: "flex", flexDirection: "column", gap: 14,
14
+ background: "var(--surface)", padding: 28, borderRadius: "var(--radius-card-lg)",
15
+ border: "var(--menu-border)", boxShadow: "var(--shadow-page)",
16
+ };
10
17
  export function LoginView({ client, providers = [] }) {
18
+ const [mode, setMode] = useState("signin");
11
19
  const [email, setEmail] = useState("");
12
20
  const [password, setPassword] = useState("");
13
21
  const [error, setError] = useState(null);
@@ -28,6 +36,20 @@ export function LoginView({ client, providers = [] }) {
28
36
  setBusy(false);
29
37
  }
30
38
  };
39
+ const forgot = async (e) => {
40
+ e.preventDefault();
41
+ setBusy(true);
42
+ setError(null);
43
+ try {
44
+ // The answer is not shown: the form must not reveal whether an email is registered.
45
+ await client.auth.resetPasswordForEmail(email, { redirectTo: window.location.origin + window.location.pathname });
46
+ }
47
+ catch { /* same */ }
48
+ finally {
49
+ setBusy(false);
50
+ setMode("sent");
51
+ }
52
+ };
31
53
  const oauth = async (provider) => {
32
54
  setBusy(true);
33
55
  setError(null);
@@ -43,9 +65,16 @@ export function LoginView({ client, providers = [] }) {
43
65
  setBusy(false);
44
66
  }
45
67
  };
46
- return (_jsx("div", { style: { minHeight: "100vh", display: "grid", placeItems: "center", background: "var(--bg)" }, children: _jsxs("form", { onSubmit: submit, style: {
47
- width: 320, display: "flex", flexDirection: "column", gap: 14,
48
- background: "var(--surface)", padding: 28, borderRadius: "var(--radius-card-lg)",
49
- border: "var(--menu-border)", boxShadow: "var(--shadow-page)",
50
- }, children: [_jsx(Logo, { height: 20 }), _jsx(Field, { label: "Email", children: _jsx("input", { className: "sm-input", type: "email", value: email, required: true, autoComplete: "email", onChange: (e) => setEmail(e.target.value) }) }), _jsx(Field, { label: "Password", children: _jsx("input", { className: "sm-input", type: "password", value: password, required: true, autoComplete: "current-password", onChange: (e) => setPassword(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 ? "Signing in…" : "Sign in" }), providers.map((p) => (_jsx(Button, { variant: "secondary", size: "lg", disabled: busy, onClick: () => oauth(p), children: LABELS[p] }, p)))] }) }));
68
+ const back = (_jsx(Button, { variant: "secondary", size: "lg", disabled: busy, onClick: () => { setError(null); setMode("signin"); }, children: "Back to sign in" }));
69
+ let card;
70
+ if (mode === "sent") {
71
+ card = (_jsxs("div", { style: CARD, children: [_jsx(Logo, { height: 20 }), _jsx("span", { children: "Check your email for a link to choose a new password." }), back] }));
72
+ }
73
+ else if (mode === "forgot") {
74
+ card = (_jsxs("form", { onSubmit: forgot, style: CARD, children: [_jsx(Logo, { height: 20 }), _jsx(Field, { label: "Email", children: _jsx("input", { className: "sm-input", type: "email", value: email, required: true, autoComplete: "email", autoFocus: true, onChange: (e) => setEmail(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 ? "Sending…" : "Send reset link" }), back] }));
75
+ }
76
+ else {
77
+ card = (_jsxs("form", { onSubmit: submit, style: CARD, children: [_jsx(Logo, { height: 20 }), _jsx(Field, { label: "Email", children: _jsx("input", { className: "sm-input", type: "email", value: email, required: true, autoComplete: "email", onChange: (e) => setEmail(e.target.value) }) }), _jsx(Field, { label: "Password", children: _jsx("input", { className: "sm-input", type: "password", value: password, required: true, autoComplete: "current-password", onChange: (e) => setPassword(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 ? "Signing in…" : "Sign in" }), providers.map((p) => (_jsx(Button, { variant: "secondary", size: "lg", disabled: busy, onClick: () => oauth(p), children: LABELS[p] }, p))), _jsx("button", { type: "button", className: "sm-link", disabled: busy, onClick: () => { setError(null); setMode("forgot"); }, style: { background: "transparent", border: 0, color: "var(--text-muted)", cursor: "pointer", font: "inherit", padding: 0 }, children: "Forgot password?" })] }));
78
+ }
79
+ return (_jsx("div", { style: { minHeight: "100vh", display: "grid", placeItems: "center", background: "var(--bg)" }, children: card }));
51
80
  }
@@ -0,0 +1,6 @@
1
+ import type { AdminOps, MembersListResult } from "../ops.ts";
2
+ export declare function MembersPage({ ops, userId, initial }: {
3
+ ops: AdminOps;
4
+ userId: string;
5
+ initial?: MembersListResult | null;
6
+ }): import("react").JSX.Element;
@@ -0,0 +1,83 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // packages/smoodly/src/admin/shell/MembersPage.tsx
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.
9
+ import { useEffect, useState } from "react";
10
+ import { MEMBER_ROLES } from "../../permissions.js";
11
+ import { ActionMenu } from "../ui/ActionMenu.js";
12
+ import { Banner, Button, Segmented, StatusBadge } from "../ui/primitives.js";
13
+ import { ErrorPane } from "./ErrorPane.js";
14
+ import { InviteModal } from "./InviteModal.js";
15
+ import { ListView } from "./ListView.js";
16
+ import { GUARD_TITLE, memberRows, roleLabel } from "./members-list.js";
17
+ export function MembersPage({ ops, userId, initial = null }) {
18
+ const [data, setData] = useState(initial);
19
+ const [error, setError] = useState(null);
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)
43
+ return;
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
+ };
61
+ if (error)
62
+ return _jsx(ErrorPane, { message: error });
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 })] }));
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
+ }
@@ -0,0 +1,9 @@
1
+ import { type MemberRole } from "../../permissions.ts";
2
+ import type { AdminOps } from "../ops.ts";
3
+ export declare function SettingsView({ ops, tail, role, userId }: {
4
+ ops: AdminOps;
5
+ tail: string | undefined;
6
+ /** The caller's role from `members.me`; null while it loads. */
7
+ role: MemberRole | null;
8
+ userId: string;
9
+ }): import("react").JSX.Element;
@@ -0,0 +1,49 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // packages/smoodly/src/admin/shell/SettingsView.tsx
4
+ // The Settings area (spec 2026-09-25 §5): a 200px secondary nav at the
5
+ // left of the main column, then the page with its own top bar. The head
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. Admins only
8
+ // (members management §2): an editor gets the error pane, and until the
9
+ // role is known the area is loading.
10
+ import { useEffect } from "react";
11
+ import { can } from "../../permissions.js";
12
+ import { TopBar } from "../ui/primitives.js";
13
+ import { useAdminHref } from "./base.js";
14
+ import { ErrorPane } from "./ErrorPane.js";
15
+ import { MembersPage } from "./MembersPage.js";
16
+ import { RouteLink } from "./RouteLink.js";
17
+ import { navigate } from "./router.js";
18
+ import { DEFAULT_SETTINGS_PAGE, SETTINGS_PAGES, settingsPage, settingsTitle } from "./settings.js";
19
+ export function SettingsView({ ops, tail, role, userId }) {
20
+ const adminHref = useAdminHref();
21
+ const page = settingsPage(tail);
22
+ useEffect(() => {
23
+ if (tail === undefined)
24
+ navigate(adminHref(`/settings/${DEFAULT_SETTINGS_PAGE}`), { replace: true });
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." });
30
+ if (!page)
31
+ return _jsx(ErrorPane, { message: "Not found." });
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"
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))) })] }) })] }));
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);
@@ -0,0 +1,21 @@
1
+ import type { MemberRecord } from "../../member-store.ts";
2
+ import { type MemberRole } from "../../permissions.ts";
3
+ export type MemberRow = {
4
+ key: string;
5
+ email: string;
6
+ fallback: boolean;
7
+ /** Display label. */
8
+ role: string;
9
+ /** The normalized role, what the control holds. */
10
+ value: MemberRole;
11
+ own: boolean;
12
+ lastAdmin: boolean;
13
+ };
14
+ export declare const roleLabel: (role: string) => string;
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
+ };
@@ -0,0 +1,21 @@
1
+ import { normalizeRole } from "../../permissions.js";
2
+ export const roleLabel = (role) => (role ? role[0].toUpperCase() + role.slice(1) : "");
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
+ });
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((_event, session) => apply(session));
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
  }
@@ -0,0 +1,15 @@
1
+ export declare const SETTINGS_PAGES: readonly [{
2
+ readonly id: "members";
3
+ readonly title: "Members";
4
+ }, {
5
+ readonly id: "roles";
6
+ readonly title: "Roles";
7
+ }, {
8
+ readonly id: "api-keys";
9
+ readonly title: "API Keys";
10
+ }];
11
+ export type SettingsPageId = (typeof SETTINGS_PAGES)[number]["id"];
12
+ export declare const DEFAULT_SETTINGS_PAGE: SettingsPageId;
13
+ /** The page for the segment after /settings: the default for none, null for an unknown one. */
14
+ export declare function settingsPage(tail: string | undefined): SettingsPageId | null;
15
+ export declare function settingsTitle(page: SettingsPageId): string;
@@ -0,0 +1,18 @@
1
+ // The Settings area's pages (spec 2026-09-25 §5): ids are URL segments
2
+ // under /settings, titles are what the secondary nav and each top bar
3
+ // show. Pure, so routing is testable without React.
4
+ export const SETTINGS_PAGES = [
5
+ { id: "members", title: "Members" },
6
+ { id: "roles", title: "Roles" },
7
+ { id: "api-keys", title: "API Keys" },
8
+ ];
9
+ export const DEFAULT_SETTINGS_PAGE = "members";
10
+ /** The page for the segment after /settings: the default for none, null for an unknown one. */
11
+ export function settingsPage(tail) {
12
+ if (tail === undefined)
13
+ return DEFAULT_SETTINGS_PAGE;
14
+ return SETTINGS_PAGES.some((p) => p.id === tail) ? tail : null;
15
+ }
16
+ export function settingsTitle(page) {
17
+ return SETTINGS_PAGES.find((p) => p.id === page).title;
18
+ }
@@ -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
- // host — same code, different SMOODLY_AUTH_URL), the editors row is read
4
- // from the CONTENT project with the write key. No space filter: the
5
- // policy set scopes the write key, and self-host has one space.
3
+ // host — same code, different SMOODLY_AUTH_URL), the members row is read
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 isEditor(userId) {
17
- const res = await input.db.from("editors").select("user_id").eq("user_id", userId).limit(1);
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
  }
@@ -975,6 +975,19 @@ body:has(.sm-admin){margin:0}
975
975
  }
976
976
  .sm-facts{display:grid;grid-template-columns:auto 1fr;gap:4px 10px;font-size:var(--text-sm);color:var(--text-muted)}
977
977
  .sm-usage{display:flex;flex-direction:column;gap:4px}
978
+ /* The Settings area (spec 2026-09-25 §5): a 200px nav card beside the
979
+ page, both under the main column; the page keeps its own top bar. */
980
+ .sm-settings{display:grid;grid-template-columns:200px 1fr;column-gap:var(--gutter);flex:1;min-height:0}
981
+ .sm-settings__nav{
982
+ display:flex;flex-direction:column;gap:2px;padding:var(--table-pad);overflow:auto;
983
+ background:var(--surface-form);border-radius:var(--radius-card-lg);
984
+ }
985
+ .sm-settings__link{
986
+ display:block;padding:8px 10px;border-radius:var(--radius-md);color:var(--text);text-decoration:none;font-size:var(--text-md);
987
+ }
988
+ .sm-settings__link:hover{background:var(--surface-inset)}
989
+ .sm-settings__link[aria-current="page"],.sm-settings__link[aria-current="page"]:hover{background:var(--nav-active-bg);color:var(--nav-active-fg);font-weight:600}
990
+ .sm-settings__page{display:flex;flex-direction:column;min-width:0;min-height:0}
978
991
  `;
979
992
  /** Where the theme toggle's choice is remembered, browser-side only. */
980
993
  export const THEME_KEY = "smoodly-theme";
package/dist/index.d.ts CHANGED
@@ -33,6 +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, memberErrors } from "./member-store.ts";
37
+ export type { MemberStore, MemberRecord, InviteInput, InviteResult } from "./member-store.ts";
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";
36
41
  export { collectEntryRefs, collectTreeRefs } from "./refs.ts";
37
42
  export type { RefEdge, SectionLike } from "./refs.ts";
38
43
  export { richtextBlocks, elementSchemas, IMAGE_NODE, ELEMENT_NODE } from "./richtext-walk.ts";
package/dist/index.js CHANGED
@@ -26,6 +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, memberErrors } from "./member-store.js";
30
+ export { SupabaseMemberStore } from "./supabase-member-store.js";
31
+ export { MEMBER_ROLES, can, normalizeRole, isMemberRole } from "./permissions.js";
29
32
  export { collectEntryRefs, collectTreeRefs } from "./refs.js";
30
33
  export { richtextBlocks, elementSchemas, IMAGE_NODE, ELEMENT_NODE } from "./richtext-walk.js";
31
34
  export { MemoryRefIndex } from "./ref-index.js";
@@ -0,0 +1,49 @@
1
+ import type { MemberRole } from "./permissions.ts";
2
+ export type MemberRecord = {
3
+ userId: string;
4
+ email: string | null;
5
+ role: string;
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
+ };
17
+ export interface MemberStore {
18
+ /** Every member of the current space, by email then user id. */
19
+ list(): Promise<MemberRecord[]>;
20
+ /** The space's console URL when the control plane manages its members; null in self-host. */
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>;
27
+ }
28
+ export declare const memberErrors: {
29
+ notFound: (userId: string) => Error;
30
+ noAccount: (email: string) => Error;
31
+ };
32
+ /** Email first (case-insensitive), rows without one last, ties by user id. */
33
+ export declare function sortMembers(rows: MemberRecord[]): MemberRecord[];
34
+ export declare class MemoryMemberStore implements MemberStore {
35
+ private rows;
36
+ private url;
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>);
44
+ list(): Promise<MemberRecord[]>;
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>;
49
+ }