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.
- package/README.md +38 -3
- package/dist/admin/auth.d.ts +10 -5
- package/dist/admin/auth.js +14 -13
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/editor/PageSettings.js +6 -0
- package/dist/admin/index.d.ts +3 -1
- package/dist/admin/next/index.d.ts +3 -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 +78 -1
- package/dist/admin/ops.d.ts +21 -0
- package/dist/admin/shell/AdminApp.js +28 -2
- package/dist/admin/shell/EntryForm.js +17 -0
- package/dist/admin/shell/IconRail.d.ts +3 -1
- package/dist/admin/shell/IconRail.js +6 -5
- package/dist/admin/shell/InviteModal.d.ts +6 -0
- package/dist/admin/shell/InviteModal.js +24 -0
- package/dist/admin/shell/ListView.d.ts +3 -1
- package/dist/admin/shell/ListView.js +2 -2
- package/dist/admin/shell/LoginView.js +34 -5
- package/dist/admin/shell/MembersPage.d.ts +6 -0
- package/dist/admin/shell/MembersPage.js +83 -0
- 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 +9 -0
- package/dist/admin/shell/SettingsView.js +49 -0
- 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 +21 -0
- package/dist/admin/shell/members-list.js +21 -0
- package/dist/admin/shell/session.d.ts +6 -0
- package/dist/admin/shell/session.js +17 -2
- package/dist/admin/shell/settings.d.ts +15 -0
- package/dist/admin/shell/settings.js +18 -0
- package/dist/admin/supabase-auth.js +7 -6
- package/dist/admin/ui/theme.js +13 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/member-store.d.ts +49 -0
- package/dist/member-store.js +60 -0
- package/dist/permissions.d.ts +7 -0
- package/dist/permissions.js +19 -0
- package/dist/sql-space.js +29 -15
- package/dist/supabase-member-store.d.ts +15 -0
- package/dist/supabase-member-store.js +78 -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,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 (`
|
|
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
|
-
- `
|
|
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.
|
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,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) => {
|
package/dist/admin/index.d.ts
CHANGED
|
@@ -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
|
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
|
@@ -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))),
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -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
|
|
13
|
-
//
|
|
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:
|
|
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 }),
|
|
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);
|