smoodly 0.0.22 → 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 +31 -3
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/index.d.ts +1 -1
- package/dist/admin/next/index.d.ts +1 -1
- package/dist/admin/next/op-handler.d.ts +3 -0
- package/dist/admin/next/op-handler.js +1 -0
- package/dist/admin/ops-impl.d.ts +3 -0
- package/dist/admin/ops-impl.js +9 -0
- package/dist/admin/ops.d.ts +10 -0
- package/dist/admin/shell/AdminApp.js +3 -0
- package/dist/admin/shell/IconRail.js +4 -4
- package/dist/admin/shell/ListView.d.ts +3 -1
- package/dist/admin/shell/ListView.js +2 -2
- package/dist/admin/shell/MembersPage.d.ts +5 -0
- package/dist/admin/shell/MembersPage.js +36 -0
- package/dist/admin/shell/SettingsView.d.ts +5 -0
- package/dist/admin/shell/SettingsView.js +28 -0
- package/dist/admin/shell/members-list.d.ts +9 -0
- package/dist/admin/shell/members-list.js +9 -0
- package/dist/admin/shell/settings.d.ts +15 -0
- package/dist/admin/shell/settings.js +18 -0
- package/dist/admin/supabase-auth.js +2 -2
- package/dist/admin/ui/theme.js +13 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/member-store.d.ts +20 -0
- package/dist/member-store.js +29 -0
- package/dist/sql-space.js +29 -15
- package/dist/supabase-member-store.d.ts +8 -0
- package/dist/supabase-member-store.js +18 -0
- package/package.json +1 -1
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 (`
|
|
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 (`
|
|
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`
|
|
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.
|
package/dist/admin/client-ops.js
CHANGED
|
@@ -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) {
|
package/dist/admin/index.d.ts
CHANGED
|
@@ -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
|
package/dist/admin/ops-impl.d.ts
CHANGED
|
@@ -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;
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -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))),
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -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
|
|
13
|
-
//
|
|
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:
|
|
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 }),
|
|
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,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,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
|
|
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("
|
|
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;
|
package/dist/admin/ui/theme.js
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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
|
|
56
|
-
-- for everyone with ONE definition. Self-host fills them
|
|
57
|
-
-- leaves them empty; smoodly.io writes rows with the service
|
|
58
|
-
-- There is never cloud-only SQL on a content database.
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
82
|
+
"row_cap" integer,
|
|
83
|
+
"console_url" text
|
|
76
84
|
);
|
|
77
85
|
|
|
78
|
-
alter table "
|
|
86
|
+
alter table "members" enable row level security;
|
|
79
87
|
alter table "space_keys" enable row level security;
|
|
80
|
-
alter table "
|
|
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 "
|
|
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
|
|
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, "
|
|
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, "
|
|
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