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
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,33 @@ 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
+ `update "members" set "role" = 'admin' where "role" = 'owner';`
644
+ then re-run the regenerated migration's `smoodly_space_row_cap` function
645
+ and the `smoodly_read` policy on `spaces` (both `create or replace` /
646
+ `drop … if exists`, so the whole migration file can be re-applied).
647
+ Policies and triggers follow a table rename. Rows written before the
648
+ column show their user id on the Members page until re-inserted (the
649
+ demo-editor upsert fills it on the next seed).
650
+ - Members management shipped 2026-09-25 (spec
651
+ `docs/superpowers/specs/2026-09-25-members-management-design.md`); out
652
+ of its scope: a pending-invite status and "resend invite" on a row
653
+ (needs the Auth user's `last_sign_in_at`, which the cloud projection
654
+ does not carry); removing the Auth account together with the member
655
+ row; invites from the cloud's `/admin` (they are smoodly.io's); a
656
+ project whose Auth is OAuth-only (the invite mail asks for a password);
657
+ redirects on a published rename (the confirm says "there are none
658
+ yet"). The API Keys page's contents are DESIGN.md OQ #33.
659
+ - The members list is not paged (tens of rows, not thousands).
660
+ - The `api-keys` page id is a URL segment; the title reads "API Keys".
661
+
627
662
  ## Follow-ups (logged 2026-09-13, history spec)
628
663
 
629
664
  - Existing databases (every install so far is ours; DESIGN.md OQ #2):
@@ -1616,7 +1651,7 @@ the toolbar instead of pushing the content.
1616
1651
  - Light/dark toggle (nav foot): the choice is stored in `localStorage`
1617
1652
  (`smoodly-theme`) and written as `data-theme` on the admin wrapper, never on
1618
1653
  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).
1654
+ preference would need a place to live (`members`, or a cloud user profile).
1620
1655
  - `lucide-react` is now a runtime dependency, for the toggle's two icons. The
1621
1656
  rest of the UI still uses the design system's unicode glyphs (↑ ↓ ⧉ × ··· ▾
1622
1657
  ← ⋮⋮); swapping them for Lucide is the next step whenever it is wanted.
@@ -1636,7 +1671,7 @@ the toolbar instead of pushing the content.
1636
1671
  - 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
1672
  - 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
1673
  - 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.
1674
+ - `memberOf` 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
1675
  - 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
1676
  - 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
