Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 `<title>{project_name} — Admin</title>` for pre-hydration paint and text-mode browsers; `useDocumentTitle` refines per route at runtime (SC 2.4.2).
- **Descriptive page titles** — `index.html` ships `<title>{project_name}</title>` 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
Expand Down
7 changes: 4 additions & 3 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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."
Expand Down
35 changes: 2 additions & 33 deletions crates/schema-forge-cli/src/commands/site/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,8 +357,8 @@ fn build_plan(ctx: &SiteContext, renderer: &SiteRenderer) -> Result<Vec<FilePlan

// ---- Top-level login page (Preserve: users restyle freely) ----
//
// Login is mounted at `/login`, outside both the `/app` and `/admin`
// subtrees, because both need to fall through to it on auth failure.
// Login is mounted at `/login`, outside the `/app` subtree, because the
// app routes need to fall through to it on auth failure.
plan.push(preserve(
"src/pages/login.tsx",
renderer.render("src/pages/login.tsx", ctx)?,
Expand Down Expand Up @@ -427,40 +427,9 @@ fn build_plan(ctx: &SiteContext, renderer: &SiteRenderer) -> Result<Vec<FilePlan
));
}

// ---- `/admin/*`: generic schema-aware admin shell (Owned) ----
//
// The admin UI is schema-agnostic: it fetches `/api/v1/forge/schemas`
// at runtime and renders generic CRUD for every schema the authenticated
// user has permission to see. Because it's not per-user content, these
// files are Owned — users customize the admin by overriding the templates
// via `--templates-dir`, not by hand-editing the generated .tsx.
//
// Phase 2 ships these as placeholder scaffolds; Phase 3 fills them in.
for (rel, logical) in ADMIN_TEMPLATES {
plan.push(owned(rel, renderer.render(logical, ctx)?));
}

Ok(plan)
}

/// Generic admin shell files. Each tuple is `(output path, template name)`.
/// Templates live under `templates/site/src/admin/` and are schema-agnostic
/// (rendered once against the top-level `SiteContext`, not per-entity).
const ADMIN_TEMPLATES: &[(&str, &str)] = &[
("src/admin/layout.tsx", "src/admin/layout.tsx"),
("src/admin/schemas-index.tsx", "src/admin/schemas-index.tsx"),
("src/admin/entity-list.tsx", "src/admin/entity-list.tsx"),
("src/admin/entity-detail.tsx", "src/admin/entity-detail.tsx"),
("src/admin/entity-edit.tsx", "src/admin/entity-edit.tsx"),
("src/admin/api-client.ts", "src/admin/api-client.ts"),
(
"src/admin/field-renderer.tsx",
"src/admin/field-renderer.tsx",
),
("src/admin/users-list.tsx", "src/admin/users-list.tsx"),
("src/admin/users-edit.tsx", "src/admin/users-edit.tsx"),
];

fn owned(path: &str, contents: String) -> FilePlan {
FilePlan {
relative_path: PathBuf::from(path),
Expand Down
98 changes: 19 additions & 79 deletions crates/schema-forge-cli/templates/site/src/App.tsx.jinja
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -26,7 +24,6 @@ import {
FileText,
Folder,
Hash,
Layers,
ListTree,
Moon,
Search,
Expand All @@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -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 + "/")

Expand All @@ -163,32 +145,6 @@ function Sidebar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () =>
<div className="rail-title">{{ project_name }}</div>
</div>

{showAdminSection ? (
<div className="rail-section">
<div className="rail-section-label">
<span>Admin</span>
</div>
{showSchemasAdmin ? (
<Link
to="/admin"
className={"rail-link" + (location.pathname === "/admin" ? " active" : "")}
>
<Layers size={14} />
<span>Schemas</span>
</Link>
) : null}
{showUsersAdmin ? (
<Link
to="/admin/users"
className={"rail-link" + (isActive("/admin/users") ? " active" : "")}
>
<Users size={14} />
<span>Users</span>
</Link>
) : null}
</div>
) : null}

<div className="rail-section" style={ { flex: 1, minHeight: 0, overflow: "auto" }}>
<div className="rail-section-label">
<span>Entities</span>
Expand Down Expand Up @@ -255,9 +211,7 @@ function Sidebar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () =>
// ---------------------------------------------------------------------------

const CRUMB_LABELS: Record<string, string> = {
admin: "Admin",
app: "App",
users: "Users",
new: "New",
edit: "Edit",
}
Expand Down Expand Up @@ -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()],
Expand All @@ -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) {
Expand Down Expand Up @@ -407,12 +361,12 @@ function Topbar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => v
<button
type="button"
className="topbar-search"
onClick={() => navigate("/admin")}
aria-label="Open schema catalog"
onClick={() => navigate(`/app/${defaultEntity}`)}
aria-label="Go to entities"
>
<Search size={14} aria-hidden="true" />
<span className="grow" aria-hidden="true">
Schema catalog
Search
</span>
</button>

Expand Down Expand Up @@ -474,7 +428,7 @@ export default function App() {
path="*"
element={
<RequireAuth>
<Navigate to="/admin" replace />
<Navigate to={`/app/${defaultEntity}`} replace />
</RequireAuth>
}
/>
Expand All @@ -493,16 +447,12 @@ export default function App() {
<Routes>
<Route path="/login" element={<LoginPage />} />

{/* 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. */}
<Route
path="/"
element={
<RequireAuth>
<Navigate to="/admin" replace />
<Navigate to={`/app/${defaultEntity}`} replace />
</RequireAuth>
}
/>
Expand All @@ -519,16 +469,6 @@ export default function App() {
}
/>
))}

{/* /admin/* — generic schema-aware admin shell (runtime-dynamic). */}
<Route
path="/admin/*"
element={
<RequireAuth>
<AdminLayout />
</RequireAuth>
}
/>
</Routes>
</main>
{/* richColors is intentionally disabled — sonner's tinted variants
Expand Down
Loading
Loading