From c973b8d861bedca1a5f188cb7f58311f0113b354 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 28 May 2026 19:24:07 -0500 Subject: [PATCH 1/2] refactor(cli): remove generated /admin console shell from site generator The runtime-dynamic admin console moves to the standalone schemaforge-console repo (versioned packages so a fix reaches every operator via a bump, not a regenerate+redeploy). The CLI's `site generate` command stays and still emits the `/app/*` typed scaffold, login, accessibility page, and generated client. - Delete templates/site/src/admin/ (9 jinja files) and the ADMIN_TEMPLATES const + emit loop in commands/site/mod.rs. - Relocate the helpers the kept /app shell still needs out of the admin client into generated/api-client.ts.jinja: listSchemas, isSystemSchema, minimal SchemaResponse/ListSchemasResponse (the sidebar nav stays runtime read-access-filtered) and getMeta/ MetaResponse (login branding). - Rewire App.tsx: drop the Admin sidebar section, admin-permissions query, AdminLayout import and /admin/* route; repoint the home, tenant-switch, and search redirects to /app/${defaultEntity}. - Point login.tsx at the generated client; tidy stale admin wording. - site_generate.rs asserts no src/admin/ is emitted and the home redirect targets /app/; site_e2e smoke + a11y specs repointed to /app/* (the /app scaffold has no delete or user-management UI). - Docs/Taskfile/README: drop admin-console references with a pointer to schemaforge-console; fix a broken link and a stale page title. --- README.md | 4 +- Taskfile.yml | 7 +- .../schema-forge-cli/src/commands/site/mod.rs | 35 +- .../templates/site/src/App.tsx.jinja | 98 +-- .../site/src/admin/api-client.ts.jinja | 694 ------------------ .../site/src/admin/entity-detail.tsx.jinja | 154 ---- .../site/src/admin/entity-edit.tsx.jinja | 265 ------- .../site/src/admin/entity-list.tsx.jinja | 380 ---------- .../site/src/admin/field-renderer.tsx.jinja | 637 ---------------- .../templates/site/src/admin/layout.tsx.jinja | 30 - .../site/src/admin/schemas-index.tsx.jinja | 185 ----- .../site/src/admin/users-edit.tsx.jinja | 342 --------- .../site/src/admin/users-list.tsx.jinja | 166 ----- .../site/src/generated/api-client.ts.jinja | 77 ++ .../src/generated/route-manifest.ts.jinja | 3 +- .../templates/site/src/lib/auth.ts.jinja | 4 +- .../site/src/pages/accessibility.tsx.jinja | 2 +- .../templates/site/src/pages/login.tsx.jinja | 4 +- .../schema-forge-cli/tests/site_e2e/README.md | 12 +- .../site_e2e/playwright/tests/a11y.spec.ts | 62 +- .../site_e2e/playwright/tests/smoke.spec.ts | 70 +- .../schema-forge-cli/tests/site_generate.rs | 29 +- docs/site-guide.md | 19 +- 23 files changed, 174 insertions(+), 3105 deletions(-) delete mode 100644 crates/schema-forge-cli/templates/site/src/admin/api-client.ts.jinja delete mode 100644 crates/schema-forge-cli/templates/site/src/admin/entity-detail.tsx.jinja delete mode 100644 crates/schema-forge-cli/templates/site/src/admin/entity-edit.tsx.jinja delete mode 100644 crates/schema-forge-cli/templates/site/src/admin/entity-list.tsx.jinja delete mode 100644 crates/schema-forge-cli/templates/site/src/admin/field-renderer.tsx.jinja delete mode 100644 crates/schema-forge-cli/templates/site/src/admin/layout.tsx.jinja delete mode 100644 crates/schema-forge-cli/templates/site/src/admin/schemas-index.tsx.jinja delete mode 100644 crates/schema-forge-cli/templates/site/src/admin/users-edit.tsx.jinja delete mode 100644 crates/schema-forge-cli/templates/site/src/admin/users-list.tsx.jinja diff --git a/README.md b/README.md index 13f3762..86af2d5 100644 --- a/README.md +++ b/README.md @@ -845,7 +845,7 @@ let extension = SchemaForgeExtension::builder() let app = extension.register_routes(axum::Router::new()); ``` -See [`docs/site-guide.md`](docs/site-guide.md) for the React site generator workflow, including the `/app/*` vs `/admin/*` route trees, template override loader, auth bootstrap, and field-type widget reference. +See [`docs/site-guide.md`](docs/site-guide.md) for the React site generator workflow, including the `/app/*` route tree, template override loader, auth bootstrap, and field-type widget reference. (The runtime-dynamic admin console moved to the [`schemaforge-console`](https://github.com/Govcraft/schemaforge-console) repo.) ### Computing Migrations @@ -904,7 +904,7 @@ Token pairs to verify on light theme: `--gc-ink` on `--gc-paper`, `--gc-steel` / The baseline also honors three platform-accessibility expectations federal reviewers test for explicitly: - **`prefers-reduced-motion`** — `src/index.css` clamps all animation and transition durations to ~0.01ms when the OS-level reduce-motion setting is on (Section 508 FPC §302.9, WCAG 2.1 SC 2.3.3 advisory). -- **Descriptive page titles** — `index.html` ships `{project_name} — Admin` for pre-hydration paint and text-mode browsers; `useDocumentTitle` refines per route at runtime (SC 2.4.2). +- **Descriptive page titles** — `index.html` ships `{project_name}` for pre-hydration paint and text-mode browsers; `useDocumentTitle` refines per route at runtime (SC 2.4.2). - **Session-timeout warning** — `src/lib/auth.ts` surfaces a T-30s warning toast with an "Extend session" action before the PASETO refresh window closes (SC 2.2.1). ## Project Status diff --git a/Taskfile.yml b/Taskfile.yml index 102ee8f..7f74e9f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -4,10 +4,11 @@ version: '3' # - Binary is `schemaforge` (defined in crates/schema-forge-cli/Cargo.toml). # - Default backend is `surrealdb`; alternate is `postgres` (mutually # exclusive with the default — opt in by setting FEATURES=postgres). -# - The React admin/app surface lives in `site/`, scaffolded by +# - The React `/app` surface lives in `site/`, scaffolded by # `schemaforge site generate` and served by Vite. The legacy Tera + # Tailwind `admin-ui` is gone; there is no embedded HTML template -# pipeline to compile any more. +# pipeline to compile any more. The runtime-dynamic admin console moved +# to the schemaforge-console repo. vars: # Override on the CLI to swap backends, e.g. `FEATURES=postgres task serve`. @@ -105,7 +106,7 @@ tasks: echo " meta: {{.BASE_URL}}/api/v1/forge/meta" echo " schemas: {{.BASE_URL}}/api/v1/forge/schemas" echo "" - echo " Launch the React admin/app site:" + echo " Launch the React app site:" echo " task site:dev # regenerates site/ and starts Vite on :{{.SITE_DEV_PORT}}" echo "" echo " Press Ctrl+C to stop the backend." diff --git a/crates/schema-forge-cli/src/commands/site/mod.rs b/crates/schema-forge-cli/src/commands/site/mod.rs index f154b06..773af15 100644 --- a/crates/schema-forge-cli/src/commands/site/mod.rs +++ b/crates/schema-forge-cli/src/commands/site/mod.rs @@ -357,8 +357,8 @@ fn build_plan(ctx: &SiteContext, renderer: &SiteRenderer) -> Result Result FilePlan { FilePlan { relative_path: PathBuf::from(path), diff --git a/crates/schema-forge-cli/templates/site/src/App.tsx.jinja b/crates/schema-forge-cli/templates/site/src/App.tsx.jinja index ac73772..799e33d 100644 --- a/crates/schema-forge-cli/templates/site/src/App.tsx.jinja +++ b/crates/schema-forge-cli/templates/site/src/App.tsx.jinja @@ -1,13 +1,11 @@ // App shell. The sidebar + topbar baseline lives here so every authed -// route shares one chrome — admin pages and per-entity /app pages alike -// — instead of double-stacking with AdminLayout. /login is the only -// route rendered outside the shell. +// route shares one chrome. /login is the only route rendered outside the +// shell. // // The shell mirrors the Govcraft DS: -// - Sidebar (ink rail): brand mark + project name; an Admin section with -// Schemas / Users entries; an Entities section listing every visible -// schema with a per-row icon; a footer block with a quick-switcher -// stub, a theme toggle, and an API/Build readout. +// - Sidebar (ink rail): brand mark + project name; an Entities section +// listing every visible schema with a per-row icon; a footer block with +// a quick-switcher stub, a theme toggle, and an API/Build readout. // - Topbar: chevron-separated breadcrumbs, a search-shaped command-K // button (palette is a follow-up; the button is the surface for it), // an icon-only theme toggle, and a user pill that signs out on click. @@ -26,7 +24,6 @@ import { FileText, Folder, Hash, - Layers, ListTree, Moon, Search, @@ -35,9 +32,8 @@ import { Target, Users, } from "lucide-react" -import { routeManifest } from "@/generated/route-manifest" +import { defaultEntity, routeManifest } from "@/generated/route-manifest" import { LoginPage } from "@/pages/login" -import { AdminLayout } from "@/admin/layout" import { RequireAuth } from "@/lib/require-auth" import { activeTenantStore, @@ -48,11 +44,10 @@ import { logout, } from "@/lib/auth" import { - getAdminPermissions, isSystemSchema, listSchemas, type SchemaResponse, -} from "@/admin/api-client" +} from "@/generated/api-client" const THEME_KEY = "{{ project_name | lower }}.theme" type Theme = "light" | "dark" @@ -128,27 +123,14 @@ function Sidebar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => const location = useLocation() // The schemas endpoint already filters to whatever the caller can read, // so the nav reflects Cedar's decision without any client-side - // post-filtering. System schemas (User et al.) are still hidden from - // the user-facing entities list — they're administered through dedicated - // /admin/users surfaces, not the generic entity browser. + // post-filtering. System schemas (User et al.) carry the `@system` + // annotation and stay out of the user-facing entity browser. const { data: schemas } = useQuery({ - queryKey: ["admin", "schemas"], + queryKey: ["schemas"], queryFn: () => listSchemas(), }) const appSchemas = (schemas ?? []).filter((s: SchemaResponse) => !isSystemSchema(s)) - // The admin shell's top-level sections (Schemas, Users) are gated by a - // tiny dedicated endpoint so the nav matches what the underlying pages - // would actually allow. A 401 from this query means the user is signed - // out — that's already handled by the auth-aware `request()` helper. - const { data: adminPerms } = useQuery({ - queryKey: ["admin", "permissions"], - queryFn: getAdminPermissions, - }) - const showSchemasAdmin = adminPerms?.admin.schemas_manage ?? false - const showUsersAdmin = adminPerms?.admin.users_manage ?? false - const showAdminSection = showSchemasAdmin || showUsersAdmin - const isActive = (prefix: string) => location.pathname === prefix || location.pathname.startsWith(prefix + "/") @@ -163,32 +145,6 @@ function Sidebar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () =>
{{ project_name }}
- {showAdminSection ? ( -
-
- Admin -
- {showSchemasAdmin ? ( - - - Schemas - - ) : null} - {showUsersAdmin ? ( - - - Users - - ) : null} -
- ) : null} -
Entities @@ -255,9 +211,7 @@ function Sidebar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => // --------------------------------------------------------------------------- const CRUMB_LABELS: Record = { - admin: "Admin", app: "App", - users: "Users", new: "New", edit: "Edit", } @@ -296,8 +250,8 @@ function shortTenantId(id: string): string { // Tenant chrome: a picker when the operator belongs to several tenants, a // static chip when they belong to exactly one, and a "platform_admin" badge // when no tenant scope is in effect (so cross-tenant visibility is explained). -// Switching is stateless — it stores the choice and hard-reloads to /admin so -// no tenant-A data lingers in the React Query cache under tenant B. +// Switching is stateless — it stores the choice and hard-reloads to the home +// route so no tenant-A data lingers in the React Query cache under tenant B. function TenantControl() { const { data: me } = useQuery({ queryKey: ["auth", "me", activeTenantStore.get()], @@ -322,7 +276,7 @@ function TenantControl() { const sep = value.indexOf(":") if (sep <= 0) return activeTenantStore.set(value.slice(0, sep), value.slice(sep + 1)) - if (typeof window !== "undefined") window.location.assign("/admin") + if (typeof window !== "undefined") window.location.assign(`/app/${defaultEntity}`) } if (chain.length >= 2) { @@ -407,12 +361,12 @@ function Topbar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => v @@ -474,7 +428,7 @@ export default function App() { path="*" element={ - + } /> @@ -493,16 +447,12 @@ export default function App() { } /> - {/* Land on the schema catalog: it's access-aware (filters to - schemas the user can read) and never auto-loads a specific - entity, so callers without read access on the first declared - schema don't trigger a 403 popup before they've even chosen - where to go. */} + {/* Land on the first declared entity's list. */} - + } /> @@ -519,16 +469,6 @@ export default function App() { } /> ))} - - {/* /admin/* — generic schema-aware admin shell (runtime-dynamic). */} - - - - } - /> {/* richColors is intentionally disabled — sonner's tinted variants diff --git a/crates/schema-forge-cli/templates/site/src/admin/api-client.ts.jinja b/crates/schema-forge-cli/templates/site/src/admin/api-client.ts.jinja deleted file mode 100644 index 2410cdc..0000000 --- a/crates/schema-forge-cli/templates/site/src/admin/api-client.ts.jinja +++ /dev/null @@ -1,694 +0,0 @@ -// Admin API client — schema-agnostic counterpart to `src/generated/api-client.ts`. -// -// The admin UI talks to the backend generically: it fetches the schema -// catalog at runtime and issues entity CRUD against whatever schema the -// user is browsing, rather than relying on codegen'd per-entity functions. -// -// Wire shapes mirror `schema-forge-acton::routes::schemas::SchemaResponse` -// and `FieldResponse`. Keep them in sync with that file when the backend -// changes — TODO(Phase 4) is to have the backend expose a pre-digested -// "view" shape so we don't parse the raw `FieldType` enum on the client. -import { ACTIVE_TENANT_HEADER, activeTenantStore, refreshToken, tokenStore } from "@/lib/auth" - -const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? "" -const FORGE_API_PREFIX = "/api/v1/forge" - -// --------------------------------------------------------------------------- -// Wire shapes (mirror of SchemaResponse / FieldResponse in Rust) -// --------------------------------------------------------------------------- - -/** Raw `FieldType` enum as serialized by serde — `{type, data?}`. */ -export type RawFieldType = { - type: string - data?: unknown -} - -export type FieldResponse = { - name: string - field_type: RawFieldType - modifiers: string[] - /** Field-level annotations (`@widget`, `@format`, `@field_access`, etc.). - * Tagged-enum shape: `{annotation: "Widget", widget_type: "..."}`. */ - annotations?: unknown[] -} - -/** Schema-level permission flags computed by Cedar for the calling user. - * Present on read responses (`GET /schemas`, `GET /schemas/:name`); absent - * on create/update responses where the caller's authority is implicit. */ -export type SchemaPermissions = { - /** Whether the user may create new entities of this schema. Drives the - * visibility of "New" buttons in the admin shell. */ - create: boolean -} - -/** Per-entity permission flags computed against the entity's actual - * attributes, so per-record policies (`@owner`, `@tenant`) are honored. */ -export type EntityPermissions = { - /** Whether the user may update this row. */ - update: boolean - /** Whether the user may delete this row. */ - delete: boolean -} - -export type SchemaResponse = { - id: string - name: string - fields: FieldResponse[] - annotations: unknown[] - /** Cedar-derived schema-level permissions for the calling user. Absent - * on responses produced by mutations. */ - permissions?: SchemaPermissions -} - -export type ListSchemasResponse = { - schemas: SchemaResponse[] - count: number -} - -type EntityEnvelope = { - id: string - schema: string - fields: Record - permissions?: EntityPermissions -} - -type ListEntitiesEnvelope = { - entities: EntityEnvelope[] - count: number - total_count?: number - permissions?: SchemaPermissions -} - -// --------------------------------------------------------------------------- -// Normalized view model -// --------------------------------------------------------------------------- - -/** - * Normalized field descriptor the admin UI consumes. Flattens the raw - * `field_type.type` + `field_type.data` shape into a single `kind` plus - * kind-specific data. - */ -export type FieldMeta = { - name: string - kind: - | "text" - | "rich_text" - | "integer" - | "float" - | "boolean" - | "datetime" - | "enum" - | "json" - | "relation_one" - | "relation_many" - | "array" - | "composite" - | "file" - | "unknown" - required: boolean - /** Enum variants, when `kind === "enum"`. */ - enumVariants?: string[] - /** Relation target schema name, when `kind` starts with `relation_`. */ - relationTarget?: string - /** Array element kind, when `kind === "array"`. */ - arrayElement?: FieldMeta - /** Composite sub-fields, when `kind === "composite"`. */ - subFields?: FieldMeta[] - /** File-field metadata, when `kind === "file"`. */ - fileMeta?: FileMeta - /** `@widget(...)` hint as a snake_case token (e.g. `"status_badge"`). */ - widget?: string - /** `@format(...)` hint as a snake_case token (e.g. `"currency"`). */ - format?: string - /** `@field_access(read=[...])` roles — undefined means no read gate. */ - accessRead?: string[] - /** `@field_access(write=[...])` roles — undefined means no write gate. */ - accessWrite?: string[] -} - -/** Metadata the file upload widget needs to render accept hints and size caps. */ -export type FileMeta = { - bucket: string - maxSizeBytes: number - maxSizeHuman: string - mimeAllowlist: string[] - access: "presigned" | "proxied" -} - -/** Project a wire `FieldResponse` into the admin's normalized `FieldMeta`. */ -export function toFieldMeta(f: FieldResponse): FieldMeta { - // Backend emits string modifiers uniformly for top-level and composite - // sub-fields (see `schema_to_response` in schema-forge-acton). The `?? []` - // defense covers the case where the field has zero modifiers and the key - // is absent from the wire payload. - const annotations = f.annotations ?? [] - const access = getFieldAccess(annotations) - const base = { - name: f.name, - required: (f.modifiers ?? []).includes("required"), - widget: getFieldWidget(annotations), - format: getFieldFormat(annotations), - accessRead: access?.read, - accessWrite: access?.write, - } - const raw = f.field_type - switch (raw.type) { - case "Text": - return { ...base, kind: "text" } - case "RichText": - return { ...base, kind: "rich_text" } - case "Integer": - return { ...base, kind: "integer" } - case "Float": - return { ...base, kind: "float" } - case "Boolean": - return { ...base, kind: "boolean" } - case "DateTime": - return { ...base, kind: "datetime" } - case "Json": - return { ...base, kind: "json" } - case "Enum": { - // `EnumVariants` serializes as a bare `string[]` — not `{variants: [...]}`. - const variants = Array.isArray(raw.data) ? (raw.data as string[]) : [] - return { ...base, kind: "enum", enumVariants: variants } - } - case "Relation": { - const data = raw.data as - | { target: string; cardinality: "One" | "Many" } - | undefined - const kind = data?.cardinality === "Many" ? "relation_many" : "relation_one" - return { ...base, kind, relationTarget: data?.target } - } - case "Array": { - // Array is a boxed inner FieldType — reuse toFieldMeta on a synthetic - // FieldResponse so we get the same normalization logic for free. - const inner = raw.data as RawFieldType | undefined - const element = inner - ? toFieldMeta({ name: `${f.name}[]`, field_type: inner, modifiers: [] }) - : undefined - return { ...base, kind: "array", arrayElement: element } - } - case "Composite": { - const inner = raw.data as FieldResponse[] | undefined - return { - ...base, - kind: "composite", - subFields: (inner ?? []).map(toFieldMeta), - } - } - case "File": { - const data = raw.data as - | { - bucket: string - max_size_bytes: number - mime_allowlist: Array< - | { kind: "exact"; value: string } - | { kind: "family"; value: string } - > - access: "presigned" | "proxied" - } - | undefined - if (!data) return { ...base, kind: "file" } - const mimeAllowlist = (data.mime_allowlist ?? []).map((m) => - m.kind === "family" ? `${m.value}/*` : m.value, - ) - return { - ...base, - kind: "file", - fileMeta: { - bucket: data.bucket, - maxSizeBytes: data.max_size_bytes, - maxSizeHuman: humanizeBytes(data.max_size_bytes), - mimeAllowlist, - access: data.access, - }, - } - } - default: - return { ...base, kind: "unknown" } - } -} - -function humanizeBytes(n: number): string { - if (n < 1024) return `${n} B` - if (n < 1024 * 1024) { - const v = n / 1024 - return v % 1 === 0 ? `${v} KB` : `${v.toFixed(1)} KB` - } - if (n < 1024 * 1024 * 1024) { - const v = n / 1024 / 1024 - return v % 1 === 0 ? `${v} MB` : `${v.toFixed(1)} MB` - } - const v = n / 1024 / 1024 / 1024 - return v % 1 === 0 ? `${v} GB` : `${v.toFixed(2)} GB` -} - -/** - * Extract the `@display("field")` annotation value from a schema's top-level - * annotation list, if present. Used to label relation options without the - * admin client knowing the target schema's structure. - * - * The wire shape is the tagged-enum form `{annotation: "Display", field: ...}` - * with the annotation tag in PascalCase — match case-insensitively to be - * resilient to the ongoing backend serialization cleanup. - */ -export function getDisplayField(annotations: unknown[]): string | undefined { - for (const a of annotations) { - if (a && typeof a === "object") { - const tag = (a as { annotation?: string }).annotation - if (typeof tag === "string" && tag.toLowerCase() === "display") { - const field = (a as { field?: string }).field - if (typeof field === "string") return field - } - } - } - return undefined -} - -/** - * Extract the `@widget("...")` hint from a field's annotation list. - * - * Wire shape: `{annotation: "Widget", widget_type: ""}`. Tag - * matched case-insensitively, same as `getDisplayField`. - */ -export function getFieldWidget(annotations: unknown[]): string | undefined { - for (const a of annotations) { - if (a && typeof a === "object") { - const tag = (a as { annotation?: string }).annotation - if (typeof tag === "string" && tag.toLowerCase() === "widget") { - const widget = (a as { widget_type?: string }).widget_type - if (typeof widget === "string") return widget - } - } - } - return undefined -} - -/** - * Extract the `@field_access(read=[...], write=[...])` annotation from a - * field's annotation list. Either list may be absent; callers should - * treat `undefined` as "no gate applied". - * - * Wire shape: `{annotation: "FieldAccess", read: [...], write: [...]}`. - * Tag matched case-insensitively, same as `getDisplayField`. - */ -export function getFieldAccess( - annotations: unknown[], -): { read?: string[]; write?: string[] } | undefined { - for (const a of annotations) { - if (a && typeof a === "object") { - const tag = (a as { annotation?: string }).annotation - if (typeof tag === "string" && tag.toLowerCase() === "fieldaccess") { - const rawRead = (a as { read?: unknown }).read - const rawWrite = (a as { write?: unknown }).write - const read = Array.isArray(rawRead) - ? (rawRead.filter((r) => typeof r === "string") as string[]) - : undefined - const write = Array.isArray(rawWrite) - ? (rawWrite.filter((r) => typeof r === "string") as string[]) - : undefined - return { read, write } - } - } - } - return undefined -} - -/** - * Extract the `@format("...")` hint from a field's annotation list. - * - * Wire shape: `{annotation: "Format", format_type: ""}`. Tag - * matched case-insensitively, same as `getDisplayField`. - */ -export function getFieldFormat(annotations: unknown[]): string | undefined { - for (const a of annotations) { - if (a && typeof a === "object") { - const tag = (a as { annotation?: string }).annotation - if (typeof tag === "string" && tag.toLowerCase() === "format") { - const format = (a as { format_type?: string }).format_type - if (typeof format === "string") return format - } - } - } - return undefined -} - -// --------------------------------------------------------------------------- -// HTTP plumbing (shared token store + 401 redirect with the app client) -// --------------------------------------------------------------------------- - -async function request(path: string, init?: RequestInit): Promise { - let res = await sendRequest(path, init) - if (res.status === 401) { - // Silent-refresh-and-retry once before bouncing to /login. - try { - await refreshToken() - res = await sendRequest(path, init) - } catch { - // Refresh failed — fall through to the login redirect below. - } - } - if (res.status === 401) { - tokenStore.clear() - if (typeof window !== "undefined") { - const next = encodeURIComponent(window.location.pathname + window.location.search) - window.location.replace(`/login?next=${next}`) - } - throw new Error("API 401: unauthorized") - } - if (!res.ok) { - throw new Error(`API ${res.status}: ${await res.text()}`) - } - if (res.status === 204) { - return undefined as T - } - return res.json() as Promise -} - -async function sendRequest(path: string, init?: RequestInit): Promise { - const token = tokenStore.get() - const headers: Record = { - "Content-Type": "application/json", - ...((init?.headers as Record | undefined) ?? {}), - } - if (token) { - headers["Authorization"] = `Bearer ${token}` - } - // Scope every entity request to the operator's chosen tenant. The server - // resolves this against the token's membership set and 403s a non-member - // value, so it is safe to send unconditionally. A caller-supplied header - // (via `init`) wins, letting one-off requests override the active scope. - const activeTenant = activeTenantStore.get() - if (activeTenant && !(ACTIVE_TENANT_HEADER in headers)) { - headers[ACTIVE_TENANT_HEADER] = activeTenant - } - return fetch(`${API_BASE}${path}`, { ...init, headers }) -} - -function flatten(env: EntityEnvelope): Record { - // `__permissions` carries the Cedar decision the server already made for - // this row. Double-underscore matches the codebase's existing convention - // for synthetic, non-field metadata (`__display`, etc.) so it's unlikely - // to collide with a schema-declared field name. - const flat: Record = { id: env.id, ...env.fields } - if (env.permissions !== undefined) { - flat.__permissions = env.permissions - } - return flat -} - -function wrap(body: Record): { fields: Record } { - const { id: _id, ...fields } = body - void _id - return { fields } -} - -// --------------------------------------------------------------------------- -// Schema introspection -// --------------------------------------------------------------------------- - -/** List every schema the caller has permission to see. */ -export async function listSchemas(): Promise { - const res = await request(`${FORGE_API_PREFIX}/schemas`) - return res.schemas -} - -// --------------------------------------------------------------------------- -// Runtime posture (`/meta`) -// --------------------------------------------------------------------------- - -/** Wire shape of `GET /api/v1/forge/meta`. The endpoint is unauthenticated - * so the login screen can render real backend / auth / build values - * without first having a token. */ -export type MetaResponse = { - /** Stable lowercase backend kind (`"surrealdb"`, `"postgres"`, `"turso"`). */ - backend: string - /** Human-readable backend label (`"SurrealDB 2.x"`). */ - backend_label: string - auth: { - /** Token scheme — `"paseto"` today. */ - kind: string - /** Login token TTL, in seconds. The login screen formats this as - * `60m TTL` etc. */ - ttl_seconds: number - } - build: { - /** Crate version of the running schema-forge-acton. */ - version: string - } -} - -/** Fetch the runtime meta snapshot. Public endpoint — no token required. - * Bypasses the auth-aware `request()` helper so a fetch failure on the - * login screen doesn't trigger a redirect-to-login loop. */ -export async function getMeta(): Promise { - const res = await fetch(`${API_BASE}${FORGE_API_PREFIX}/meta`, { - headers: { "Content-Type": "application/json" }, - }) - if (!res.ok) { - throw new Error(`API ${res.status}: ${await res.text()}`) - } - return res.json() as Promise -} - -/** True when the schema carries the `@system` annotation. System schemas - * (User, TenantMembership, WebhookSubscription) are platform plumbing - * and should be hidden from the user-facing entity navigation — they - * remain reachable through the schema-administration view for operators - * who need them. */ -export function isSystemSchema(schema: SchemaResponse): boolean { - return (schema.annotations ?? []).some( - (a) => - typeof a === "object" && - a !== null && - "annotation" in a && - (a as { annotation: unknown }).annotation === "System", - ) -} - -/** Describe a single schema. Returns both the raw response and the - * normalized field metadata used by the admin renderer. */ -export async function describeSchema(name: string): Promise<{ - schema: SchemaResponse - fields: FieldMeta[] - displayField?: string -}> { - const schema = await request( - `${FORGE_API_PREFIX}/schemas/${encodeURIComponent(name)}`, - ) - return { - schema, - fields: schema.fields.map(toFieldMeta), - displayField: getDisplayField(schema.annotations), - } -} - -// --------------------------------------------------------------------------- -// Generic entity CRUD -// --------------------------------------------------------------------------- - -export type ListEntitiesParams = { - limit?: number - offset?: number - sort?: string - /** - * Per-field filter pairs. Keys use the backend's `__` syntax - * (e.g. `name__contains`, `status__eq`, `age__gte`). Values are sent - * verbatim as URL query parameters. Each entry becomes one filter - * clause; multiple entries are AND-combined by the backend. - */ - filters?: Record -} - -/** A flattened entity row. The optional `__permissions` field carries the - * Cedar decision the server made for the calling user, so per-row UI - * affordances can be gated without round-tripping. */ -export type EntityRow = Record & { - id: string - __permissions?: EntityPermissions -} - -export async function listEntities( - schema: string, - params: ListEntitiesParams = { limit: 100 }, -): Promise<{ rows: EntityRow[]; count: number; permissions?: SchemaPermissions }> { - const qs = new URLSearchParams() - if (params.limit !== undefined) qs.set("limit", String(params.limit)) - if (params.offset !== undefined) qs.set("offset", String(params.offset)) - if (params.sort) qs.set("sort", params.sort) - if (params.filters) { - for (const [key, value] of Object.entries(params.filters)) { - if (value !== "") qs.set(key, value) - } - } - const suffix = qs.toString() ? `?${qs.toString()}` : "" - const env = await request( - `${FORGE_API_PREFIX}/schemas/${encodeURIComponent(schema)}/entities${suffix}`, - ) - return { - rows: env.entities.map((e) => flatten(e) as EntityRow), - count: env.total_count ?? env.count, - permissions: env.permissions, - } -} - -export async function getEntity( - schema: string, - id: string, -): Promise { - const env = await request( - `${FORGE_API_PREFIX}/schemas/${encodeURIComponent(schema)}/entities/${encodeURIComponent(id)}`, - ) - return flatten(env) as EntityRow -} - -export async function createEntity( - schema: string, - body: Record, -): Promise { - const env = await request( - `${FORGE_API_PREFIX}/schemas/${encodeURIComponent(schema)}/entities`, - { method: "POST", body: JSON.stringify(wrap(body)) }, - ) - return flatten(env) as EntityRow -} - -export async function updateEntity( - schema: string, - id: string, - body: Record, -): Promise { - const env = await request( - `${FORGE_API_PREFIX}/schemas/${encodeURIComponent(schema)}/entities/${encodeURIComponent(id)}`, - { method: "PATCH", body: JSON.stringify(wrap(body)) }, - ) - return flatten(env) as EntityRow -} - -export async function deleteEntity(schema: string, id: string): Promise { - await request( - `${FORGE_API_PREFIX}/schemas/${encodeURIComponent(schema)}/entities/${encodeURIComponent(id)}`, - { method: "DELETE" }, - ) -} - -// --------------------------------------------------------------------------- -// User management (mirrors `schema-forge-acton::routes::users`) -// --------------------------------------------------------------------------- - -/** A user row as returned by `GET /users`. */ -export type UserRow = { - username: string - roles: string[] - display_name: string | null - active: boolean - /** Server-computed max rank across `roles`. Read-only. */ - role_rank: number -} - -type ListUsersEnvelope = { - users: UserRow[] - count: number -} - -/** Request body for `POST /users`. */ -export type CreateUserBody = { - username: string - password: string - roles: string[] - display_name?: string | null -} - -/** Request body for `PUT /users/:username`. All fields are optional — - * omitting a field leaves it unchanged. An empty `roles` array clears - * every role. Password rotation goes through `changePassword(...)`. */ -export type UpdateUserBody = { - roles?: string[] - display_name?: string - active?: boolean -} - -/** List every user (admin only). */ -export async function listUsers(): Promise { - const env = await request(`${FORGE_API_PREFIX}/users`) - return env.users -} - -/** Create a new user (admin only). */ -export async function createUser(body: CreateUserBody): Promise { - return request(`${FORGE_API_PREFIX}/users`, { - method: "POST", - body: JSON.stringify(body), - }) -} - -/** Update a user's roles, display name, and/or active flag (admin only). - * Password rotation goes through `changePassword`. */ -export async function updateUser( - username: string, - body: UpdateUserBody, -): Promise { - return request( - `${FORGE_API_PREFIX}/users/${encodeURIComponent(username)}`, - { method: "PUT", body: JSON.stringify(body) }, - ) -} - -/** Delete a user by username (admin only). */ -export async function deleteUser(username: string): Promise { - await request( - `${FORGE_API_PREFIX}/users/${encodeURIComponent(username)}`, - { method: "DELETE" }, - ) -} - -/** Change a user's password. Allowed when the caller is admin or self. */ -export async function changePassword( - username: string, - password: string, -): Promise { - await request( - `${FORGE_API_PREFIX}/users/${encodeURIComponent(username)}/password`, - { method: "POST", body: JSON.stringify({ password }) }, - ) -} - -/** A single available role as returned by `GET /users/roles`. */ -export type RoleOption = { - name: string - rank: number -} - -type ListRolesEnvelope = { - roles: RoleOption[] - count: number -} - -/** List the roles the caller is allowed to grant. The server filters out - * `platform_admin` for callers that don't already hold it, so the form - * can render the response directly without re-filtering. */ -export async function listRoles(): Promise { - const env = await request(`${FORGE_API_PREFIX}/users/roles`) - return env.roles -} - -// --------------------------------------------------------------------------- -// Admin-shell permissions (`/permissions`) -// --------------------------------------------------------------------------- - -/** Wire shape of `GET /api/v1/forge/permissions`. The admin shell queries - * this once after login and uses the flags to decide which top-level - * admin nav items to render. */ -export type AdminPermissionsResponse = { - admin: { - /** Whether the Schemas admin section is visible (platform_admin only). */ - schemas_manage: boolean - /** Whether the Users admin section is visible (anyone with List on User). */ - users_manage: boolean - } -} - -/** Fetch the admin-shell permission flags for the calling user. */ -export async function getAdminPermissions(): Promise { - return request(`${FORGE_API_PREFIX}/permissions`) -} diff --git a/crates/schema-forge-cli/templates/site/src/admin/entity-detail.tsx.jinja b/crates/schema-forge-cli/templates/site/src/admin/entity-detail.tsx.jinja deleted file mode 100644 index 1042edd..0000000 --- a/crates/schema-forge-cli/templates/site/src/admin/entity-detail.tsx.jinja +++ /dev/null @@ -1,154 +0,0 @@ -// Read-only entity detail rendered as a Govcraft "spec sheet": numbered -// margin column (§ 01, § 02, …), mono uppercase field labels, hairline -// row dividers. Edit / Delete / Back actions live in the page header. -import { Link, useNavigate, useParams } from "react-router-dom" -import { useMutation, useQuery } from "@tanstack/react-query" -import { toast } from "sonner" -import { - deleteEntity, - describeSchema, - getEntity, -} from "@/admin/api-client" -import { FieldRenderer, canReadField } from "@/admin/field-renderer" -import { Button } from "@/components/ui/button" -import { ConfirmDialog } from "@/components/ui/confirm-dialog" -import { ErrorBlock } from "@/components/ui/error-block" -import { useDocumentTitle } from "@/lib/use-document-title" - -export function AdminEntityDetail() { - const { schema, id } = useParams<{ schema: string; id: string }>() - useDocumentTitle(schema ? `${schema} record` : "Record") - const navigate = useNavigate() - - const meta = useQuery({ - queryKey: ["admin", "schema", schema], - queryFn: () => describeSchema(schema!), - enabled: Boolean(schema), - }) - const entity = useQuery({ - queryKey: ["admin", "entity", schema, id], - queryFn: () => getEntity(schema!, id!), - enabled: Boolean(schema && id), - }) - - const remove = useMutation({ - mutationFn: () => deleteEntity(schema!, id!), - onSuccess: () => { - toast.success(`Deleted ${schema} ${id}`) - navigate(`/admin/${schema}`) - }, - }) - - if (!schema || !id) return null - if (meta.isLoading || entity.isLoading) { - return ( -
-
Loading…
-
- ) - } - if (meta.error || entity.error || !entity.data) { - const err = meta.error ?? entity.error ?? new Error("record not found") - return ( -
- { - meta.refetch() - entity.refetch() - }} - /> -
- ) - } - - const fields = (meta.data?.fields ?? []).filter(canReadField) - const data = entity.data - // Per-entity permissions are computed by Cedar against this record's - // actual attributes, so attribute-driven policies (`@owner`, `@tenant`) - // are honored. Default to hidden when absent — better to under-show than - // to render a button that 403s on click. - const canUpdate = data.__permissions?.update ?? false - const canDelete = data.__permissions?.delete ?? false - - return ( -
-
-
-
{schema} · RECORD
-