1677
  - `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.
@@ -1,13 +1,18 @@
1
+ import { type MemberRole } from "../permissions.ts";
1
2
  import type { AdminOpCall, OpResult } from "./ops.ts";
2
- /** The verified editor: the Auth user id and the email the locale rows record as `updated_by` (spec 2026-09-13 history §2). */
3
- export type AdminSession = {
3
+ /** What the auth project vouches for: the Auth user id and the email the locale rows record as `updated_by` (spec 2026-09-13 history §2). */
4
+ export type VerifiedUser = {
4
5
  userId: string;
5
6
  email: string | null;
6
7
  };
8
+ /** The verified member: the user plus the normalized role from `members`. */
9
+ export type AdminSession = VerifiedUser & {
10
+ role: MemberRole;
11
+ };
7
12
  export type AdminAuth = {
8
13
  /** The user behind a session token, or null when the auth project rejects it. */
9
- verify(token: string): Promise<AdminSession | null>;
10
- /** Does `editors` hold this user for the current space? */
11
- isEditor(userId: string): Promise<boolean>;
14
+ verify(token: string): Promise<VerifiedUser | null>;
15
+ /** The stored role of this user in `members` for the current space, or null without a row. */
16
+ memberOf(userId: string): Promise<string | null>;
12
17
  };
13
18
  export declare function gateAdminOp(auth: AdminAuth, call: AdminOpCall, run: (call: AdminOpCall, session: AdminSession) => Promise<OpResult<unknown>>): Promise<OpResult<unknown>>;
@@ -1,3 +1,9 @@
1
+ // Admission to the admin (spec 2026-09-04 §5; roles 2026-09-25 members
2
+ // management §2): a session token from the auth project, then a row in
3
+ // `members` read with the write key, whose role rides on the session.
4
+ // One gate around the ops seam — individual ops ask can(). Framework-
5
+ // free; Supabase-backed implementation in supabase-auth.ts.
6
+ import { normalizeRole } from "../permissions.js";
1
7
  const unauthorized = (message) => ({ ok: false, code: "unauthorized", message });
2
8
  export async function gateAdminOp(auth, call, run) {
3
9
  const token = typeof call === "object" && call !== null && typeof call.token === "string"
@@ -5,24 +11,19 @@ export async function gateAdminOp(auth, call, run) {
5
11
  : null;
6
12
  if (!token)
7
13
  return unauthorized("smoodly: sign in to use the admin.");
8
- let session;
9
- let editor;
14
+ let user;
15
+ let stored;
10
16
  try {
11
- session = await auth.verify(token);
12
- if (!session)
17
+ user = await auth.verify(token);
18
+ if (!user)
13
19
  return unauthorized("smoodly: your session has expired — sign in again.");
14
- editor = await auth.isEditor(session.userId);
20
+ stored = await auth.memberOf(user.userId);
15
21
  }
16
22
  catch (e) {
17
23
  console.error("smoodly: auth check failed", e);
18
- return {
19
- ok: false,
20
- code: "internal",
21
- message: "smoodly: could not check your session.",
22
- };
24
+ return { ok: false, code: "internal", message: "smoodly: could not check your session." };
23
25
  }
24
- if (!editor) {
26
+ if (stored === null)
25
27
  return unauthorized("smoodly: this account is not an editor of this site.");
26
- }
27
- return run(call, session);
28
+ return run(call, { ...user, role: normalizeRole(stored) });
28
29
  }
@@ -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", "me", "invite", "setRole", "remove"],
6
7
  preview: ["enable", "disable"],
7
8
  };
8
9
  export function makeClientOps(action, token) {
@@ -10,6 +10,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
10
10
  import { useEffect, useState } from "react";
11
11
  import { updatedLine } from "../ui/format.js";
12
12
  import { Field, InfoRow, SectionLabel } from "../ui/primitives.js";
13
+ import { confirmLiveUrl } from "../shell/live-url.js";
13
14
  export function PageSettings({ record, locale, registry, path, fixed, onRename }) {
14
15
  const row = record.locales[locale];
15
16
  const [draft, setDraft] = useState({ title: row?.title ?? "", slug: row?.slug ?? "" });
@@ -30,6 +31,11 @@ export function PageSettings({ record, locale, registry, path, fixed, onRename }
30
31
  setError(`A ${key} is required — nothing falls back to another locale.`);
31
32
  return;
32
33
  }
34
+ // A published slug is a live URL: one confirm, Cancel restores the input (members management §5).
35
+ if (key === "slug" && !confirmLiveUrl({ status: row.status, from: row.slug, to: next })) {
36
+ setDraft((d) => ({ ...d, slug: row.slug }));
37
+ return;
38
+ }
33
39
  setError(await onRename({ [key]: next }));
34
40
  };
35
41
  const onKey = (key) => (e) => {
@@ -1,4 +1,6 @@
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
+ export type { InviteInput, InviteResult, MemberRecord } from "../member-store.ts";
3
+ export type { MemberRole } from "../permissions.ts";
2
4
  export { createAdminOps, runAdminOp, type AdminEffects } from "./ops-impl.ts";
3
5
  export { fixedNodeSpecs, fixedNodeOf, ensureFixedNodes, type FixedNode } from "./fixed-nodes.ts";
4
6
  export { ensureGlobalItems, initialGlobalFields } from "./global-items.ts";
@@ -1,6 +1,8 @@
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
+ export type { InviteInput, InviteResult, MemberRecord } from "../../member-store.ts";
5
+ export type { MemberRole } from "../../permissions.ts";
4
6
  export type { AuthConfig } from "../shell/session.ts";
5
7
  export type { AdminAuth } from "../auth.ts";
6
8
  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;
@@ -1,5 +1,6 @@
1
1
  import { entryFieldsIn, entryTitleIn } from "../entry-store.js";
2
2
  import { distinctTags } from "../tags.js";
3
+ import { can, isMemberRole, normalizeRole } from "../permissions.js";
3
4
  import { ASSET_COLLECTION } from "../media.js";
4
5
  import { adminHref, adminSlugTaken, isAdminSlug } from "../admin-path.js";
5
6
  import { affectedTargets, collectionTag, entryTag, pageTag, pagesTreeTag, globalTag } from "../revalidate.js";
@@ -87,7 +88,7 @@ const classify = (e) => {
87
88
  return err("validation", message);
88
89
  if (message.includes("not a valid slug"))
89
90
  return err("validation", message);
90
- if (message.includes("no page") || message.includes("no entry") || message.includes("no collection") || message.includes("no asset"))
91
+ if (message.includes("no page") || message.includes("no entry") || message.includes("no collection") || message.includes("no asset") || message.includes("no member"))
91
92
  return err("not_found", message);
92
93
  if (message.includes("not pending"))
93
94
  return err("conflict", message);
@@ -200,6 +201,27 @@ export function createAdminOps(deps) {
200
201
  const nameOf = (r) => r.locales[def]?.slug ?? Object.values(r.locales)[0]?.slug ?? r.id;
201
202
  const assets = deps.assets;
202
203
  const noMedia = () => err("internal", "smoodly: no media store is configured — pass `assets` to createAdminOpHandler.");
204
+ const noMembers = () => err("internal", "smoodly: no member store is configured — pass `members` to createAdminOpHandler.");
205
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
206
+ /** unauthorized unless the actor may manage members; no actor (scripts, the seed) may. */
207
+ const membersDenied = () => deps.actor && !can(deps.actor.role, "members.manage") ? err("unauthorized", "smoodly: only an admin can manage members.") : null;
208
+ /** blocked when the control plane manages this space's members (a row, never a mode check). */
209
+ const membersManaged = async () => (await deps.members.consoleUrl()) ? err("blocked", "smoodly: members are managed at smoodly.io.") : null;
210
+ /** The two guards on setRole and remove: never your own row, never the last admin (unless the change keeps them admin). */
211
+ const memberGuards = async (userId, staysAdmin) => {
212
+ if (deps.actor?.userId === userId)
213
+ return err("blocked", "smoodly: you cannot change your own membership.");
214
+ if (staysAdmin)
215
+ return null;
216
+ const rows = await deps.members.list();
217
+ const target = rows.find((m) => m.userId === userId);
218
+ if (!target)
219
+ return null; // the store answers not_found
220
+ const admins = rows.filter((m) => normalizeRole(m.role) === "admin");
221
+ if (normalizeRole(target.role) === "admin" && admins.length <= 1)
222
+ return err("blocked", "smoodly: this site needs at least one admin.");
223
+ return null;
224
+ };
203
225
  const mb = (bytes) => `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`;
204
226
  const isDim = (v) => v === undefined || (typeof v === "number" && Number.isFinite(v) && v >= 0);
205
227
  /** What an asset's usage row points at, labelled for the Media screen. */
@@ -849,6 +871,61 @@ export function createAdminOps(deps) {
849
871
  return ok(out);
850
872
  }),
851
873
  },
874
+ members: {
875
+ list: () => guard(async () => {
876
+ if (!deps.members)
877
+ return noMembers();
878
+ const denied = membersDenied();
879
+ if (denied)
880
+ return denied;
881
+ const [members, consoleUrl] = await Promise.all([deps.members.list(), deps.members.consoleUrl()]);
882
+ return ok({ members, consoleUrl });
883
+ }),
884
+ me: () => guard(async () => ok({ role: deps.actor?.role ?? "admin" })),
885
+ invite: (input) => guard(async () => {
886
+ if (!deps.members)
887
+ return noMembers();
888
+ const refused = membersDenied() ?? (await membersManaged());
889
+ if (refused)
890
+ return refused;
891
+ const email = typeof input?.email === "string" ? input.email.trim() : "";
892
+ if (!EMAIL_RE.test(email))
893
+ return err("validation", "smoodly: enter a valid email address.");
894
+ if (!isMemberRole(input.role))
895
+ return err("validation", "smoodly: the role must be admin or editor.");
896
+ if (typeof input.redirectTo !== "string" || !input.redirectTo)
897
+ return err("validation", "smoodly: the invite needs a return URL.");
898
+ const rows = await deps.members.list();
899
+ if (rows.some((m) => m.email?.toLowerCase() === email.toLowerCase()))
900
+ return err("conflict", "smoodly: already a member.");
901
+ return ok(await deps.members.invite({ email, role: input.role, redirectTo: input.redirectTo }));
902
+ }),
903
+ setRole: (userId, role) => guard(async () => {
904
+ if (!deps.members)
905
+ return noMembers();
906
+ const refused = membersDenied() ?? (await membersManaged());
907
+ if (refused)
908
+ return refused;
909
+ if (!isMemberRole(role))
910
+ return err("validation", "smoodly: the role must be admin or editor.");
911
+ const guardHit = await memberGuards(userId, role === "admin");
912
+ if (guardHit)
913
+ return guardHit;
914
+ return ok(await deps.members.setRole(userId, role));
915
+ }),
916
+ remove: (userId) => guard(async () => {
917
+ if (!deps.members)
918
+ return noMembers();
919
+ const refused = membersDenied() ?? (await membersManaged());
920
+ if (refused)
921
+ return refused;
922
+ const guardHit = await memberGuards(userId, false);
923
+ if (guardHit)
924
+ return guardHit;
925
+ await deps.members.remove(userId);
926
+ return ok(null);
927
+ }),
928
+ },
852
929
  preview: {
853
930
  enable: () => guard(async () => (await effects?.draft?.enable(), ok(null))),
854
931
  disable: () => guard(async () => (await effects?.draft?.disable(), ok(null))),
@@ -3,6 +3,8 @@ 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 { InviteInput, InviteResult, MemberRecord } from "../member-store.ts";
7
+ import type { MemberRole } from "../permissions.ts";
6
8
  import type { FieldError } from "./validate.ts";
7
9
  export type OpError = {
8
10
  ok: false;
@@ -91,6 +93,11 @@ export type AssetUsageItem = {
91
93
  title: string;
92
94
  href: string | null;
93
95
  };
96
+ /** The Members page's read (spec 2026-09-25 §4): the rows and, when the control plane manages them, where. */
97
+ export type MembersListResult = {
98
+ members: MemberRecord[];
99
+ consoleUrl: string | null;
100
+ };
94
101
  export type AdminOps = {
95
102
  pages: {
96
103
  /** Lists every node, materializing fixed pages and collection mounts (in every supported locale) first. */
@@ -189,6 +196,20 @@ export type AdminOps = {
189
196
  delete(id: string): Promise<OpResult<null>>;
190
197
  usage(id: string): Promise<OpResult<AssetUsageItem[]>>;
191
198
  };
199
+ members: {
200
+ /** The space's members; admins only. `consoleUrl` set means smoodly.io manages them and every write below is blocked. */
201
+ list(): Promise<OpResult<MembersListResult>>;
202
+ /** The caller's role; every member. The shell hides Settings from editors with it. */
203
+ me(): Promise<OpResult<{
204
+ role: MemberRole;
205
+ }>>;
206
+ /** Admins only, self-host only: invites (or adds an existing account), refuses a duplicate row (spec 2026-09-25 members management §3). */
207
+ invite(input: InviteInput): Promise<OpResult<InviteResult>>;
208
+ /** Admins only, self-host only; never your own row, never the last admin. */
209
+ setRole(userId: string, role: MemberRole): Promise<OpResult<MemberRecord>>;
210
+ /** Admins only, self-host only; the same two guards. The Auth account stays. */
211
+ remove(userId: string): Promise<OpResult<null>>;
212
+ };
192
213
  preview: {
193
214
  enable(): Promise<OpResult<null>>;
194
215
  disable(): Promise<OpResult<null>>;
@@ -12,7 +12,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  // (shell/IconRail.tsx), then the main column whose views own their own
13
13
  // 48px top bar (the editor adds the third, its inspector). The shell is
14
14
  // viewport-height; every column scrolls inside itself.
15
- import { useState } from "react";
15
+ import { useEffect, useState } from "react";
16
16
  import { makeClientOps } from "../client-ops.js";
17
17
  import { PagesList } from "./PagesList.js";
18
18
  import { EntriesList } from "./EntriesList.js";
@@ -21,13 +21,16 @@ import { GlobalList } from "./GlobalList.js";
21
21
  import { GlobalForm } from "./GlobalForm.js";
22
22
  import { EditorView } from "../editor/EditorView.js";
23
23
  import { LoginView } from "./LoginView.js";
24
+ import { SetPasswordView } from "./SetPasswordView.js";
24
25
  import { useAdminSession } from "./session.js";
25
26
  import { IconRail } from "./IconRail.js";
26
27
  import { useRoute } from "./router.js";
27
28
  import { AdminPathProvider } from "./base.js";
28
29
  import { mountMismatch } from "../../admin-path.js";
30
+ import { can } from "../../permissions.js";
29
31
  import { ErrorPane } from "./ErrorPane.js";
30
32
  import { MediaScreen } from "./MediaScreen.js";
33
+ import { SettingsView } from "./SettingsView.js";
31
34
  import { AssetsProvider } from "../forms/assets-context.js";
32
35
  import { ElementsProvider } from "../forms/elements-context.js";
33
36
  export function AdminApp({ registry, op, auth }) {
@@ -37,6 +40,24 @@ export function AdminApp({ registry, op, auth }) {
37
40
  const [head = "pages", tail] = route.segments.length ? route.segments : ["pages"];
38
41
  const isCollection = registry.collections.some((c) => c.name === head);
39
42
  const email = session.state.status === "signed_in" ? session.state.session.user.email : undefined;
43
+ // The role rides on members.me (members management §2): Settings hides
44
+ // from an editor, and the area itself refuses. Unknown until it answers.
45
+ const signedIn = session.state.status === "signed_in";
46
+ const [role, setRole] = useState(null);
47
+ useEffect(() => {
48
+ if (!signedIn) {
49
+ setRole(null);
50
+ return;
51
+ }
52
+ let live = true;
53
+ ops.members.me()
54
+ .then((r) => { if (live)
55
+ setRole(r.ok ? r.data.role : "editor"); })
56
+ .catch(() => { if (live)
57
+ setRole("editor"); });
58
+ return () => { live = false; };
59
+ }, [signedIn, ops]);
60
+ const userId = session.state.status === "signed_in" ? session.state.session.user.id : "";
40
61
  if (session.state.status === "loading") {
41
62
  return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", color: "var(--text-muted)" }, children: "Loading\u2026" }));
42
63
  }
@@ -46,6 +67,9 @@ export function AdminApp({ registry, op, auth }) {
46
67
  }
47
68
  if (session.state.status === "signed_out")
48
69
  return _jsx(LoginView, { client: session.client, providers: auth.providers });
70
+ // An invite or reset link signed the person in: Set password before the shell (members management §6).
71
+ if (session.arrival)
72
+ return _jsx(SetPasswordView, { client: session.client, mode: session.arrival, onDone: session.clearArrival });
49
73
  let main;
50
74
  if (head === "pages" && !tail)
51
75
  main = _jsx(PagesList, { ops: ops, registry: registry });
@@ -59,9 +83,11 @@ export function AdminApp({ registry, op, auth }) {
59
83
  main = _jsx(GlobalList, { ops: ops, registry: registry });
60
84
  else if (head === "globals" && tail)
61
85
  main = _jsx(GlobalForm, { ops: ops, registry: registry, name: decodeURIComponent(tail) });
86
+ else if (head === "settings")
87
+ main = _jsx(SettingsView, { ops: ops, tail: tail, role: role, userId: userId });
62
88
  else if (head === "media")
63
89
  main = _jsx(MediaScreen, {});
64
90
  else
65
91
  main = _jsx(ErrorPane, { message: "Not found." });
66
- return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(AssetsProvider, { ops: ops, media: registry.media, children: _jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }) }) }) }));
92
+ return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(AssetsProvider, { ops: ops, media: registry.media, children: _jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, settings: role !== null && can(role, "settings"), onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }) }) }) }));
67
93
  }
@@ -32,6 +32,7 @@ import { entrySourceLocale, entryTitle, localizedFieldKeys } from "./entries-lis
32
32
  import { useAdminHref } from "./base.js";
33
33
  import { localeFromUrl, writeLocaleToUrl } from "../editor/locale-url.js";
34
34
  import { navigate, useLeaveGuard } from "./router.js";
35
+ import { LIVE_URL_MESSAGE, liveUrlChanges } from "./live-url.js";
35
36
  /** A stable empty map for the hooks that run before the "no such collection" return. */
36
37
  const NO_FIELDS = {};
37
38
  export function EntryForm({ ops, registry, collection, id }) {
@@ -59,10 +60,26 @@ export function EntryForm({ ops, registry, collection, id }) {
59
60
  const formRef = useRef(null);
60
61
  const fieldsRef = useRef(fields);
61
62
  fieldsRef.current = fields;
63
+ const recordRef = useRef(record);
64
+ recordRef.current = record;
65
+ /** The slug the editor already confirmed as a live-URL change, so the next autosave does not ask again. */
66
+ const confirmedSlug = useRef(null);
62
67
  // The page editor's controller: debounced, retrying, flushable (spec 2026-09-14 §3).
63
68
  const { saveState, mark, flush } = useDraftAutosave({
64
69
  read: () => fieldsRef.current,
65
70
  persist: async (value) => {
71
+ // A published entry's slug is a live URL: one confirm at the save, Cancel restores it (members management §5).
72
+ const row = recordRef.current?.locales[locale];
73
+ const stored = row?.slug ?? null;
74
+ const next = typeof value.slug === "string" ? value.slug : null;
75
+ if (next !== confirmedSlug.current && liveUrlChanges({ status: row?.status, from: stored, to: next })) {
76
+ if (window.confirm(LIVE_URL_MESSAGE))
77
+ confirmedSlug.current = next;
78
+ else {
79
+ value = { ...value, slug: stored };
80
+ setFields((f) => ({ ...(f ?? {}), slug: stored }));
81
+ }
82
+ }
66
83
  const r = await ops.entries.save(collection, id, locale, value);
67
84
  if (r.ok)
68
85
  setRecord(r.data);
@@ -1,7 +1,9 @@
1
1
  import type { AdminRegistry } from "../serialize.ts";
2
- export declare function IconRail({ registry, active, email, onSignOut }: {
2
+ export declare function IconRail({ registry, active, email, settings, onSignOut }: {
3
3
  registry: AdminRegistry;
4
4
  active: string;
5
5
  email?: string;
6
+ /** Whether the Settings tile shows — `can(role, "settings")` in the shell. */
7
+ settings?: boolean;
6
8
  onSignOut: () => void;
7
9
  }): import("react").JSX.Element;
@@ -9,8 +9,9 @@ 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; admins only, so the shell passes
14
+ // `settings` false for an editor). The user avatar is pinned to the bottom and opens the same
14
15
  // flyout with the theme toggle and Sign out, which the old nav's footer
15
16
  // held.
16
17
  //
@@ -29,7 +30,7 @@ const ICON = { size: 18, strokeWidth: 2, "aria-hidden": true };
29
30
  function AppIcon() {
30
31
  return (_jsxs("svg", { className: "sm-rail__logo", viewBox: "0 0 89 86", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "Smoodly", children: [_jsx("path", { fill: "#0038B1", d: "M0 51.5552C0 24.3141 25.0505 0 52.5493 0H52.7761C74.4707 0 89 14.6336 89 34.445C89 61.6862 64.4034 86 36.0066 86C14.0946 86 0 70.9165 0 51.5552Z" }), _jsx("path", { fill: "#FFFFFF", d: "M23.25 46.1188C23.25 33.0027 34.8806 21.2959 47.6479 21.2959H47.7532C57.8257 21.2959 64.5714 28.3417 64.5714 37.8805C64.5714 50.9966 53.1516 62.7033 39.9674 62.7033C29.7939 62.7033 23.25 55.4409 23.25 46.1188Z" })] }));
31
32
  }
32
- export function IconRail({ registry, active, email, onSignOut }) {
33
+ export function IconRail({ registry, active, email, settings = true, onSignOut }) {
33
34
  const adminHref = useAdminHref();
34
35
  const root = useRef(null);
35
36
  const uid = useId();
@@ -72,11 +73,11 @@ export function IconRail({ registry, active, email, onSignOut }) {
72
73
  items[(i + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length].focus();
73
74
  }
74
75
  };
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 })] })) }));
76
+ 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
77
  const collections = registry.collections;
77
78
  const collectionActive = collections.some((c) => c.name === active);
78
79
  const collectionsOpen = open === "collections" || (hovered && open === null);
79
80
  const name = email?.split("@")[0] ?? "Editor";
80
81
  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" }) })] })] })] }));
82
+ 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")), settings && 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
83
  }
@@ -0,0 +1,6 @@
1
+ import type { InviteInput } from "../../member-store.ts";
2
+ export declare function InviteModal({ onClose, onInvite }: {
3
+ onClose(): void;
4
+ /** Resolves to an error message, or null when the invite landed (the caller closes). */
5
+ onInvite(input: Omit<InviteInput, "redirectTo">): Promise<string | null>;
6
+ }): import("react").JSX.Element;
@@ -0,0 +1,24 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // packages/smoodly/src/admin/shell/InviteModal.tsx
4
+ // Invite a member (spec 2026-09-25 members management §4): an email, a
5
+ // role, Send. The op's error shows inside; success closes it and the
6
+ // page reports what happened.
7
+ import { useState } from "react";
8
+ import { MEMBER_ROLES } from "../../permissions.js";
9
+ import { Modal } from "../ui/Modal.js";
10
+ import { Banner, Button, Field, Segmented } from "../ui/primitives.js";
11
+ import { roleLabel } from "./members-list.js";
12
+ export function InviteModal({ onClose, onInvite }) {
13
+ const [email, setEmail] = useState("");
14
+ const [role, setRole] = useState("editor");
15
+ const [error, setError] = useState(null);
16
+ const [busy, setBusy] = useState(false);
17
+ const submit = async (e) => {
18
+ e.preventDefault();
19
+ setBusy(true);
20
+ setError(await onInvite({ email: email.trim(), role }));
21
+ setBusy(false);
22
+ };
23
+ return (_jsx(Modal, { title: "Invite a member", width: 420, onClose: onClose, children: _jsxs("form", { onSubmit: submit, style: { display: "flex", flexDirection: "column", gap: 14 }, children: [_jsx(Field, { label: "Email", children: _jsx("input", { className: "sm-input", type: "email", required: true, autoFocus: true, value: email, onChange: (e) => setEmail(e.target.value) }) }), _jsx(Field, { label: "Role", asDiv: true, children: _jsx(Segmented, { options: MEMBER_ROLES, value: role, onChange: setRole, label: roleLabel }) }), error && _jsx(Banner, { tone: "error", children: error }), _jsxs("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8 }, children: [_jsx(Button, { variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx("button", { type: "submit", className: "sm-btn sm-btn--primary", disabled: busy, children: busy ? "Sending…" : "Send invite" })] })] }) }));
24
+ }
@@ -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);