smoodly 0.0.21 → 0.0.23

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 CHANGED
@@ -239,7 +239,7 @@ Implemented:
239
239
 
240
240
  - The space layer (spec 2026-09-04 §3): `space_id` on every content
241
241
  table, `smoodly_current_space()`, the insert trigger, the row cap, the
242
- three cloud-state tables (`editors`, `space_keys`, `space_caps`) and
242
+ three cloud-state tables (`members`, `space_keys`, `spaces`) and
243
243
  the policy set. Service role bypasses it; a write key is scoped to its
244
244
  space. Contract suites run in both modes; `test/isolation.supabase.test.ts`
245
245
  proves isolation. Generated per-collection views are `security_invoker`,
@@ -495,6 +495,14 @@ after both are on the registry. From the repo root:
495
495
  `prepublishOnly` rebuilds each package. The Claude Code skill
496
496
  `.claude/skills/releasing-smoodly` carries the same order with its checks.
497
497
 
498
+ ## Follow-ups (logged 2026-09-25, docs session)
499
+
500
+ - `f.slug({ from })` stores `from` on the descriptor, but nothing reads it:
501
+ the admin never derives a slug from the named field. Either wire it (fill
502
+ the slug from `from` while the slug is untouched, in the entry form) or
503
+ drop the option. The Fields guide documents the slug as a plain text box
504
+ with the URL prefix and does not mention `from`.
505
+
498
506
  ## Follow-ups (logged 2026-09-17, admin path spec)
499
507
 
500
508
  - A robots `Disallow` for the admin path from `next/meta`.
