smoodly 0.0.23 → 0.0.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -4
- package/dist/admin/auth.d.ts +10 -5
- package/dist/admin/auth.js +14 -13
- package/dist/admin/client-ops.js +1 -1
- package/dist/admin/editor/PageSettings.js +6 -0
- package/dist/admin/index.d.ts +2 -0
- package/dist/admin/next/index.d.ts +2 -0
- package/dist/admin/ops-impl.js +69 -1
- package/dist/admin/ops.d.ts +13 -2
- package/dist/admin/shell/AdminApp.js +26 -3
- package/dist/admin/shell/EntryForm.js +17 -0
- package/dist/admin/shell/IconRail.d.ts +3 -1
- package/dist/admin/shell/IconRail.js +4 -3
- package/dist/admin/shell/InviteModal.d.ts +6 -0
- package/dist/admin/shell/InviteModal.js +24 -0
- package/dist/admin/shell/LoginView.js +34 -5
- package/dist/admin/shell/MembersPage.d.ts +2 -1
- package/dist/admin/shell/MembersPage.js +72 -25
- package/dist/admin/shell/PagesList.js +7 -0
- package/dist/admin/shell/SetPasswordView.d.ts +7 -0
- package/dist/admin/shell/SetPasswordView.js +41 -0
- package/dist/admin/shell/SettingsView.d.ts +5 -1
- package/dist/admin/shell/SettingsView.js +25 -4
- package/dist/admin/shell/live-url.d.ts +9 -0
- package/dist/admin/shell/live-url.js +10 -0
- package/dist/admin/shell/members-list.d.ts +13 -1
- package/dist/admin/shell/members-list.js +19 -7
- package/dist/admin/shell/session.d.ts +6 -0
- package/dist/admin/shell/session.js +17 -2
- package/dist/admin/supabase-auth.js +6 -5
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/member-store.d.ts +30 -1
- package/dist/member-store.js +37 -6
- package/dist/permissions.d.ts +7 -0
- package/dist/permissions.js +19 -0
- package/dist/supabase-member-store.d.ts +8 -1
- package/dist/supabase-member-store.js +62 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -640,15 +640,22 @@ tool — reset and reseed a development database (`supabase db reset`,
|
|
|
640
640
|
`alter table "space_caps" rename to "spaces";`
|
|
641
641
|
`alter table "spaces" alter column "row_cap" drop not null;`
|
|
642
642
|
`alter table "spaces" add column "console_url" text;`
|
|
643
|
+
`update "members" set "role" = 'admin' where "role" = 'owner';`
|
|
643
644
|
then re-run the regenerated migration's `smoodly_space_row_cap` function
|
|
644
645
|
and the `smoodly_read` policy on `spaces` (both `create or replace` /
|
|
645
646
|
`drop … if exists`, so the whole migration file can be re-applied).
|
|
646
647
|
Policies and triggers follow a table rename. Rows written before the
|
|
647
648
|
column show their user id on the Members page until re-inserted (the
|
|
648
649
|
demo-editor upsert fills it on the next seed).
|
|
649
|
-
-
|
|
650
|
-
|
|
651
|
-
|
|
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.
|
|
652
659
|
- The members list is not paged (tens of rows, not thousands).
|
|
653
660
|
- The `api-keys` page id is a URL segment; the title reads "API Keys".
|
|
654
661
|
|
|
@@ -1664,7 +1671,7 @@ the toolbar instead of pushing the content.
|
|
|
1664
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.
|
|
1665
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.
|
|
1666
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).
|
|
1667
|
-
- `
|
|
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.
|
|
1668
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.
|
|
1669
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).
|
|
1670
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.
|
package/dist/admin/auth.d.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
+
import { type MemberRole } from "../permissions.ts";
|
|
1
2
|
import type { AdminOpCall, OpResult } from "./ops.ts";
|
|
2
|
-
/**
|
|
3
|
-
export type
|
|
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<
|
|
10
|
-
/**
|
|
11
|
-
|
|
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>>;
|
package/dist/admin/auth.js
CHANGED
|
@@ -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
|
|
9
|
-
let
|
|
14
|
+
let user;
|
|
15
|
+
let stored;
|
|
10
16
|
try {
|
|
11
|
-
|
|
12
|
-
if (!
|
|
17
|
+
user = await auth.verify(token);
|
|
18
|
+
if (!user)
|
|
13
19
|
return unauthorized("smoodly: your session has expired — sign in again.");
|
|
14
|
-
|
|
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 (
|
|
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
|
}
|
package/dist/admin/client-ops.js
CHANGED
|
@@ -3,7 +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
|
+
members: ["list", "me", "invite", "setRole", "remove"],
|
|
7
7
|
preview: ["enable", "disable"],
|
|
8
8
|
};
|
|
9
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) => {
|
package/dist/admin/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
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
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";
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -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);
|
|
@@ -201,6 +202,26 @@ export function createAdminOps(deps) {
|
|
|
201
202
|
const assets = deps.assets;
|
|
202
203
|
const noMedia = () => err("internal", "smoodly: no media store is configured — pass `assets` to createAdminOpHandler.");
|
|
203
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
|
+
};
|
|
204
225
|
const mb = (bytes) => `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`;
|
|
205
226
|
const isDim = (v) => v === undefined || (typeof v === "number" && Number.isFinite(v) && v >= 0);
|
|
206
227
|
/** What an asset's usage row points at, labelled for the Media screen. */
|
|
@@ -854,9 +875,56 @@ export function createAdminOps(deps) {
|
|
|
854
875
|
list: () => guard(async () => {
|
|
855
876
|
if (!deps.members)
|
|
856
877
|
return noMembers();
|
|
878
|
+
const denied = membersDenied();
|
|
879
|
+
if (denied)
|
|
880
|
+
return denied;
|
|
857
881
|
const [members, consoleUrl] = await Promise.all([deps.members.list(), deps.members.consoleUrl()]);
|
|
858
882
|
return ok({ members, consoleUrl });
|
|
859
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
|
+
}),
|
|
860
928
|
},
|
|
861
929
|
preview: {
|
|
862
930
|
enable: () => guard(async () => (await effects?.draft?.enable(), ok(null))),
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -3,7 +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 { MemberRecord } from "../member-store.ts";
|
|
6
|
+
import type { InviteInput, InviteResult, MemberRecord } from "../member-store.ts";
|
|
7
|
+
import type { MemberRole } from "../permissions.ts";
|
|
7
8
|
import type { FieldError } from "./validate.ts";
|
|
8
9
|
export type OpError = {
|
|
9
10
|
ok: false;
|
|
@@ -196,8 +197,18 @@ export type AdminOps = {
|
|
|
196
197
|
usage(id: string): Promise<OpResult<AssetUsageItem[]>>;
|
|
197
198
|
};
|
|
198
199
|
members: {
|
|
199
|
-
/** The space's members
|
|
200
|
+
/** The space's members; admins only. `consoleUrl` set means smoodly.io manages them and every write below is blocked. */
|
|
200
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>>;
|
|
201
212
|
};
|
|
202
213
|
preview: {
|
|
203
214
|
enable(): 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,11 +21,13 @@ 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";
|
|
31
33
|
import { SettingsView } from "./SettingsView.js";
|
|
@@ -38,6 +40,24 @@ export function AdminApp({ registry, op, auth }) {
|
|
|
38
40
|
const [head = "pages", tail] = route.segments.length ? route.segments : ["pages"];
|
|
39
41
|
const isCollection = registry.collections.some((c) => c.name === head);
|
|
40
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 : "";
|
|
41
61
|
if (session.state.status === "loading") {
|
|
42
62
|
return (_jsx("div", { style: { height: "100vh", display: "grid", placeItems: "center", color: "var(--text-muted)" }, children: "Loading\u2026" }));
|
|
43
63
|
}
|
|
@@ -47,6 +67,9 @@ export function AdminApp({ registry, op, auth }) {
|
|
|
47
67
|
}
|
|
48
68
|
if (session.state.status === "signed_out")
|
|
49
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 });
|
|
50
73
|
let main;
|
|
51
74
|
if (head === "pages" && !tail)
|
|
52
75
|
main = _jsx(PagesList, { ops: ops, registry: registry });
|
|
@@ -61,10 +84,10 @@ export function AdminApp({ registry, op, auth }) {
|
|
|
61
84
|
else if (head === "globals" && tail)
|
|
62
85
|
main = _jsx(GlobalForm, { ops: ops, registry: registry, name: decodeURIComponent(tail) });
|
|
63
86
|
else if (head === "settings")
|
|
64
|
-
main = _jsx(SettingsView, { ops: ops, tail: tail });
|
|
87
|
+
main = _jsx(SettingsView, { ops: ops, tail: tail, role: role, userId: userId });
|
|
65
88
|
else if (head === "media")
|
|
66
89
|
main = _jsx(MediaScreen, {});
|
|
67
90
|
else
|
|
68
91
|
main = _jsx(ErrorPane, { message: "Not found." });
|
|
69
|
-
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 ?? ""}`)] }) }) }) }));
|
|
70
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;
|
|
@@ -10,7 +10,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
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
12
|
// the route is /globals), and Settings (the Settings area, spec
|
|
13
|
-
// 2026-09-25; the route is /settings
|
|
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();
|
|
@@ -78,5 +79,5 @@ export function IconRail({ registry, active, email, onSignOut }) {
|
|
|
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 }), 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
|
+
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,10 +4,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
// Google/GitHub when the auth project has them. Errors come from
|
|
5
5
|
// Supabase Auth verbatim. Being an editor is checked by the server on
|
|
6
6
|
// the first op — a signed-in non-editor sees that message in the shell.
|
|
7
|
+
// Forgot password (members management §6) mails a reset link; the reply
|
|
8
|
+
// is the same whatever the email, so the form reveals no membership.
|
|
7
9
|
import { useState } from "react";
|
|
8
10
|
import { Banner, Button, Field, Logo } from "../ui/primitives.js";
|
|
9
11
|
const LABELS = { google: "Continue with Google", github: "Continue with GitHub" };
|
|
12
|
+
const CARD = {
|
|
13
|
+
width: 320, display: "flex", flexDirection: "column", gap: 14,
|
|
14
|
+
background: "var(--surface)", padding: 28, borderRadius: "var(--radius-card-lg)",
|
|
15
|
+
border: "var(--menu-border)", boxShadow: "var(--shadow-page)",
|
|
16
|
+
};
|
|
10
17
|
export function LoginView({ client, providers = [] }) {
|
|
18
|
+
const [mode, setMode] = useState("signin");
|
|
11
19
|
const [email, setEmail] = useState("");
|
|
12
20
|
const [password, setPassword] = useState("");
|
|
13
21
|
const [error, setError] = useState(null);
|
|
@@ -28,6 +36,20 @@ export function LoginView({ client, providers = [] }) {
|
|
|
28
36
|
setBusy(false);
|
|
29
37
|
}
|
|
30
38
|
};
|
|
39
|
+
const forgot = async (e) => {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
setBusy(true);
|
|
42
|
+
setError(null);
|
|
43
|
+
try {
|
|
44
|
+
// The answer is not shown: the form must not reveal whether an email is registered.
|
|
45
|
+
await client.auth.resetPasswordForEmail(email, { redirectTo: window.location.origin + window.location.pathname });
|
|
46
|
+
}
|
|
47
|
+
catch { /* same */ }
|
|
48
|
+
finally {
|
|
49
|
+
setBusy(false);
|
|
50
|
+
setMode("sent");
|
|
51
|
+
}
|
|
52
|
+
};
|
|
31
53
|
const oauth = async (provider) => {
|
|
32
54
|
setBusy(true);
|
|
33
55
|
setError(null);
|
|
@@ -43,9 +65,16 @@ export function LoginView({ client, providers = [] }) {
|
|
|
43
65
|
setBusy(false);
|
|
44
66
|
}
|
|
45
67
|
};
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
68
|
+
const back = (_jsx(Button, { variant: "secondary", size: "lg", disabled: busy, onClick: () => { setError(null); setMode("signin"); }, children: "Back to sign in" }));
|
|
69
|
+
let card;
|
|
70
|
+
if (mode === "sent") {
|
|
71
|
+
card = (_jsxs("div", { style: CARD, children: [_jsx(Logo, { height: 20 }), _jsx("span", { children: "Check your email for a link to choose a new password." }), back] }));
|
|
72
|
+
}
|
|
73
|
+
else if (mode === "forgot") {
|
|
74
|
+
card = (_jsxs("form", { onSubmit: forgot, style: CARD, children: [_jsx(Logo, { height: 20 }), _jsx(Field, { label: "Email", children: _jsx("input", { className: "sm-input", type: "email", value: email, required: true, autoComplete: "email", autoFocus: true, onChange: (e) => setEmail(e.target.value) }) }), error && _jsx(Banner, { tone: "error", children: error }), _jsx("button", { type: "submit", disabled: busy, className: "sm-btn sm-btn--primary sm-btn--lg", children: busy ? "Sending…" : "Send reset link" }), back] }));
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
card = (_jsxs("form", { onSubmit: submit, style: CARD, children: [_jsx(Logo, { height: 20 }), _jsx(Field, { label: "Email", children: _jsx("input", { className: "sm-input", type: "email", value: email, required: true, autoComplete: "email", onChange: (e) => setEmail(e.target.value) }) }), _jsx(Field, { label: "Password", children: _jsx("input", { className: "sm-input", type: "password", value: password, required: true, autoComplete: "current-password", onChange: (e) => setPassword(e.target.value) }) }), error && _jsx(Banner, { tone: "error", children: error }), _jsx("button", { type: "submit", disabled: busy, className: "sm-btn sm-btn--primary sm-btn--lg", children: busy ? "Signing in…" : "Sign in" }), providers.map((p) => (_jsx(Button, { variant: "secondary", size: "lg", disabled: busy, onClick: () => oauth(p), children: LABELS[p] }, p))), _jsx("button", { type: "button", className: "sm-link", disabled: busy, onClick: () => { setError(null); setMode("forgot"); }, style: { background: "transparent", border: 0, color: "var(--text-muted)", cursor: "pointer", font: "inherit", padding: 0 }, children: "Forgot password?" })] }));
|
|
78
|
+
}
|
|
79
|
+
return (_jsx("div", { style: { minHeight: "100vh", display: "grid", placeItems: "center", background: "var(--bg)" }, children: card }));
|
|
51
80
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AdminOps, MembersListResult } from "../ops.ts";
|
|
2
|
-
export declare function MembersPage({ ops, initial }: {
|
|
2
|
+
export declare function MembersPage({ ops, userId, initial }: {
|
|
3
3
|
ops: AdminOps;
|
|
4
|
+
userId: string;
|
|
4
5
|
initial?: MembersListResult | null;
|
|
5
6
|
}): import("react").JSX.Element;
|