{shortLabel(data)}

-
{data.id}
-
-
- - {canUpdate ? ( - - ) : null} - {canDelete ? ( - - Delete - - } - title={`Delete ${schema}?`} - description={ - <> - You are about to permanently delete{" "} - {shortLabel(data)}{" "} - ({data.id}). This cannot be - undone. - - } - confirmLabel="Delete" - destructive - busy={remove.isPending} - onConfirm={() => remove.mutate()} - /> - ) : null} -
-
- -
- {fields.map((f, i) => { - const value = data[f.name] - const num = String(i + 1).padStart(2, "0") - const isEmpty = value === null || value === undefined || value === "" - return ( -
-
§ {num}
-
- {f.name} - {f.required ? * : null} -
-
- {isEmpty ? ( - — empty - ) : ( - - )} -
-
- ) - })} -
-
- ) -} - -function shortLabel(data: Record): string { - // Pick the most "human" field for the page title — name/title/label first, - // otherwise fall back to the id. Keeps the spec sheet feeling like a - // record, not a UUID. - const candidates = ["name", "title", "label", "subject", "username", "email"] - for (const k of candidates) { - const v = data[k] - if (typeof v === "string" && v.trim()) return v - } - return String(data.id ?? "Record") -} diff --git a/crates/schema-forge-cli/templates/site/src/admin/entity-edit.tsx.jinja b/crates/schema-forge-cli/templates/site/src/admin/entity-edit.tsx.jinja deleted file mode 100644 index 7a099a6..0000000 --- a/crates/schema-forge-cli/templates/site/src/admin/entity-edit.tsx.jinja +++ /dev/null @@ -1,265 +0,0 @@ -// Generic create / edit form. Same component backs both `/admin/:schema/new` -// and `/admin/:schema/:id/edit`: when `:id` is present we prefill the form -// from the server; otherwise we start empty and POST on submit. -// -// Layout follows the Govcraft form-grid: 220px mono uppercase labels in -// a left column, fields in a right column. State is a controlled value -// dict — react-hook-form isn't pulling its weight when every field is -// schema-driven. -import { FormEvent, useEffect, useId, useState } from "react" -import { Link, useNavigate, useParams } from "react-router-dom" -import { useMutation, useQuery } from "@tanstack/react-query" -import { - createEntity, - describeSchema, - getEntity, - updateEntity, - type FieldMeta, -} from "@/admin/api-client" -import { toast } from "sonner" -import { FieldRenderer, canReadField, canWriteField } from "@/admin/field-renderer" -import { Button } from "@/components/ui/button" -import { ErrorBlock } from "@/components/ui/error-block" -import { useDocumentTitle } from "@/lib/use-document-title" - -export function AdminEntityEdit() { - const { schema, id } = useParams<{ schema: string; id?: string }>() - const navigate = useNavigate() - const isNew = !id - useDocumentTitle( - schema ? `${isNew ? "New" : "Edit"} ${schema}` : isNew ? "New record" : "Edit record", - ) - - const meta = useQuery({ - queryKey: ["admin", "schema", schema], - queryFn: () => describeSchema(schema!), - enabled: Boolean(schema), - }) - const existing = useQuery({ - queryKey: ["admin", "entity", schema, id], - queryFn: () => getEntity(schema!, id!), - enabled: Boolean(schema && id), - }) - - const [values, setValues] = useState>({}) - const [formError, setFormError] = useState(null) - - // Seed the form once schema + existing entity have loaded. - useEffect(() => { - if (!meta.data) return - if (isNew) { - setValues(defaultValuesFor(meta.data.fields)) - return - } - if (existing.data) { - setValues(pickKnownFields(existing.data, meta.data.fields)) - } - }, [meta.data, existing.data, isNew]) - - const save = useMutation({ - mutationFn: async (body: Record) => { - if (isNew) return createEntity(schema!, body) - return updateEntity(schema!, id!, body) - }, - onSuccess: (entity) => { - toast.success(isNew ? `Created ${schema}` : `Saved ${schema} ${entity.id}`) - navigate(`/admin/${schema}/${entity.id}`) - }, - }) - - if (!schema) return null - if (meta.isLoading || (!isNew && existing.isLoading)) { - return ( -
-
Loading…
-
- ) - } - if (meta.error) { - return ( -
- meta.refetch()} - /> -
- ) - } - - // Fields the user can at least read. Fields gated by - // `@field_access(read=[...])` are dropped entirely; write-only-denied - // fields are kept but forced into read-only mode by the renderer. - const fields = (meta.data?.fields ?? []).filter(canReadField) - - function onSubmit(ev: FormEvent) { - ev.preventDefault() - setFormError(null) - // Validate required fields client-side. Anything past that is the - // server's call — we forward the 4xx body verbatim. - for (const f of fields) { - if (f.required && canWriteField(f) && isBlank(values[f.name])) { - setFormError(`Field "${f.name}" is required`) - return - } - } - // Drop fields the user can't write from the payload so the backend's - // Cedar policy doesn't reject the whole request for an unrelated - // read-only field. The server still enforces this authoritatively. - const writable: Record = {} - for (const f of fields) { - if (canWriteField(f)) writable[f.name] = values[f.name] - } - save.mutate(writable) - } - - return ( -
-
-
-
{schema} · {isNew ? "NEW" : "EDIT"}
-