@@ -624,6 +632,26 @@ tool — reset and reseed a development database (`supabase db reset`,
624
632
  the control never offers it there, so nothing shows — the store could
625
633
  answer it as a no-op instead.
626
634
 
635
+ ## Follow-ups (logged 2026-09-25, settings + members spec)
636
+
637
+ - Existing databases (every install so far is ours; DESIGN.md OQ #2):
638
+ `alter table "editors" rename to "members";`
639
+ `alter table "members" add column "email" text;`
640
+ `alter table "space_caps" rename to "spaces";`
641
+ `alter table "spaces" alter column "row_cap" drop not null;`
642
+ `alter table "spaces" add column "console_url" text;`
643
+ then re-run the regenerated migration's `smoodly_space_row_cap` function
644
+ and the `smoodly_read` policy on `spaces` (both `create or replace` /
645
+ `drop … if exists`, so the whole migration file can be re-applied).
646
+ Policies and triggers follow a table rename. Rows written before the
647
+ column show their user id on the Members page until re-inserted (the
648
+ demo-editor upsert fills it on the next seed).
649
+ - The Members page is read-only; adding, re-roling and removing members
650
+ in self-host, the role vocabulary and its enforcement at the gate, and
651
+ the API Keys page's contents are DESIGN.md OQ #33.
652
+ - The members list is not paged (tens of rows, not thousands).
653
+ - The `api-keys` page id is a URL segment; the title reads "API Keys".
654
+
627
655
  ## Follow-ups (logged 2026-09-13, history spec)
628
656
 
629
657
  - Existing databases (every install so far is ours; DESIGN.md OQ #2):
@@ -1616,7 +1644,7 @@ the toolbar instead of pushing the content.
1616
1644
  - Light/dark toggle (nav foot): the choice is stored in `localStorage`
1617
1645
  (`smoodly-theme`) and written as `data-theme` on the admin wrapper, never on
1618
1646
  the host `<html>`. It is per browser, not per editor account — a server-side
1619
- preference would need a place to live (`editors`, or a cloud user profile).
1647
+ preference would need a place to live (`members`, or a cloud user profile).
1620
1648
  - `lucide-react` is now a runtime dependency, for the toggle's two icons. The
1621
1649
  rest of the UI still uses the design system's unicode glyphs (↑ ↓ ⧉ × ··· ▾
1622
1650
  ← ⋮⋮); swapping them for Lucide is the next step whenever it is wanted.
@@ -1636,7 +1664,7 @@ the toolbar instead of pushing the content.
1636
1664
  - Add an index on `pages(published_version_id)`: the `page_versions` read policy's `exists` probe runs on every anon/read-key read and has no supporting index.
1637
1665
  - A malformed `space_id`/`key_id` claim raises `22P02` inside the policy: fails closed but surfaces as a 500. Only reachable from a validly-signed key, so it is a cloud key-minting guard, not a request-path one.
1638
1666
  - Authorization trusts the JWT claims alone; `smoodly_space_key_revoked()` checks only `revoked_at`. When cloud key-minting exists, make it a full key check (the claims must match the `space_keys` row).
1639
- - `isEditor` reads `editors` with no space filter: under a cloud write key RLS scopes it, but a service-role client bypasses RLS and would admit an editor of any space. Harmless in self-host (one space); never use a service-role client against the shared content project.
1667
+ - `isEditor` and `SupabaseMemberStore` read `members` (and `spaces`) with no space filter: under a cloud write key RLS scopes it, but a service-role client bypasses RLS and would admit an editor of any space. Harmless in self-host (one space); never use a service-role client against the shared content project.
1640
1668
  - The `sb_secret_` branch of `clientOptions` (secret key as the sole `apikey`, no bearer) was exercised live on 2026-09-05 against a hosted project: PostgREST reads with the service-role template and the GoTrue admin API (`listUsers`, `createUser`) both accept it; a password sign-in with the matching `sb_publishable_` key returns a session. Unit tests still cover the shape only; the local stack stays on legacy JWT keys.
1641
1669
  - The migration is not re-appliable to a pre-rename (`project_id`) database: `create table if not exists` leaves it untouched and the `space_id` policies then fail. No installs predate the rename; fold into DESIGN.md OQ #2 (schema upgrades).
1642
1670
  - `examples/site/smoodly/server.ts` should carry `import "server-only"` now that it also exports `publicAuth` — a client-side import would fail loudly at build instead of at runtime.
@@ -3,6 +3,7 @@ const GROUPS = {
3
3
  entries: ["list", "distinct", "get", "create", "save", "setStatus", "discardDraft", "delete", "move", "addLocale", "removeLocale", "listVersions", "getVersion", "restore"],
4
4
  globals: ["list", "get", "save", "setStatus", "discardDraft", "listVersions", "getVersion", "restore"],
5
5
  assets: ["list", "get", "beginUpload", "finishUpload", "update", "delete", "usage"],
6
+ members: ["list"],
6
7
  preview: ["enable", "disable"],
7
8
  };
8
9
  export function makeClientOps(action, token) {
@@ -1,4 +1,4 @@
1
- export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, GlobalListItem, GlobalGetResult, AssetUsageItem } from "./ops.ts";
1
+ export type { AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, GlobalListItem, GlobalGetResult, AssetUsageItem, MembersListResult } from "./ops.ts";
2
2
  export { createAdminOps, runAdminOp, type AdminEffects } from "./ops-impl.ts";
3
3
  export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes, type FixedNode } from "./fixed-nodes.ts";
4
4
  export { ensureGlobalItems, initialGlobalFields } from "./global-items.ts";
@@ -1,6 +1,6 @@
1
1
  export { createAdminOpHandler } from "./op-handler.ts";
2
2
  export { createSmoodlyAdmin } from "./create-admin.tsx";
3
- export type { AdminOpCall, AdminOpAction, AdminOps, OpResult } from "../ops.ts";
3
+ export type { AdminOpCall, AdminOpAction, AdminOps, OpResult, MembersListResult } from "../ops.ts";
4
4
  export type { AuthConfig } from "../shell/session.ts";
5
5
  export type { AdminAuth } from "../auth.ts";
6
6
  export { supabaseAdminAuth } from "../supabase-auth.ts";
@@ -2,6 +2,7 @@ import type { ResolvedConfig } from "../../config.ts";
2
2
  import type { EntryStore } from "../../entry-store.ts";
3
3
  import type { PageStore } from "../../store.ts";
4
4
  import type { AssetStore } from "../../asset-store.ts";
5
+ import type { MemberStore } from "../../member-store.ts";
5
6
  import { type AdminAuth } from "../auth.ts";
6
7
  import type { AdminOpAction } from "../ops.ts";
7
8
  export declare function createAdminOpHandler(deps: {
@@ -12,4 +13,6 @@ export declare function createAdminOpHandler(deps: {
12
13
  href?: (path: string, locale: string) => string;
13
14
  /** The media library; without it the assets ops answer "no media store". */
14
15
  assets?: AssetStore;
16
+ /** The admission table; without it members.list answers "no member store". */
17
+ members?: MemberStore;
15
18
  }): AdminOpAction;
@@ -13,6 +13,7 @@ export function createAdminOpHandler(deps) {
13
13
  entries: deps.entries,
14
14
  href: deps.href,
15
15
  assets: deps.assets,
16
+ members: deps.members,
16
17
  effects: {
17
18
  // updateTag, not revalidateTag(tag, profile): the profile form is
18
19
  // stale-while-revalidate, so the first request after publish still
@@ -1,6 +1,7 @@
1
1
  import type { ResolvedConfig } from "../config.ts";
2
2
  import type { EntryStore } from "../entry-store.ts";
3
3
  import type { AssetStore } from "../asset-store.ts";
4
+ import type { MemberStore } from "../member-store.ts";
4
5
  import type { PageStore } from "../store.ts";
5
6
  import type { AdminOpCall, AdminOps, OpResult } from "./ops.ts";
6
7
  import type { AdminSession } from "./auth.ts";
@@ -21,6 +22,8 @@ export declare function createAdminOps(deps: {
21
22
  href?: (path: string, locale: string) => string;
22
23
  /** The media library; absent = every assets op answers "no media store". */
23
24
  assets?: AssetStore;
25
+ /** The admission table (spec 2026-09-25 §4); absent = members.list answers "no member store". */
26
+ members?: MemberStore;
24
27
  /** The verified editor; their email is every write's `by`. Absent for the seed and tests (spec 2026-09-13 history §2). */
25
28
  actor?: AdminSession;
26
29
  }): AdminOps;
@@ -200,6 +200,7 @@ export function createAdminOps(deps) {
200
200
  const nameOf = (r) => r.locales[def]?.slug ?? Object.values(r.locales)[0]?.slug ?? r.id;
201
201
  const assets = deps.assets;
202
202
  const noMedia = () => err("internal", "smoodly: no media store is configured — pass `assets` to createAdminOpHandler.");
203
+ const noMembers = () => err("internal", "smoodly: no member store is configured — pass `members` to createAdminOpHandler.");
203
204
  const mb = (bytes) => `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`;
204
205
  const isDim = (v) => v === undefined || (typeof v === "number" && Number.isFinite(v) && v >= 0);
205
206
  /** What an asset's usage row points at, labelled for the Media screen. */
@@ -849,6 +850,14 @@ export function createAdminOps(deps) {
849
850
  return ok(out);
850
851
  }),
851
852
  },
853
+ members: {
854
+ list: () => guard(async () => {
855
+ if (!deps.members)
856
+ return noMembers();
857
+ const [members, consoleUrl] = await Promise.all([deps.members.list(), deps.members.consoleUrl()]);
858
+ return ok({ members, consoleUrl });
859
+ }),
860
+ },
852
861
  preview: {
853
862
  enable: () => guard(async () => (await effects?.draft?.enable(), ok(null))),
854
863
  disable: () => guard(async () => (await effects?.draft?.disable(), ok(null))),
@@ -3,6 +3,7 @@ import type { PageTree } from "../render.tsx";
3
3
  import type { EntryRecord, EntryUsage } from "../entry-store.ts";
4
4
  import type { AssetRecord, AssetMeta } from "../media.ts";
5
5
  import type { AssetPage, AssetQuery, FinishInput, UploadTicket } from "../asset-store.ts";
6
+ import type { MemberRecord } from "../member-store.ts";
6
7
  import type { FieldError } from "./validate.ts";
7
8
  export type OpError = {
8
9
  ok: false;
@@ -91,6 +92,11 @@ export type AssetUsageItem = {
91
92
  title: string;
92
93
  href: string | null;
93
94
  };
95
+ /** The Members page's read (spec 2026-09-25 §4): the rows and, when the control plane manages them, where. */
96
+ export type MembersListResult = {
97
+ members: MemberRecord[];
98
+ consoleUrl: string | null;
99
+ };
94
100
  export type AdminOps = {
95
101
  pages: {
96
102
  /** Lists every node, materializing fixed pages and collection mounts (in every supported locale) first. */
@@ -189,6 +195,10 @@ export type AdminOps = {
189
195
  delete(id: string): Promise<OpResult<null>>;
190
196
  usage(id: string): Promise<OpResult<AssetUsageItem[]>>;
191
197
  };
198
+ members: {
199
+ /** The space's members, read-only in this cut; `consoleUrl` set means smoodly.io manages them. */
200
+ list(): Promise<OpResult<MembersListResult>>;
201
+ };
192
202
  preview: {
193
203
  enable(): Promise<OpResult<null>>;
194
204
  disable(): Promise<OpResult<null>>;
@@ -28,6 +28,7 @@ import { AdminPathProvider } from "./base.js";
28
28
  import { mountMismatch } from "../../admin-path.js";
29
29
  import { ErrorPane } from "./ErrorPane.js";
30
30
  import { MediaScreen } from "./MediaScreen.js";
31
+ import { SettingsView } from "./SettingsView.js";
31
32
  import { AssetsProvider } from "../forms/assets-context.js";
32
33
  import { ElementsProvider } from "../forms/elements-context.js";
33
34
  export function AdminApp({ registry, op, auth }) {
@@ -59,6 +60,8 @@ export function AdminApp({ registry, op, auth }) {
59
60
  main = _jsx(GlobalList, { ops: ops, registry: registry });
60
61
  else if (head === "globals" && tail)
61
62
  main = _jsx(GlobalForm, { ops: ops, registry: registry, name: decodeURIComponent(tail) });
63
+ else if (head === "settings")
64
+ main = _jsx(SettingsView, { ops: ops, tail: tail });
62
65
  else if (head === "media")
63
66
  main = _jsx(MediaScreen, {});
64
67
  else
@@ -9,8 +9,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  // Items are registry-derived, never separately configured: Pages, then
10
10
  // Collections — one rail item whose flyout lists every collection — then
11
11
  // Media, Globals (the global content items, when the registry has any;
12
- // the route is /globals), and Settings, shown but inert until it
13
- // exists. The user avatar is pinned to the bottom and opens the same
12
+ // the route is /globals), and Settings (the Settings area, spec
13
+ // 2026-09-25; the route is /settings). The user avatar is pinned to the bottom and opens the same
14
14
  // flyout with the theme toggle and Sign out, which the old nav's footer
15
15
  // held.
16
16
  //
@@ -72,11 +72,11 @@ export function IconRail({ registry, active, email, onSignOut }) {
72
72
  items[(i + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length].focus();
73
73
  }
74
74
  };
75
- const link = (key, label, icon, href) => (_jsx("li", { className: "sm-rail__item", children: href ? (_jsxs(RouteLink, { className: "sm-rail__btn", href: href, "aria-current": key === active ? "page" : undefined, onClick: close, children: [_jsx("span", { className: "sm-rail__tile", children: icon }), _jsx("span", { className: "sm-rail__label", children: label })] })) : (_jsxs("a", { className: "sm-rail__btn", "aria-disabled": true, children: [_jsx("span", { className: "sm-rail__tile", children: icon }), _jsx("span", { className: "sm-rail__label", children: label })] })) }));
75
+ const link = (key, label, icon, href) => (_jsx("li", { className: "sm-rail__item", children: _jsxs(RouteLink, { className: "sm-rail__btn", href: href, "aria-current": key === active ? "page" : undefined, onClick: close, children: [_jsx("span", { className: "sm-rail__tile", children: icon }), _jsx("span", { className: "sm-rail__label", children: label })] }) }));
76
76
  const collections = registry.collections;
77
77
  const collectionActive = collections.some((c) => c.name === active);
78
78
  const collectionsOpen = open === "collections" || (hovered && open === null);
79
79
  const name = email?.split("@")[0] ?? "Editor";
80
80
  const initials = name.slice(0, 2).toUpperCase();
81
- return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: adminHref(`/${c.name}`), "aria-current": c.name === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), registry.globals.length > 0 && link("globals", "Globals", _jsx(Globe, { ...ICON }), adminHref("/globals")), link("settings", "Settings", _jsx(Settings, { ...ICON }), null)] }), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
81
+ return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: adminHref(`/${c.name}`), "aria-current": c.name === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), registry.globals.length > 0 && link("globals", "Globals", _jsx(Globe, { ...ICON }), adminHref("/globals")), link("settings", "Settings", _jsx(Settings, { ...ICON }), adminHref("/settings"))] }), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
82
82
  }
@@ -4,11 +4,13 @@ export type ListColumn = {
4
4
  label: string;
5
5
  width: string;
6
6
  };
7
- export declare function ListView({ title, titleTrailing, actions, columns, rows, empty, sortable }: {
7
+ export declare function ListView({ title, titleTrailing, actions, notice, columns, rows, empty, sortable }: {
8
8
  title: string;
9
9
  /** Rides right after the title — the locale picker, in the place every form's switch has. */
10
10
  titleTrailing?: ReactNode;
11
11
  actions?: ReactNode;
12
+ /** Between the top bar and the list card — a Banner, as the Media screen places its error. */
13
+ notice?: ReactNode;
12
14
  columns: ListColumn[];
13
15
  rows: {
14
16
  key: string;
@@ -3,10 +3,10 @@
3
3
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  import { Grip, TopBar, cx } from "../ui/primitives.js";
5
5
  import { navigate } from "./router.js";
6
- export function ListView({ title, titleTrailing, actions, columns, rows, empty, sortable }) {
6
+ export function ListView({ title, titleTrailing, actions, notice, columns, rows, empty, sortable }) {
7
7
  const widths = sortable ? ["24px", ...columns.map((c) => c.width)] : columns.map((c) => c.width);
8
8
  const grid = { gridTemplateColumns: widths.join(" ") };
9
- return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx("span", { className: "sm-title", children: title }), titleTrailing] }), right: actions }), _jsx("div", { className: "sm-listwrap", children: _jsxs("div", { className: "sm-table", children: [_jsxs("div", { className: "sm-table__head", style: grid, children: [sortable && _jsx("span", { "aria-hidden": "true" }), columns.map((c) => _jsx("span", { children: c.label }, c.label))] }), _jsxs("div", { className: "sm-table__body", ref: sortable?.hook.containerRef, children: [rows.map((r, i) => {
9
+ return (_jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsxs(_Fragment, { children: [_jsx("span", { className: "sm-title", children: title }), titleTrailing] }), right: actions }), notice, _jsx("div", { className: "sm-listwrap", children: _jsxs("div", { className: "sm-table", children: [_jsxs("div", { className: "sm-table__head", style: grid, children: [sortable && _jsx("span", { "aria-hidden": "true" }), columns.map((c) => _jsx("span", { children: c.label }, c.label))] }), _jsxs("div", { className: "sm-table__body", ref: sortable?.hook.containerRef, children: [rows.map((r, i) => {
10
10
  const clickable = Boolean(r.href || r.onClick);
11
11
  const href = r.href;
12
12
  const go = r.onClick ?? (href ? () => { navigate(href); } : undefined);
@@ -0,0 +1,5 @@
1
+ import type { AdminOps, MembersListResult } from "../ops.ts";
2
+ export declare function MembersPage({ ops, initial }: {
3
+ ops: AdminOps;
4
+ initial?: MembersListResult | null;
5
+ }): import("react").JSX.Element;
@@ -0,0 +1,36 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // packages/smoodly/src/admin/shell/MembersPage.tsx
4
+ // The Members page (spec 2026-09-25 §7): the admission table, read-only
5
+ // in this cut — Email and Role, no actions, no row click. A console URL
6
+ // from the op means smoodly.io manages the rows, and a banner under the
7
+ // top bar says so.
8
+ import { useEffect, useState } from "react";
9
+ import { Banner, StatusBadge } from "../ui/primitives.js";
10
+ import { ErrorPane } from "./ErrorPane.js";
11
+ import { ListView } from "./ListView.js";
12
+ import { memberRows } from "./members-list.js";
13
+ export function MembersPage({ ops, initial = null }) {
14
+ const [data, setData] = useState(initial);
15
+ const [error, setError] = useState(null);
16
+ useEffect(() => {
17
+ if (initial)
18
+ return;
19
+ ops.members.list()
20
+ .then((r) => (r.ok ? setData(r.data) : setError(r.message)))
21
+ .catch(() => setError("Couldn't reach the server."));
22
+ }, [ops, initial]);
23
+ if (error)
24
+ return _jsx(ErrorPane, { message: error });
25
+ const rows = data ? memberRows(data.members) : [];
26
+ return (_jsx(ListView, { title: "Members", notice: 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" })] })), columns: [
27
+ { label: "Email", width: "minmax(200px,1fr)" },
28
+ { label: "Role", width: "120px" },
29
+ ], rows: rows.map((r) => ({
30
+ key: r.key,
31
+ cells: [
32
+ r.fallback ? _jsx("span", { style: { color: "var(--text-muted)" }, children: r.email }) : r.email,
33
+ _jsx(StatusBadge, { status: r.role }),
34
+ ],
35
+ })), empty: "No members yet" }));
36
+ }
@@ -0,0 +1,5 @@
1
+ import type { AdminOps } from "../ops.ts";
2
+ export declare function SettingsView({ ops, tail }: {
3
+ ops: AdminOps;
4
+ tail: string | undefined;
5
+ }): import("react").JSX.Element;
@@ -0,0 +1,28 @@
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.
8
+ import { useEffect } from "react";
9
+ import { TopBar } from "../ui/primitives.js";
10
+ import { useAdminHref } from "./base.js";
11
+ import { ErrorPane } from "./ErrorPane.js";
12
+ import { MembersPage } from "./MembersPage.js";
13
+ import { RouteLink } from "./RouteLink.js";
14
+ import { navigate } from "./router.js";
15
+ import { DEFAULT_SETTINGS_PAGE, SETTINGS_PAGES, settingsPage, settingsTitle } from "./settings.js";
16
+ export function SettingsView({ ops, tail }) {
17
+ const adminHref = useAdminHref();
18
+ const page = settingsPage(tail);
19
+ useEffect(() => {
20
+ if (tail === undefined)
21
+ navigate(adminHref(`/settings/${DEFAULT_SETTINGS_PAGE}`), { replace: true });
22
+ }, [tail, adminHref]);
23
+ if (!page)
24
+ return _jsx(ErrorPane, { message: "Not found." });
25
+ 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
+ : _jsxs(_Fragment, { children: [_jsx(TopBar, { left: _jsx("span", { className: "sm-title", children: settingsTitle(page) }) }), _jsx("div", { className: "sm-listwrap" })] }) })] }));
28
+ }
@@ -0,0 +1,9 @@
1
+ import type { MemberRecord } from "../../member-store.ts";
2
+ export type MemberRow = {
3
+ key: string;
4
+ email: string;
5
+ fallback: boolean;
6
+ role: string;
7
+ };
8
+ export declare const roleLabel: (role: string) => string;
9
+ export declare function memberRows(members: MemberRecord[]): MemberRow[];
@@ -0,0 +1,9 @@
1
+ export const roleLabel = (role) => (role ? role[0].toUpperCase() + role.slice(1) : "");
2
+ export function memberRows(members) {
3
+ return members.map((m) => ({
4
+ key: m.userId,
5
+ email: m.email ?? m.userId,
6
+ fallback: m.email === null,
7
+ role: roleLabel(m.role),
8
+ }));
9
+ }
@@ -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,6 +1,6 @@
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
3
+ // host — same code, different SMOODLY_AUTH_URL), the members row is read
4
4
  // from the CONTENT project with the write key. No space filter: the
5
5
  // policy set scopes the write key, and self-host has one space.
6
6
  import { createClient } from "@supabase/supabase-js";
@@ -14,7 +14,7 @@ export function supabaseAdminAuth(input) {
14
14
  return res.data.user ? { userId: res.data.user.id, email: res.data.user.email ?? null } : null;
15
15
  },
16
16
  async isEditor(userId) {
17
- const res = await input.db.from("editors").select("user_id").eq("user_id", userId).limit(1);
17
+ const res = await input.db.from("members").select("user_id").eq("user_id", userId).limit(1);
18
18
  if (res.error)
19
19
  throw new Error(`smoodly: ${res.error.message}`);
20
20
  return res.data.length > 0;
@@ -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,9 @@ 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";
38
+ export { SupabaseMemberStore } from "./supabase-member-store.ts";
36
39
  export { collectEntryRefs, collectTreeRefs } from "./refs.ts";
37
40
  export type { RefEdge, SectionLike } from "./refs.ts";
38
41
  export { richtextBlocks, elementSchemas, IMAGE_NODE, ELEMENT_NODE } from "./richtext-walk.ts";
package/dist/index.js CHANGED
@@ -26,6 +26,8 @@ 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";
30
+ export { SupabaseMemberStore } from "./supabase-member-store.js";
29
31
  export { collectEntryRefs, collectTreeRefs } from "./refs.js";
30
32
  export { richtextBlocks, elementSchemas, IMAGE_NODE, ELEMENT_NODE } from "./richtext-walk.js";
31
33
  export { MemoryRefIndex } from "./ref-index.js";
@@ -0,0 +1,20 @@
1
+ export type MemberRecord = {
2
+ userId: string;
3
+ email: string | null;
4
+ role: string;
5
+ };
6
+ export interface MemberStore {
7
+ /** Every member of the current space, by email then user id. */
8
+ list(): Promise<MemberRecord[]>;
9
+ /** The space's console URL when the control plane manages its members; null in self-host. */
10
+ consoleUrl(): Promise<string | null>;
11
+ }
12
+ /** Email first (case-insensitive), rows without one last, ties by user id. */
13
+ export declare function sortMembers(rows: MemberRecord[]): MemberRecord[];
14
+ export declare class MemoryMemberStore implements MemberStore {
15
+ private rows;
16
+ private url;
17
+ constructor(rows?: MemberRecord[], url?: string | null);
18
+ list(): Promise<MemberRecord[]>;
19
+ consoleUrl(): Promise<string | null>;
20
+ }
@@ -0,0 +1,29 @@
1
+ // The members contract (spec 2026-09-25 §4) and its memory adapter. A
2
+ // member is one row of the admission table: the Auth user id, the email
3
+ // stored beside it, and a role from the package's vocabulary. Read-only
4
+ // in this cut; the console URL says whether the control plane manages
5
+ // the rows (a cloud space) — no row, no URL, self-host.
6
+ /** Email first (case-insensitive), rows without one last, ties by user id. */
7
+ export function sortMembers(rows) {
8
+ return [...rows].sort((a, b) => {
9
+ if (a.email === null && b.email === null)
10
+ return a.userId.localeCompare(b.userId);
11
+ if (a.email === null)
12
+ return 1;
13
+ if (b.email === null)
14
+ return -1;
15
+ return a.email.localeCompare(b.email, undefined, { sensitivity: "base" }) || a.userId.localeCompare(b.userId);
16
+ });
17
+ }
18
+ export class MemoryMemberStore {
19
+ constructor(rows = [], url = null) {
20
+ this.rows = rows;
21
+ this.url = url;
22
+ }
23
+ async list() {
24
+ return sortMembers(this.rows);
25
+ }
26
+ async consoleUrl() {
27
+ return this.url;
28
+ }
29
+ }
package/dist/sql-space.js CHANGED
@@ -30,7 +30,7 @@ language sql stable as $$
30
30
  $$;`.trim();
31
31
  }
32
32
  /** Which rows a read key (or the anon key, which has no claim) may see.
33
- * null = write keys only — refs is editor tooling; editors is admission
33
+ * null = write keys only — refs is editor tooling; members is admission
34
34
  * data; nothing a browser needs. Global content (spec 2026-09-07) rides
35
35
  * the three entry predicates: it is stored as entries. */
36
36
  const PUBLISHED_READ = {
@@ -41,7 +41,7 @@ const PUBLISHED_READ = {
41
41
  entry_locales: `"status" = 'published'`,
42
42
  entry_versions: `exists (select 1 from "entry_locales" l where l."published_version_id" = "entry_versions"."id")`,
43
43
  refs: null,
44
- editors: null,
44
+ members: null,
45
45
  assets: `"status" = 'ready'`,
46
46
  paths: `(("kind" = 'page' and exists (select 1 from "page_locales" l where l."page_id" = "paths"."target_id" and l."locale" = "paths"."locale" and l."status" = 'published')) or ("kind" = 'entry' and exists (select 1 from "entry_locales" l where l."entry_id" = "paths"."target_id" and l."locale" = "paths"."locale" and l."status" = 'published')))`,
47
47
  };
@@ -52,13 +52,17 @@ const NEEDS_SPACE_INDEX = [
52
52
  export function spaceLayerSQL() {
53
53
  const statements = [];
54
54
  statements.push(`
55
- -- ── Cloud state (spec 2026-09-04 §1): three ordinary tables, shipped
56
- -- for everyone with ONE definition. Self-host fills them by hand or
57
- -- leaves them empty; smoodly.io writes rows with the service role.
58
- -- There is never cloud-only SQL on a content database.
59
- create table if not exists "editors" (
55
+ -- ── Cloud state (spec 2026-09-04 §1, names 2026-09-25): three ordinary
56
+ -- tables, shipped for everyone with ONE definition. Self-host fills them
57
+ -- by hand or leaves them empty; smoodly.io writes rows with the service
58
+ -- role. There is never cloud-only SQL on a content database.
59
+ -- members: who may open /admin for a space (spec 2026-09-25 §1–§2). The
60
+ -- email is stored so the Members page needs no Auth lookup — the auth
61
+ -- project is not the content project in the cloud.
62
+ create table if not exists "members" (
60
63
  ${SPACE_COL},
61
64
  "user_id" uuid not null,
65
+ "email" text,
62
66
  "role" text not null default 'editor',
63
67
  primary key ("space_id", "user_id")
64
68
  );
@@ -70,14 +74,18 @@ create table if not exists "space_keys" (
70
74
  "revoked_at" timestamptz
71
75
  );
72
76
 
73
- create table if not exists "space_caps" (
77
+ -- spaces: the control plane's row for a space (spec 2026-09-25 §3). No
78
+ -- row = self-host: no cap, members managed in /admin. console_url set =
79
+ -- smoodly.io manages the members and /admin links there.
80
+ create table if not exists "spaces" (
74
81
  "space_id" uuid primary key,
75
- "row_cap" integer not null
82
+ "row_cap" integer,
83
+ "console_url" text
76
84
  );
77
85
 
78
- alter table "editors" enable row level security;
86
+ alter table "members" enable row level security;
79
87
  alter table "space_keys" enable row level security;
80
- alter table "space_caps" enable row level security;
88
+ alter table "spaces" enable row level security;
81
89
 
82
90
  -- ── Claim readers. The two lookups run as definer so they can read the
83
91
  -- service-role-only tables from inside a policy; search_path is pinned.
@@ -96,7 +104,7 @@ $$;
96
104
 
97
105
  create or replace function "smoodly_space_row_cap"() returns integer
98
106
  language sql stable security definer set search_path = public as $$
99
- select c."row_cap" from "space_caps" c where c."space_id" = "smoodly_current_space"();
107
+ select c."row_cap" from "spaces" c where c."space_id" = "smoodly_current_space"();
100
108
  $$;
101
109
 
102
110
  -- ── Triggers. A key's claim wins over whatever the insert said, so an
@@ -113,7 +121,7 @@ begin
113
121
  end;
114
122
  $$;
115
123
 
116
- -- Per table, per space. No space_caps row = no cap (self-host). Trigger
124
+ -- Per table, per space. No spaces row, or a null cap = no cap (self-host). Trigger
117
125
  -- a_ (smoodly_set_space) runs first, so new.space_id already equals the
118
126
  -- current space for a keyed insert; smoodly_space_row_cap() reads the
119
127
  -- current space itself, so this never needs the row's space_id as an
@@ -148,7 +156,7 @@ $$;`.trim());
148
156
  statements.push(`create index if not exists "${table}_space_idx" on "${table}" ("space_id");`);
149
157
  }
150
158
  // Trigger names sort a_ before b_: same-event triggers fire alphabetically.
151
- for (const table of [...CONTENT_TABLES, "editors"]) {
159
+ for (const table of [...CONTENT_TABLES, "members"]) {
152
160
  statements.push(`drop trigger if exists "smoodly_a_set_space" on "${table}";\n` +
153
161
  `create trigger "smoodly_a_set_space" before insert on "${table}"\n` +
154
162
  ` for each row execute function "smoodly_set_space"();`);
@@ -166,7 +174,7 @@ $$;`.trim());
166
174
  // Postgres never inlines; applied to all three calls for the same win.
167
175
  const own = `"space_id" = (select "smoodly_current_space"()) and not (select "smoodly_space_key_revoked"())`;
168
176
  const write = `${own} and (select "smoodly_key_kind"()) = 'write'`;
169
- for (const table of [...CONTENT_TABLES, "editors"]) {
177
+ for (const table of [...CONTENT_TABLES, "members"]) {
170
178
  statements.push(`drop policy if exists "smoodly_write" on "${table}";\n` +
171
179
  `create policy "smoodly_write" on "${table}" for all\n` +
172
180
  ` using (${write})\n` +
@@ -179,6 +187,12 @@ $$;`.trim());
179
187
  ` using (${predicate});`);
180
188
  }
181
189
  }
190
+ // A write key reads its own space's row — the console URL the Members
191
+ // page links to (spec 2026-09-25 §3). Nothing else touches spaces or
192
+ // space_keys but the two definer functions and the service role.
193
+ statements.push(`drop policy if exists "smoodly_read" on "spaces";\n` +
194
+ `create policy "smoodly_read" on "spaces" for select\n` +
195
+ ` using (${write});`);
182
196
  statements.push(storageSQL());
183
197
  return statements.join("\n\n");
184
198
  }
@@ -0,0 +1,8 @@
1
+ import type { SupabaseClient } from "@supabase/supabase-js";
2
+ import { type MemberRecord, type MemberStore } from "./member-store.ts";
3
+ export declare class SupabaseMemberStore implements MemberStore {
4
+ private db;
5
+ constructor(db: SupabaseClient);
6
+ list(): Promise<MemberRecord[]>;
7
+ consoleUrl(): Promise<string | null>;
8
+ }
@@ -0,0 +1,18 @@
1
+ import { sortMembers } from "./member-store.js";
2
+ export class SupabaseMemberStore {
3
+ constructor(db) {
4
+ this.db = db;
5
+ }
6
+ async list() {
7
+ const res = await this.db.from("members").select("user_id, email, role");
8
+ if (res.error)
9
+ throw new Error(`smoodly: ${res.error.message}`);
10
+ return sortMembers(res.data.map((r) => ({ userId: r.user_id, email: r.email, role: r.role })));
11
+ }
12
+ async consoleUrl() {
13
+ const res = await this.db.from("spaces").select("console_url").limit(1).maybeSingle();
14
+ if (res.error)
15
+ throw new Error(`smoodly: ${res.error.message}`);
16
+ return res.data?.console_url ?? null;
17
+ }
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoodly",
3
- "version": "0.0.21",
3
+ "version": "0.0.23",
4
4
  "description": "Open-source, code-first CMS for Next.js on Supabase: define a component once, it becomes a typed, editable building block.",
5
5
  "license": "MIT",
6
6
  "repository": {