- {isNew ? `New ${schema}` : `Edit ${schema}`} -

- {!isNew ?
{id}
: null} -
-
- -
-
- {fields.map((f) => ( - - setValues((prev) => ({ ...prev, [f.name]: next })) - } - /> - ))} -
- - {formError ? ( -
- - - Error: - {formError} - -
- ) : null} - -
- - -
-
-
- ) -} - -// --------------------------------------------------------------------------- -// Per-field row component (uses useId() so the visible label is wired to the -// underlying input via htmlFor/id — SC 1.3.1 / SC 3.3.2). Pulled out of the -// parent so the hook can run at field scope. -// --------------------------------------------------------------------------- - -function FormField({ - field, - value, - onChange, -}: { - field: FieldMeta - value: unknown - onChange: (next: unknown) => void -}) { - const writable = canWriteField(field) - const inputId = useId() - return ( -
- -
- -
{field.kind}
-
-
- ) -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function defaultValuesFor(fields: FieldMeta[]): Record { - const out: Record = {} - for (const f of fields) { - switch (f.kind) { - case "boolean": - out[f.name] = false - break - case "array": - case "relation_many": - out[f.name] = [] - break - case "composite": - out[f.name] = f.subFields ? defaultValuesFor(f.subFields) : {} - break - default: - out[f.name] = null - } - } - return out -} - -function pickKnownFields( - entity: Record, - fields: FieldMeta[], -): Record { - const out: Record = {} - for (const f of fields) { - out[f.name] = entity[f.name] - } - return out -} - -function isBlank(v: unknown): boolean { - if (v === null || v === undefined) return true - if (typeof v === "string") return v.trim() === "" - if (Array.isArray(v)) return v.length === 0 - return false -} diff --git a/crates/schema-forge-cli/templates/site/src/admin/entity-list.tsx.jinja b/crates/schema-forge-cli/templates/site/src/admin/entity-list.tsx.jinja deleted file mode 100644 index 59d1a93..0000000 --- a/crates/schema-forge-cli/templates/site/src/admin/entity-list.tsx.jinja +++ /dev/null @@ -1,380 +0,0 @@ -// Generic entity browser. Compact 32px-row table with mono uppercase -// column eyebrows, hairline borders, signal-orange accents on the active -// sort column and selected rows. Sort, contains-filter, and offset pager -// drive the backend's standard query params. -import { useEffect, useState, type ReactNode } from "react" -import { Link, useNavigate, useParams } from "react-router-dom" -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { toast } from "sonner" -import { - deleteEntity, - describeSchema, - listEntities, - type FieldMeta, -} from "@/admin/api-client" -import { canReadField } from "@/admin/field-renderer" -import { formatFieldValue } from "@/generated/formatters" -import { Button } from "@/components/ui/button" -import { ConfirmDialog } from "@/components/ui/confirm-dialog" -import { ErrorBlock } from "@/components/ui/error-block" -import { useDocumentTitle } from "@/lib/use-document-title" - -const PAGE_SIZES = [25, 50, 100, 200] as const -type SortDir = "asc" | "desc" - -export function AdminEntityList() { - const { schema } = useParams<{ schema: string }>() - useDocumentTitle(schema ?? "Records") - const navigate = useNavigate() - const qc = useQueryClient() - - const [limit, setLimit] = useState(50) - const [offset, setOffset] = useState(0) - const [sortField, setSortField] = useState(null) - const [sortDir, setSortDir] = useState("asc") - const [filterField, setFilterField] = useState("") - const [filterInput, setFilterInput] = useState("") - const [filterValue, setFilterValue] = useState("") - - // Debounce the filter input so keystrokes don't stampede the backend. - useEffect(() => { - const t = setTimeout(() => { - setFilterValue(filterInput) - setOffset(0) - }, 300) - return () => clearTimeout(t) - }, [filterInput]) - - const sortParam = sortField - ? sortDir === "desc" - ? `-${sortField}` - : sortField - : undefined - - const meta = useQuery({ - queryKey: ["admin", "schema", schema], - queryFn: () => describeSchema(schema!), - enabled: Boolean(schema), - }) - - // Text-like columns that can back the contains filter. - const filterableFields = (meta.data?.fields ?? []).filter( - (f) => f.kind === "text" || f.kind === "enum", - ) - const effectiveFilterField = - filterField || filterableFields[0]?.name || "" - const filters = - effectiveFilterField && filterValue - ? { [`${effectiveFilterField}__contains`]: filterValue } - : undefined - - const rows = useQuery({ - queryKey: [ - "admin", - "entities", - schema, - { limit, offset, sort: sortParam, filters }, - ], - queryFn: () => - listEntities(schema!, { - limit, - offset, - sort: sortParam, - filters, - }), - enabled: Boolean(schema), - // Keep the previous page visible while the next one loads so the - // table doesn't flash empty on pager clicks. - placeholderData: (previous) => previous, - }) - - const remove = useMutation({ - mutationFn: (id: string) => deleteEntity(schema!, id), - onSuccess: (_data, id) => { - toast.success(`Deleted ${schema} ${id}`) - qc.invalidateQueries({ queryKey: ["admin", "entities", schema] }) - }, - }) - - if (!schema) return null - if (meta.isLoading) { - return ( -
-
Loading…
-
- ) - } - if (meta.error) { - return ( -
- meta.refetch()} - /> -
- ) - } - - // Columns: every non-composite field the current user can read, plus - // an implicit id. Fields gated by `@field_access(read=[...])` are - // dropped from the table entirely. - const fields = (meta.data?.fields ?? []).filter( - (f) => f.kind !== "composite" && canReadField(f), - ) - - const total = rows.data?.count ?? 0 - const pageStart = total === 0 ? 0 : offset + 1 - const pageEnd = Math.min(offset + (rows.data?.rows.length ?? 0), total) - - // Schema-level "create" decision rides on the list response so the - // "New {schema}" button matches what `POST /entities` would actually - // accept. Default to hidden when the server hasn't reported a value - // yet — better to under-show than to render a button that 403s. - const canCreate = rows.data?.permissions?.create ?? false - - function toggleSort(fieldName: string) { - if (sortField !== fieldName) { - setSortField(fieldName) - setSortDir("asc") - } else if (sortDir === "asc") { - setSortDir("desc") - } else { - setSortField(null) - setSortDir("asc") - } - setOffset(0) - } - - return ( -
-
-
-
ENTITY
-

{schema}

-
- {total.toLocaleString()} total -
-
-
- {canCreate ? ( - - ) : null} -
-
- -
- {filterableFields.length > 0 ? ( - <> - -
- - contains - - setFilterInput(e.target.value)} - /> -
- - ) : null} - - -
- -
- - - - - {fields.map((f) => { - const active = sortField === f.name - const arrow = active ? (sortDir === "asc" ? " ↑" : " ↓") : "" - const ariaSort: "ascending" | "descending" | "none" = active - ? sortDir === "asc" - ? "ascending" - : "descending" - : "none" - const sortAnnouncement = active - ? `, sorted ${sortDir === "asc" ? "ascending" : "descending"}` - : ", not sorted" - return ( - - ) - })} - - - - - {rows.isLoading && !rows.data ? ( - - - - ) : null} - {rows.error && !rows.data ? ( - - - - ) : null} - {(rows.data?.rows ?? []).map((row) => { - // Per-row permissions ride along on the list envelope. Cedar - // already evaluated them against this entity's actual fields, - // so attribute-based policies (`@owner`, `@tenant`) get - // honored — no client-side guesswork. - const canUpdate = row.__permissions?.update ?? false - const canDelete = row.__permissions?.delete ?? false - return ( - - - {fields.map((f) => ( - - ))} - - - ) - })} - {!rows.isLoading && - !rows.error && - (rows.data?.rows ?? []).length === 0 ? ( - - - - ) : null} - -
id - - - Row actions -
-
- Loading… -
-
- rows.refetch()} - /> -
- {shortId(row.id)} - {renderCell(f, row[f.name])} -
- {canUpdate ? ( - - ) : null} - {canDelete ? ( - - Delete - - } - title={`Delete ${schema}?`} - description={ - <> - You are about to permanently delete{" "} - {shortId(row.id)}. - This cannot be undone. - - } - confirmLabel="Delete" - destructive - busy={remove.isPending} - onConfirm={() => remove.mutate(row.id)} - /> - ) : null} -
-
-
-

No {schema} records

-
Create one to get started, or relax your filter.
-
-
-
- -
-
- - {total === 0 ? "0 of 0" : `${pageStart}–${pageEnd} of ${total}`} - -
-
- - -
-
-
- ) -} - -function shortId(id: string): string { - // Trim long surreal-style IDs ("table:abc-def-…") to keep the column tight. - if (id.length <= 18) return id - return id.slice(0, 8) + "…" + id.slice(-6) -} - -function renderCell(field: FieldMeta, value: unknown): ReactNode { - // Keep list cells terse — use the formatter's narrower kind vocabulary. - const kind = - field.kind === "unknown" || field.kind === "composite" ? "text" : field.kind - const out = formatFieldValue(value, { kind: kind as never }) - if (value === null || value === undefined || value === "") { - return - } - return out -} diff --git a/crates/schema-forge-cli/templates/site/src/admin/field-renderer.tsx.jinja b/crates/schema-forge-cli/templates/site/src/admin/field-renderer.tsx.jinja deleted file mode 100644 index 82eadb0..0000000 --- a/crates/schema-forge-cli/templates/site/src/admin/field-renderer.tsx.jinja +++ /dev/null @@ -1,637 +0,0 @@ -// Generic field renderer shared across admin detail / edit / list views. -// -// Dispatches on `FieldMeta.kind` to the appropriate shadcn primitive. The -// read-only mode reuses the codegen'd `formatFieldValue`; the edit mode -// renders a controlled input that calls `onChange(next)` with the next -// value. Each kind is kept intentionally simple — fancier widgets -// (markdown preview, JSON linter, etc.) are a later polish pass. -import { useState, type ReactNode } from "react" -import { Input } from "@/components/ui/input" -import { RelationSelect } from "@/components/ui/relation-select" -import { formatFieldValue, type FieldDisplayHints } from "@/generated/formatters" -import type { FieldMeta } from "@/admin/api-client" -import { getCurrentRoles, hasAnyRole } from "@/lib/auth" - -/** - * Return true if the current user can read `field`. Fields without a - * `@field_access(read=[...])` annotation are visible to everyone. - */ -export function canReadField(field: FieldMeta): boolean { - if (!field.accessRead || field.accessRead.length === 0) return true - return hasAnyRole(field.accessRead, getCurrentRoles()) -} - -/** - * Return true if the current user can write `field`. Fields without a - * `@field_access(write=[...])` annotation are writable by anyone with - * read access. - */ -export function canWriteField(field: FieldMeta): boolean { - if (!canReadField(field)) return false - if (!field.accessWrite || field.accessWrite.length === 0) return true - return hasAnyRole(field.accessWrite, getCurrentRoles()) -} - -export type FieldRendererProps = { - field: FieldMeta - value: unknown - onChange?: (next: unknown) => void - readOnly?: boolean - /** Optional id propagated to the primary input control so callers can - * wire