Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

130
apps/demo/README.md Normal file
View File

@@ -0,0 +1,130 @@
# @suaveplan/demo
Unified Bun + Hono server with a React 19 SPA front-end and a JSON API
in one process. Demonstrates locale-prefix routing across five base
locales, `@suaveplan/api-plugin-auth`-driven Better-Auth authentication
backed by SurrealDB, and React + Tailwind v4 styling.
## What's wired up
- **Server** — single Bun process running a Hono app on port `3300`
(override with `PORT=…`). The Hono instance is the one owned by a
`SuaveApp` from `@suaveplan/api`. `/api/health` is registered by
`registerHealthRoutes`; `/api/auth/*` is mounted by
`@suaveplan/api-plugin-auth` (9 OpenAPI routes + the Better-Auth
catch-all); `/api/me` is a demo convenience that calls
`authService.getSession(headers)`; everything else falls through to
the Vite-built client bundle in `dist/client/`, with `index.html`
fallback so the client router owns deep links.
- **Auth** — the canonical genesis pipeline. The Hono app is assembled
via `defineApp()` from `@suaveplan/api`, with `createAuthPlugin(...)`
from `@suaveplan/api-plugin-auth` registered as a plugin. The auth
plugin's 6-step `onInit` applies the bundled 17-table Better-Auth
SurrealQL schema, constructs the real Better-Auth instance backed by
`betterAuthSurrealAdapter` (from `@suaveplan/better-auth-surrealdb`,
the Better-Auth v1.6 `DBAdapterInstance` factory), registers the
typed `authService` on the host `ServiceRegistry`, mounts the 9
OpenAPI routes plus the Better-Auth catch-all under `/api/auth/*`,
and installs the global session-resolver middleware so every request
gets a typed `Principal` on its Hono context. A small inline
`demo-db-seed` plugin (in `src/server/auth.ts`) drops the live
`SurrealAdapter` into `ServiceRegistry["db"]` during `onRegister` so
the auth plugin can resolve it during `onInit`. Backing store is an
**external SurrealDB** reached via `SURREAL_URL` (default
`ws://localhost:8000`), namespace `SURREAL_NS` (default `demo`),
database `SURREAL_DB` (default `demo`), credentials `SURREAL_USER` /
`SURREAL_PASS` (default `root` / `root`).
- **Client** — React 19 + Vite + `@tailwindcss/vite` for Tailwind v4.
Routing via `react-router-dom@7` with a locale-prefix design. The
app root composes `<ReactUiProvider>` (from `@suaveplan/react-ui`)
around the entire tree, seeded with a singleton `I18nInstance`
(`createI18n` from `@suaveplan/i18n`) and the chassis `lightTheme`
plugin. `<ReactUiProvider>` internally wraps
`<TranslationProvider>` → `<ThemeProvider>` → `<PackRegistryProvider>`
and auto-registers the matching chassis locale bundle on mount /
language change, so any react-ui primitive that calls
`useTranslation(...)` finds a populated catalog. Form pages
(`/auth/login`, `/auth/register`, `/auth/forgot-password`,
`/user/settings`) compose `<Card>`, `<Field>`, `<FieldLabel>`,
`<Input>`, `<Button>`, `<Alert>` from the chassis — no hand-rolled
Tailwind on those primitives. Demo-specific copy continues to flow
through a typed `LocaleProvider` so the page-level message catalogs
stay co-located and statically checked; the i18n instance is driven
to the matching BCP47 variant (`en` → `en-GB`, `zh` → `zh-CN`,
`ar` → `ar-AE`, `cy` → `cy-GB`, `ru` → `ru-RU`) via
`syncDemoLocale(base)` from a `useEffect`.
## Locale-prefix routing
Five base locales: `en`, `zh`, `ar`, `cy`, `ru`. Region variants
(e.g. `en-GB`, `zh-CN`, `ar-EG`) parse case-insensitively and
canonicalise to lowercase-base + UPPERCASE-region. Anything else
renders the `NotFoundPage` with a language picker.
- `/` → `Navigate` to `/<detected>/home` (uses `navigator.languages`).
- `/<locale>/home`
- `/<locale>/about-us`
- `/<locale>/contact-us`
- `/<locale>/privacy-policy`
- `/<locale>/terms-of-service`
- `/<locale>/auth/login`
- `/<locale>/auth/register`
- `/<locale>/auth/forgot-password`
- `/<locale>/auth/logout`
- `/<locale>/user/control-panel` *(gated — `RequireAuth`)*
- `/<locale>/user/settings` *(gated — `RequireAuth`)*
- `/<locale>/admin/control-panel` *(gated — `RequireRole admin`)*
- `/<locale>/admin/settings` *(gated — `RequireRole admin`)*
`ar` flips `<html dir="rtl">`. The `<LocaleSwitcher>` rewrites the URL
in place when a user changes language.
## Running the demo
The demo requires a reachable SurrealDB instance. The simplest path:
```bash
docker run -d --name demo-surreal -p 8000:8000 \
surrealdb/surrealdb:latest start --user root --pass root --bind 0.0.0.0:8000
```
Then, from the repo root:
```bash
bun install # workspace deps
bunx turbo build --filter=@suaveplan/demo # build api/auth deps + dist/client
bun run --filter=@suaveplan/demo dev # boot the server on :3300
```
Visit `http://localhost:3300` — it should redirect to `/en/home` (or
your browser's preferred locale if it matches one of the five).
Smoke endpoints:
```bash
curl http://localhost:3300/api/health
curl http://localhost:3300/api/me
curl http://localhost:3300/api/auth/get-session
```
End-to-end sign-up roundtrip — produces a 200 with a Better-Auth
session token + user record:
```bash
curl -X POST http://localhost:3300/api/auth/sign-up/email \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"hunter2","name":"Alice"}'
```
Env overrides (all optional):
| Variable | Default |
| ---------------------- | ------------------------- |
| `PORT` | `3300` |
| `SURREAL_URL` | `ws://localhost:8000` |
| `SURREAL_NS` | `demo` |
| `SURREAL_DB` | `demo` |
| `SURREAL_USER` | `root` |
| `SURREAL_PASS` | `root` |
| `BETTER_AUTH_URL` | `http://localhost:3300` |
| `BETTER_AUTH_SECRET` | demo placeholder — set in prod |

View File

@@ -0,0 +1,86 @@
/**
* Smoke test — boots the built app and asserts:
*
* 1. `/api/health` returns 200 JSON with `ok: true`.
* 2. `/` redirects to `/<locale>/home`.
* 3. Each public page (`/en/home`, `/en/about-us`,
* `/en/contact-us`, `/en/privacy-policy`, `/en/terms-of-service`)
* returns 200 with an HTML body containing the SPA root element.
*
* Run with `bun run e2e/smoke.test.ts`. Requires `bun run build`
* to have produced `dist/client/index.html` first.
*/
import { spawn } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
const PORT = 3399;
const BASE = `http://localhost:${PORT}`;
async function waitForReady(): Promise<void> {
for (let i = 0; i < 30; i++) {
try {
const res = await fetch(`${BASE}/api/health`);
if (res.ok) return;
} catch {
// not up yet
}
await delay(500);
}
throw new Error("server failed to start within 15s");
}
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(`SMOKE FAIL: ${message}`);
}
async function main(): Promise<void> {
const child = spawn("bun", ["run", "src/server/index.ts"], {
env: { ...process.env, PORT: String(PORT), NODE_ENV: "test" },
stdio: ["ignore", "pipe", "pipe"],
});
try {
await waitForReady();
const health = await (await fetch(`${BASE}/api/health`)).json();
// `@suaveplan/api`'s `registerHealthRoutes` emits
// `{ status, version, state, timestamp }` — accept either the new
// shape (`status: "healthy"`) or the legacy `{ ok: true }` shape so
// the smoke remains forward-compatible if the route is rewritten.
const healthy = health.status === "healthy" || health.ok === true;
assert(healthy, `health probe not healthy: ${JSON.stringify(health)}`);
const root = await fetch(`${BASE}/`, { redirect: "manual" });
// Server sends the SPA shell, client-side router handles the redirect.
// We accept either a 200 (shell) or a 3xx redirect.
assert(root.status === 200 || (root.status >= 300 && root.status < 400),
`/ expected 200 or 3xx, got ${root.status}`);
const paths = [
"/en/home",
"/en/about-us",
"/en/contact-us",
"/en/privacy-policy",
"/en/terms-of-service",
];
for (const path of paths) {
const res = await fetch(`${BASE}${path}`);
assert(res.ok, `${path} returned ${res.status}`);
const body = await res.text();
assert(body.includes('id="root"'), `${path} body missing #root`);
}
// Auth endpoint reachable.
const session = await fetch(`${BASE}/api/auth/get-session`);
assert(session.status === 200, `/api/auth/get-session status ${session.status}`);
console.log("SMOKE OK");
} finally {
child.kill("SIGTERM");
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

12
apps/demo/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Suaveplan Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

37
apps/demo/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "@suaveplan/demo",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run src/server/index.ts",
"build": "vite build",
"start": "bun run src/server/index.ts",
"smoke": "bun run e2e/smoke.test.ts",
"typecheck": "bunx tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@suaveplan/api": "workspace:^",
"@suaveplan/api-plugin": "workspace:^",
"@suaveplan/api-plugin-auth": "workspace:^",
"@suaveplan/better-auth-surrealdb": "workspace:^",
"@suaveplan/i18n": "workspace:^",
"@suaveplan/react-ui": "workspace:^",
"@suaveplan/surrealdb": "workspace:^",
"better-auth": "catalog:auth",
"hono": "catalog:",
"react": "catalog:react",
"react-dom": "catalog:react",
"react-router-dom": "catalog:react"
},
"devDependencies": {
"@tailwindcss/vite": "catalog:build",
"@types/node": "catalog:",
"@types/react": "catalog:react",
"@types/react-dom": "catalog:react",
"@vitejs/plugin-react": "catalog:build",
"tailwindcss": "catalog:build",
"typescript": "catalog:",
"vite": "catalog:build"
}
}

View File

@@ -0,0 +1,139 @@
/**
* Demo app shell.
*
* Top-level structure:
*
* - `BrowserRouter`
* - `SessionProvider` — polls `/api/auth/get-session`
* - `ReactUiProvider` — composes
* `<TranslationProvider>` → `<ThemeProvider>` →
* `<PackRegistryProvider>` for the entire app tree, seeded with
* the demo's singleton `I18nInstance` and the chassis
* `lightTheme` plugin from `@suaveplan/react-ui`.
* - `Routes`
* - `/` → redirect to `/<detected>/home`
* - `/:locale/*` → LocaleProvider + Layout
* - everything else → NotFoundPage in default-locale context
*/
import { lightTheme, ReactUiProvider } from "@suaveplan/react-ui";
import React from "react";
import {
BrowserRouter,
Navigate,
Outlet,
Route,
Routes,
} from "react-router-dom";
import { Layout } from "./components/layout.js";
import { getDemoI18n } from "./i18n/i18n-instance.js";
import { LocaleProvider } from "./i18n/locale-context.js";
import { DEFAULT_LOCALE, parseLocaleSegment } from "./i18n/locales.js";
import { RequireAuth, RequireRole } from "./lib/guards.js";
import { SessionProvider } from "./lib/session-context.js";
import {
AdminControlPanelPage,
AdminSettingsPage,
UserControlPanelPage,
UserSettingsPage,
} from "./pages/account-pages.js";
import {
ForgotPasswordPage,
LoginPage,
LogoutPage,
RegisterPage,
} from "./pages/auth-pages.js";
import { NotFoundPage } from "./pages/not-found-page.js";
import {
AboutPage,
ContactPage,
HomePage,
PrivacyPage,
TermsPage,
} from "./pages/public-pages.js";
function detectLocale(): string {
if (typeof navigator === "undefined") return DEFAULT_LOCALE;
for (const candidate of navigator.languages ?? [navigator.language]) {
const parsed = parseLocaleSegment(candidate);
if (parsed !== null) return parsed.base;
}
return DEFAULT_LOCALE;
}
function RootRedirect(): React.JSX.Element {
return <Navigate to={`/${detectLocale()}/home`} replace />;
}
function LocalisedShell(): React.JSX.Element {
return (
<LocaleProvider>
<Outlet />
</LocaleProvider>
);
}
export function App(): React.JSX.Element {
const i18n = getDemoI18n();
return (
<BrowserRouter>
<SessionProvider>
<ReactUiProvider
i18n={i18n}
themes={[lightTheme]}
defaultTheme="light"
>
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/:locale" element={<LocalisedShell />}>
<Route element={<Layout />}>
<Route index element={<Navigate to="home" replace />} />
<Route path="home" element={<HomePage />} />
<Route path="about-us" element={<AboutPage />} />
<Route path="contact-us" element={<ContactPage />} />
<Route path="privacy-policy" element={<PrivacyPage />} />
<Route path="terms-of-service" element={<TermsPage />} />
<Route path="auth/login" element={<LoginPage />} />
<Route path="auth/register" element={<RegisterPage />} />
<Route path="auth/forgot-password" element={<ForgotPasswordPage />} />
<Route path="auth/logout" element={<LogoutPage />} />
<Route
path="user/control-panel"
element={
<RequireAuth>
<UserControlPanelPage />
</RequireAuth>
}
/>
<Route
path="user/settings"
element={
<RequireAuth>
<UserSettingsPage />
</RequireAuth>
}
/>
<Route
path="admin/control-panel"
element={
<RequireRole role="admin">
<AdminControlPanelPage />
</RequireRole>
}
/>
<Route
path="admin/settings"
element={
<RequireRole role="admin">
<AdminSettingsPage />
</RequireRole>
}
/>
<Route path="*" element={<NotFoundPage />} />
</Route>
</Route>
</Routes>
</ReactUiProvider>
</SessionProvider>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,89 @@
/**
* Shared layout — top nav with locale picker + auth menu, content
* area, footer. Renders an `<Outlet>` for nested routes.
*/
import React from "react";
import { Link, NavLink, Outlet } from "react-router-dom";
import { LOCALE_ENTRIES, useLocale } from "../i18n/locale-context.js";
import type { BaseLocale } from "../i18n/locales.js";
import { useSession } from "../lib/session-context.js";
function LocaleSwitcher(): React.JSX.Element {
const { base, setLocale, availableLocales } = useLocale();
return (
<select
aria-label="Language"
value={base}
onChange={(e) => setLocale(e.target.value as BaseLocale)}
className="rounded-md border border-[color:var(--color-border)] bg-[color:var(--color-surface)] px-2 py-1 text-sm"
data-testid="locale-switcher"
>
{availableLocales.map((loc) => (
<option key={loc} value={loc}>
{LOCALE_ENTRIES[loc].nativeName}
</option>
))}
</select>
);
}
function NavItem({ to, label }: { readonly to: string; readonly label: string }): React.JSX.Element {
return (
<NavLink
to={to}
className={({ isActive }) =>
`rounded-md px-3 py-2 text-sm font-medium transition ${
isActive
? "bg-[color:var(--color-accent)] text-[color:var(--color-accent-fg)]"
: "hover:bg-[color:var(--color-surface)]"
}`
}
>
{label}
</NavLink>
);
}
export function Layout(): React.JSX.Element {
const { base, messages } = useLocale();
const { session } = useSession();
const p = (sub: string): string => `/${base}${sub}`;
return (
<div className="flex min-h-screen flex-col">
<header className="border-b border-[color:var(--color-border)] bg-[color:var(--color-surface)]">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-4 px-4 py-3">
<Link to={p("/home")} className="text-lg font-semibold no-underline">
{messages.siteName}
</Link>
<nav className="flex flex-wrap items-center gap-1">
<NavItem to={p("/home")} label={messages.nav.home} />
<NavItem to={p("/about-us")} label={messages.nav.about} />
<NavItem to={p("/contact-us")} label={messages.nav.contact} />
<NavItem to={p("/privacy-policy")} label={messages.nav.privacy} />
<NavItem to={p("/terms-of-service")} label={messages.nav.terms} />
</nav>
<div className="ms-auto flex items-center gap-2">
<LocaleSwitcher />
{session === null ? (
<>
<NavItem to={p("/auth/login")} label={messages.nav.login} />
<NavItem to={p("/auth/register")} label={messages.nav.register} />
</>
) : (
<>
<NavItem to={p("/user/control-panel")} label={messages.nav.userPanel} />
<NavItem to={p("/auth/logout")} label={messages.nav.logout} />
</>
)}
</div>
</div>
</header>
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-8">
<Outlet />
</main>
<footer className="border-t border-[color:var(--color-border)] py-4 text-center text-xs text-[color:var(--color-muted)]">
{messages.siteName} • {base}
</footer>
</div>
);
}

View File

@@ -0,0 +1,29 @@
/**
* Re-usable page shell — heading + lead paragraph block consumed by
* every public-information page so titles, headings, and the lead
* line are kept consistent.
*/
import React from "react";
import { useDocumentTitle } from "../i18n/locale-context.js";
export interface PageShellProps {
readonly title: string;
readonly heading: string;
readonly lead: string;
readonly children?: React.ReactNode;
}
export function PageShell({ title, heading, lead, children }: PageShellProps): React.JSX.Element {
useDocumentTitle(title);
return (
<article className="mx-auto max-w-3xl">
<h1 className="text-3xl font-semibold" data-testid="page-heading">
{heading}
</h1>
<p className="mt-3 text-base leading-relaxed text-[color:var(--color-muted)]" data-testid="page-lead">
{lead}
</p>
{children !== undefined ? <div className="mt-6">{children}</div> : null}
</article>
);
}

View File

@@ -0,0 +1,67 @@
/**
* Build the demo's `I18nInstance`.
*
* The instance is consumed by `<ReactUiProvider>` and any
* `@suaveplan/react-ui` primitive that calls `useTranslation()` (e.g.
* `<Button>` for icon-only-button fallback, `<Input>` for placeholder
* resolution).
*
* Locale strategy: the URL carries the language subtag only (`/en/home`,
* `/zh/home`, etc.). The chassis bundles in `@suaveplan/react-ui` are
* keyed by full BCP47 — `en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU`. We
* therefore canonicalise each demo base locale to its closest BCP47
* variant and use THAT as the i18n locale. The demo's typed `Messages`
* catalog continues to be consumed via `useLocale()` keyed by base, so
* the demo never depends on react-i18n's `t(key)` for its own copy.
*
* The chassis bundles themselves are auto-registered by
* `<ReactUiProvider>` (its internal `ReactUiBundleLoader` effect imports
* the matching JSON and merges it into the catalog on mount / language
* change), so this module deliberately does NOT preload them.
*/
import { createI18n, type I18nInstance } from "@suaveplan/i18n/plugin";
import type { BaseLocale } from "./locales.js";
export type DemoBcp47 = "en-GB" | "zh-CN" | "ar-AE" | "cy-GB" | "ru-RU";
/** Map URL base locale -> canonical BCP47 variant fed to the i18n core. */
export const BASE_TO_BCP47: Readonly<Record<BaseLocale, DemoBcp47>> = {
en: "en-GB",
zh: "zh-CN",
ar: "ar-AE",
cy: "cy-GB",
ru: "ru-RU",
};
/**
* Singleton i18n instance for the demo. Created lazily so the i18n core
* (which is module-side-effect free) does not run during static module
* graph evaluation in tests or SSR snapshots.
*/
let cached: I18nInstance | undefined;
export function getDemoI18n(): I18nInstance {
if (cached !== undefined) return cached;
// Use the default `string` generic so the cached singleton matches
// `<ReactUiProvider>`'s `i18n: I18nInstance` (without the
// narrowed locale generic).
const instance: I18nInstance = createI18n({
defaultLocale: BASE_TO_BCP47.en as string,
fallbackLocale: BASE_TO_BCP47.en as string,
});
cached = instance;
return instance;
}
/**
* Drive the i18n instance to the BCP47 variant matching the URL base.
* Safe to call from a `useEffect` — `changeLanguage(...)` short-circuits
* when the locale already matches.
*/
export async function syncDemoLocale(base: BaseLocale): Promise<void> {
const i18n = getDemoI18n();
const target = BASE_TO_BCP47[base];
if (i18n.locale !== target) {
await i18n.changeLanguage(target);
}
}

View File

@@ -0,0 +1,113 @@
/**
* Typed locale context for the demo app.
*
* Holds the resolved `BaseLocale`, the canonical BCP47 tag (e.g. `en`,
* `en-GB`, `zh-CN`), the active `Messages` catalog, and a `setLocale`
* setter that rewrites the URL prefix and updates `<html lang>` +
* `<html dir>` so RTL flips correctly for Arabic.
*
* Kept deliberately small — full @suaveplan/i18n integration (with
* backends, formatters, plugin registry) is overkill for a five-page
* demo. The provider shape mirrors react-i18n's `useLocale` /
* `useI18n` so a future swap is mechanical.
*/
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
} from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { syncDemoLocale } from "./i18n-instance.js";
import {
BASE_LOCALES,
type BaseLocale,
DEFAULT_LOCALE,
LOCALE_ENTRIES,
parseLocaleSegment,
RTL_BASE_LOCALES,
} from "./locales.js";
import { CATALOGS, type Messages } from "./messages.js";
export interface LocaleContextValue {
readonly base: BaseLocale;
readonly canonical: string;
readonly direction: "ltr" | "rtl";
readonly messages: Messages;
readonly setLocale: (next: BaseLocale | string) => void;
readonly availableLocales: readonly BaseLocale[];
}
const LocaleContext = createContext<LocaleContextValue | null>(null);
export function useLocale(): LocaleContextValue {
const ctx = useContext(LocaleContext);
if (ctx === null) {
throw new Error(
"useLocale must be used inside <LocaleProvider> — wrap your routes."
);
}
return ctx;
}
export function useMessages(): Messages {
return useLocale().messages;
}
export interface LocaleProviderProps {
readonly children: React.ReactNode;
}
export function LocaleProvider({ children }: LocaleProviderProps): React.JSX.Element {
const params = useParams<{ locale?: string }>();
const navigate = useNavigate();
const location = useLocation();
const parsed = parseLocaleSegment(params.locale);
const base: BaseLocale = parsed?.base ?? DEFAULT_LOCALE;
const canonical = parsed?.canonical ?? base;
const direction = RTL_BASE_LOCALES.has(base) ? "rtl" : "ltr";
const messages = CATALOGS[base];
useEffect(() => {
document.documentElement.lang = canonical;
document.documentElement.dir = direction;
document.title = messages.home.title; // updated per page via useDocumentTitle
// Drive the singleton I18nInstance to the BCP47 variant matching the
// current URL base so any @suaveplan/react-ui chassis lookup sees a
// catalog populated for the active locale.
void syncDemoLocale(base);
}, [base, canonical, direction, messages]);
const setLocale = useCallback(
(next: BaseLocale | string) => {
// Strip the existing locale segment then prepend the new one.
const path = location.pathname.replace(/^\/[^/]+/, "");
navigate(`/${next}${path === "" ? "/home" : path}${location.search}${location.hash}`);
},
[location.hash, location.pathname, location.search, navigate]
);
const value = useMemo<LocaleContextValue>(
() => ({
base,
canonical,
direction,
messages,
setLocale,
availableLocales: BASE_LOCALES,
}),
[base, canonical, direction, messages, setLocale]
);
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
}
export function useDocumentTitle(title: string): void {
useEffect(() => {
document.title = title;
}, [title]);
}
export { LOCALE_ENTRIES };

View File

@@ -0,0 +1,49 @@
/**
* Locale registry for the demo app.
*
* Five canonical base locales — `en`, `zh`, `ar`, `cy`, `ru` — plus a
* permissive `<base>-<region>` resolver so URLs like `/en-GB/home` or
* `/zh-CN/home` collapse onto the base catalog. RTL is tracked on the
* base code.
*/
export const BASE_LOCALES = ["en", "zh", "ar", "cy", "ru"] as const;
export type BaseLocale = (typeof BASE_LOCALES)[number];
export const DEFAULT_LOCALE: BaseLocale = "en";
export const RTL_BASE_LOCALES: ReadonlySet<BaseLocale> = new Set(["ar"]);
export interface LocaleEntry {
readonly base: BaseLocale;
readonly name: string;
readonly nativeName: string;
readonly direction: "ltr" | "rtl";
}
export const LOCALE_ENTRIES: Readonly<Record<BaseLocale, LocaleEntry>> = {
en: { base: "en", name: "English", nativeName: "English", direction: "ltr" },
zh: { base: "zh", name: "Chinese", nativeName: "中文", direction: "ltr" },
ar: { base: "ar", name: "Arabic", nativeName: "العربية", direction: "rtl" },
cy: { base: "cy", name: "Welsh", nativeName: "Cymraeg", direction: "ltr" },
ru: { base: "ru", name: "Russian", nativeName: "Русский", direction: "ltr" },
};
/**
* Normalise a raw URL segment into a canonical `<base>` or
* `<base>-<REGION>` BCP47 tag, or `null` if the base does not match
* one of {@link BASE_LOCALES}.
*/
export function parseLocaleSegment(segment: string | undefined): {
readonly base: BaseLocale;
readonly canonical: string;
} | null {
if (!segment) return null;
const [rawBase, rawRegion] = segment.toLowerCase().split("-");
const base = (BASE_LOCALES as readonly string[]).includes(rawBase ?? "")
? (rawBase as BaseLocale)
: null;
if (!base) return null;
if (!rawRegion) return { base, canonical: base };
const region = rawRegion.toUpperCase();
return { base, canonical: `${base}-${region}` };
}

View File

@@ -0,0 +1,337 @@
/**
* Static message catalogs for the demo app.
*
* `en` is fully translated. The other four locales (`zh`, `ar`, `cy`,
* `ru`) ship with canonical translations for page titles, headings,
* and the lead paragraph, and fall back to the English copy for
* secondary microcopy. Every key has a non-empty value in every
* locale so render-time fallback never silently picks up `en`.
*/
import type { BaseLocale } from "./locales.js";
export interface Messages {
// Site chrome
readonly siteName: string;
readonly nav: {
readonly home: string;
readonly about: string;
readonly contact: string;
readonly privacy: string;
readonly terms: string;
readonly login: string;
readonly register: string;
readonly logout: string;
readonly userPanel: string;
readonly userSettings: string;
readonly adminPanel: string;
readonly adminSettings: string;
};
// Pages
readonly home: { readonly title: string; readonly heading: string; readonly lead: string };
readonly about: { readonly title: string; readonly heading: string; readonly lead: string };
readonly contact: { readonly title: string; readonly heading: string; readonly lead: string };
readonly privacy: { readonly title: string; readonly heading: string; readonly lead: string };
readonly terms: { readonly title: string; readonly heading: string; readonly lead: string };
readonly login: {
readonly title: string;
readonly heading: string;
readonly lead: string;
readonly emailLabel: string;
readonly passwordLabel: string;
readonly submit: string;
readonly linkToRegister: string;
readonly linkToForgot: string;
};
readonly register: {
readonly title: string;
readonly heading: string;
readonly lead: string;
readonly emailLabel: string;
readonly passwordLabel: string;
readonly confirmLabel: string;
readonly submit: string;
readonly linkToLogin: string;
};
readonly forgot: {
readonly title: string;
readonly heading: string;
readonly lead: string;
readonly emailLabel: string;
readonly submit: string;
readonly sentNotice: string;
readonly linkToLogin: string;
};
readonly logout: {
readonly title: string;
readonly heading: string;
readonly lead: string;
};
readonly userPanel: { readonly title: string; readonly heading: string; readonly lead: string };
readonly userSettings: { readonly title: string; readonly heading: string; readonly lead: string };
readonly adminPanel: { readonly title: string; readonly heading: string; readonly lead: string };
readonly adminSettings: { readonly title: string; readonly heading: string; readonly lead: string };
readonly notFound: { readonly title: string; readonly heading: string; readonly lead: string };
readonly unauthorized: { readonly title: string; readonly heading: string; readonly lead: string };
}
const en: Messages = {
siteName: "Suaveplan Demo",
nav: {
home: "Home",
about: "About us",
contact: "Contact us",
privacy: "Privacy policy",
terms: "Terms of service",
login: "Log in",
register: "Register",
logout: "Log out",
userPanel: "User panel",
userSettings: "User settings",
adminPanel: "Admin panel",
adminSettings: "Admin settings",
},
home: {
title: "Suaveplan Demo — Home",
heading: "Welcome to the Suaveplan Demo",
lead: "A unified Bun + Hono server with React-rendered pages and a JSON API in one process, wired up with @suaveplan/react-ui and @suaveplan/react-i18n.",
},
about: {
title: "About us",
heading: "About Suaveplan",
lead: "Suaveplan is a bottom-up rebuild of an enterprise platform, packaged as a Turborepo monorepo of focused libraries.",
},
contact: {
title: "Contact us",
heading: "Get in touch",
lead: "Drop us a line through any of the channels listed below. We read every message.",
},
privacy: {
title: "Privacy policy",
heading: "Privacy policy",
lead: "This demo collects no personal data beyond what the better-auth memory adapter holds for the lifetime of the process.",
},
terms: {
title: "Terms of service",
heading: "Terms of service",
lead: "This is a demo application provided as-is for illustrative purposes; no warranty is offered or implied.",
},
login: {
title: "Log in",
heading: "Log in to your account",
lead: "Sign in with your email address and password.",
emailLabel: "Email",
passwordLabel: "Password",
submit: "Log in",
linkToRegister: "Need an account? Register",
linkToForgot: "Forgot your password?",
},
register: {
title: "Create an account",
heading: "Create your account",
lead: "Sign up with your email address and a password of at least 6 characters.",
emailLabel: "Email",
passwordLabel: "Password",
confirmLabel: "Confirm password",
submit: "Create account",
linkToLogin: "Already have an account? Log in",
},
forgot: {
title: "Forgot password",
heading: "Reset your password",
lead: "Enter your email and we'll send you a password-reset link.",
emailLabel: "Email",
submit: "Send reset link",
sentNotice: "If an account exists for that email, a reset link is on its way.",
linkToLogin: "Back to log in",
},
logout: {
title: "Logging out",
heading: "Logging you out…",
lead: "Clearing your session — you'll be redirected home in a moment.",
},
userPanel: {
title: "User control panel",
heading: "Control panel",
lead: "An overview of your account, sessions, and recent activity.",
},
userSettings: {
title: "User settings",
heading: "Settings",
lead: "Manage your profile, locale preference, and security options.",
},
adminPanel: {
title: "Admin control panel",
heading: "Admin control panel",
lead: "Administrative dashboard — restricted to users with the admin role.",
},
adminSettings: {
title: "Admin settings",
heading: "Admin settings",
lead: "Configure global application settings — restricted to admins.",
},
notFound: {
title: "Not found",
heading: "404 — page not found",
lead: "The page you asked for does not exist. Try one of the languages below or return home.",
},
unauthorized: {
title: "Sign in required",
heading: "Sign in required",
lead: "You need to be signed in to view this page.",
},
};
// Lightweight translations: page-title, heading, lead per locale.
// Secondary microcopy falls through to English so the demo always
// renders intelligibly while making the per-locale shape obvious.
const zh: Messages = {
...en,
nav: {
...en.nav,
home: "首页",
about: "关于我们",
contact: "联系我们",
privacy: "隐私政策",
terms: "服务条款",
login: "登录",
register: "注册",
logout: "退出",
userPanel: "用户面板",
userSettings: "用户设置",
adminPanel: "管理面板",
adminSettings: "管理设置",
},
siteName: "Suaveplan 演示",
home: {
title: "Suaveplan 演示 — 首页",
heading: "欢迎来到 Suaveplan 演示",
lead: "在单一进程中提供统一的 Bun + Hono 服务器、React 渲染页面与 JSON API,使用 @suaveplan/react-ui 和 @suaveplan/react-i18n。",
},
about: { title: "关于我们", heading: "关于 Suaveplan", lead: en.about.lead },
contact: { title: "联系我们", heading: "联系我们", lead: en.contact.lead },
privacy: { title: "隐私政策", heading: "隐私政策", lead: en.privacy.lead },
terms: { title: "服务条款", heading: "服务条款", lead: en.terms.lead },
login: { ...en.login, title: "登录", heading: "登录您的帐户", lead: "使用电子邮件地址和密码登录。" },
register: { ...en.register, title: "创建帐户", heading: "创建您的帐户", lead: "使用电子邮件和至少 6 个字符的密码注册。" },
forgot: { ...en.forgot, title: "忘记密码", heading: "重置密码", lead: "输入您的电子邮件,我们将发送重置链接。" },
logout: { title: "退出登录", heading: "正在退出登录...", lead: en.logout.lead },
userPanel: { title: "用户控制面板", heading: "控制面板", lead: en.userPanel.lead },
userSettings: { title: "用户设置", heading: "设置", lead: en.userSettings.lead },
adminPanel: { title: "管理控制面板", heading: "管理控制面板", lead: en.adminPanel.lead },
adminSettings: { title: "管理设置", heading: "管理设置", lead: en.adminSettings.lead },
notFound: { title: "未找到", heading: "404 — 找不到页面", lead: en.notFound.lead },
unauthorized: { title: "需要登录", heading: "需要登录", lead: en.unauthorized.lead },
};
const ar: Messages = {
...en,
nav: {
...en.nav,
home: "الرئيسية",
about: "من نحن",
contact: "اتصل بنا",
privacy: "سياسة الخصوصية",
terms: "شروط الخدمة",
login: "تسجيل الدخول",
register: "تسجيل",
logout: "تسجيل الخروج",
userPanel: "لوحة المستخدم",
userSettings: "إعدادات المستخدم",
adminPanel: "لوحة الإدارة",
adminSettings: "إعدادات الإدارة",
},
siteName: "عرض Suaveplan التوضيحي",
home: { title: "Suaveplan — الرئيسية", heading: "مرحبًا بك في عرض Suaveplan التوضيحي", lead: en.home.lead },
about: { title: "من نحن", heading: "حول Suaveplan", lead: en.about.lead },
contact: { title: "اتصل بنا", heading: "تواصل معنا", lead: en.contact.lead },
privacy: { title: "سياسة الخصوصية", heading: "سياسة الخصوصية", lead: en.privacy.lead },
terms: { title: "شروط الخدمة", heading: "شروط الخدمة", lead: en.terms.lead },
login: { ...en.login, title: "تسجيل الدخول", heading: "تسجيل الدخول إلى حسابك", lead: "سجّل الدخول باستخدام بريدك الإلكتروني وكلمة المرور." },
register: { ...en.register, title: "إنشاء حساب", heading: "إنشاء حسابك", lead: "سجّل ببريدك الإلكتروني وكلمة مرور لا تقل عن 6 أحرف." },
forgot: { ...en.forgot, title: "نسيت كلمة المرور", heading: "إعادة تعيين كلمة المرور", lead: en.forgot.lead },
logout: { title: "جاري تسجيل الخروج", heading: "جاري تسجيل خروجك…", lead: en.logout.lead },
userPanel: { title: "لوحة تحكم المستخدم", heading: "لوحة التحكم", lead: en.userPanel.lead },
userSettings: { title: "إعدادات المستخدم", heading: "الإعدادات", lead: en.userSettings.lead },
adminPanel: { title: "لوحة تحكم الإدارة", heading: "لوحة تحكم الإدارة", lead: en.adminPanel.lead },
adminSettings: { title: "إعدادات الإدارة", heading: "إعدادات الإدارة", lead: en.adminSettings.lead },
notFound: { title: "غير موجود", heading: "404 — الصفحة غير موجودة", lead: en.notFound.lead },
unauthorized: { title: "تسجيل الدخول مطلوب", heading: "تسجيل الدخول مطلوب", lead: en.unauthorized.lead },
};
const cy: Messages = {
...en,
nav: {
...en.nav,
home: "Hafan",
about: "Amdanom ni",
contact: "Cysylltwch â ni",
privacy: "Polisi preifatrwydd",
terms: "Telerau gwasanaeth",
login: "Mewngofnodi",
register: "Cofrestru",
logout: "Allgofnodi",
userPanel: "Panel defnyddiwr",
userSettings: "Gosodiadau defnyddiwr",
adminPanel: "Panel gweinyddol",
adminSettings: "Gosodiadau gweinyddol",
},
siteName: "Demo Suaveplan",
home: { title: "Suaveplan — Hafan", heading: "Croeso i Demo Suaveplan", lead: en.home.lead },
about: { title: "Amdanom ni", heading: "Am Suaveplan", lead: en.about.lead },
contact: { title: "Cysylltwch â ni", heading: "Cysylltu â ni", lead: en.contact.lead },
privacy: { title: "Polisi preifatrwydd", heading: "Polisi preifatrwydd", lead: en.privacy.lead },
terms: { title: "Telerau gwasanaeth", heading: "Telerau gwasanaeth", lead: en.terms.lead },
login: { ...en.login, title: "Mewngofnodi", heading: "Mewngofnodi i'ch cyfrif", lead: "Mewngofnodi gyda'ch e-bost a'ch cyfrinair." },
register: { ...en.register, title: "Creu cyfrif", heading: "Creu eich cyfrif", lead: "Cofrestrwch gyda'ch e-bost a chyfrinair o leiaf 6 nod." },
forgot: { ...en.forgot, title: "Anghofio cyfrinair", heading: "Ailosod eich cyfrinair", lead: en.forgot.lead },
logout: { title: "Allgofnodi", heading: "Allgofnodi…", lead: en.logout.lead },
userPanel: { title: "Panel rheoli defnyddiwr", heading: "Panel rheoli", lead: en.userPanel.lead },
userSettings: { title: "Gosodiadau defnyddiwr", heading: "Gosodiadau", lead: en.userSettings.lead },
adminPanel: { title: "Panel rheoli gweinyddol", heading: "Panel rheoli gweinyddol", lead: en.adminPanel.lead },
adminSettings: { title: "Gosodiadau gweinyddol", heading: "Gosodiadau gweinyddol", lead: en.adminSettings.lead },
notFound: { title: "Heb ddod o hyd", heading: "404 — tudalen heb ei ganfod", lead: en.notFound.lead },
unauthorized: { title: "Mewngofnodi sy'n ofynnol", heading: "Mae angen mewngofnodi", lead: en.unauthorized.lead },
};
const ru: Messages = {
...en,
nav: {
...en.nav,
home: "Главная",
about: "О нас",
contact: "Контакты",
privacy: "Политика конфиденциальности",
terms: "Условия обслуживания",
login: "Войти",
register: "Регистрация",
logout: "Выйти",
userPanel: "Панель пользователя",
userSettings: "Настройки пользователя",
adminPanel: "Панель администратора",
adminSettings: "Настройки администратора",
},
siteName: "Демо Suaveplan",
home: { title: "Suaveplan — Главная", heading: "Добро пожаловать в демо Suaveplan", lead: en.home.lead },
about: { title: "О нас", heading: "О Suaveplan", lead: en.about.lead },
contact: { title: "Контакты", heading: "Свяжитесь с нами", lead: en.contact.lead },
privacy: { title: "Политика конфиденциальности", heading: "Политика конфиденциальности", lead: en.privacy.lead },
terms: { title: "Условия обслуживания", heading: "Условия обслуживания", lead: en.terms.lead },
login: { ...en.login, title: "Войти", heading: "Войти в учетную запись", lead: "Войдите, используя email и пароль." },
register: { ...en.register, title: "Создать учетную запись", heading: "Создайте учетную запись", lead: "Зарегистрируйтесь с email и паролем не менее 6 символов." },
forgot: { ...en.forgot, title: "Забыли пароль", heading: "Сбросить пароль", lead: en.forgot.lead },
logout: { title: "Выход", heading: "Выход из системы…", lead: en.logout.lead },
userPanel: { title: "Панель управления пользователя", heading: "Панель управления", lead: en.userPanel.lead },
userSettings: { title: "Настройки пользователя", heading: "Настройки", lead: en.userSettings.lead },
adminPanel: { title: "Панель управления администратора", heading: "Панель управления администратора", lead: en.adminPanel.lead },
adminSettings: { title: "Настройки администратора", heading: "Настройки администратора", lead: en.adminSettings.lead },
notFound: { title: "Не найдено", heading: "404 — страница не найдена", lead: en.notFound.lead },
unauthorized: { title: "Требуется вход", heading: "Требуется вход", lead: en.unauthorized.lead },
};
export const CATALOGS: Readonly<Record<BaseLocale, Messages>> = {
en,
zh,
ar,
cy,
ru,
};

View File

@@ -0,0 +1,87 @@
/**
* Tiny browser auth client that talks directly to the better-auth
* HTTP endpoints mounted by the server at `/api/auth/*`. Keeps the
* demo client free of `better-auth/client` so the React build stays
* lean.
*/
export interface SessionUser {
readonly id: string;
readonly email: string;
readonly name?: string | null;
readonly role?: string | null;
}
export interface Session {
readonly user: SessionUser;
readonly expiresAt?: string;
}
export async function fetchSession(): Promise<Session | null> {
const res = await fetch("/api/auth/get-session", {
credentials: "include",
headers: { accept: "application/json" },
});
if (!res.ok) return null;
const body = (await res.json()) as { user?: SessionUser; session?: { expiresAt?: string } } | null;
if (body === null || body.user === undefined) return null;
return {
user: body.user,
...(body.session?.expiresAt !== undefined ? { expiresAt: body.session.expiresAt } : {}),
};
}
export async function signInEmail(args: {
readonly email: string;
readonly password: string;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const res = await fetch("/api/auth/sign-in/email", {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify(args),
});
if (res.ok) return { ok: true };
const body = (await res.json().catch(() => ({}))) as { message?: string };
return { ok: false, error: body.message ?? `Sign-in failed (${res.status})` };
}
export async function signUpEmail(args: {
readonly email: string;
readonly password: string;
readonly name?: string;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const res = await fetch("/api/auth/sign-up/email", {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...args, name: args.name ?? args.email.split("@")[0] }),
});
if (res.ok) return { ok: true };
const body = (await res.json().catch(() => ({}))) as { message?: string };
return { ok: false, error: body.message ?? `Sign-up failed (${res.status})` };
}
export async function signOut(): Promise<void> {
await fetch("/api/auth/sign-out", {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: "{}",
}).catch(() => undefined);
}
export async function forgetPassword(args: {
readonly email: string;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const res = await fetch("/api/auth/forget-password", {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...args, redirectTo: "/auth/login" }),
});
if (res.ok) return { ok: true };
// better-auth returns 200 even for unknown emails to avoid enumeration;
// we still surface non-2xx for transport-level errors.
const body = (await res.json().catch(() => ({}))) as { message?: string };
return { ok: false, error: body.message ?? `Request failed (${res.status})` };
}

View File

@@ -0,0 +1,57 @@
/**
* Route guards — wrap a child element with `<RequireAuth>` or
* `<RequireRole role="admin">`. Both rely on `useSession()` so they
* react to live sign-in / sign-out without a hard reload.
*/
import React from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useLocale } from "../i18n/locale-context.js";
import { useSession } from "./session-context.js";
export function RequireAuth({ children }: { readonly children: React.ReactNode }): React.JSX.Element {
const { session, loading } = useSession();
const { base } = useLocale();
const location = useLocation();
if (loading) {
return <div className="p-8 text-sm text-muted">Loading session…</div>;
}
if (session === null) {
return (
<Navigate
to={`/${base}/auth/login?return=${encodeURIComponent(location.pathname)}`}
replace
/>
);
}
return <>{children}</>;
}
export function RequireRole({
role,
children,
}: {
readonly role: string;
readonly children: React.ReactNode;
}): React.JSX.Element {
const { session, loading } = useSession();
const { base, messages } = useLocale();
if (loading) {
return <div className="p-8 text-sm text-muted">Loading session…</div>;
}
if (session === null) {
return <Navigate to={`/${base}/auth/login`} replace />;
}
if (session.user.role !== role) {
return (
<main className="mx-auto max-w-2xl p-8">
<h1 className="text-2xl font-semibold" data-testid="page-heading">
{messages.unauthorized.heading}
</h1>
<p className="mt-2 text-sm text-muted">
Required role: <code>{role}</code>
</p>
</main>
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,53 @@
/**
* Session context — polls `/api/auth/get-session` on mount and exposes
* `useSession()` and a `refresh()` to forms.
*/
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { fetchSession, type Session } from "./auth-client.js";
export interface SessionContextValue {
readonly session: Session | null;
readonly loading: boolean;
readonly refresh: () => Promise<void>;
}
const SessionContext = createContext<SessionContextValue | null>(null);
export function useSession(): SessionContextValue {
const ctx = useContext(SessionContext);
if (ctx === null) throw new Error("useSession must be inside <SessionProvider>");
return ctx;
}
export function SessionProvider({ children }: { children: React.ReactNode }): React.JSX.Element {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
try {
const next = await fetchSession();
setSession(next);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const value = useMemo<SessionContextValue>(
() => ({ session, loading, refresh }),
[session, loading, refresh]
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}

View File

@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./styles/globals.css";
import { App } from "./app.js";
const root = document.getElementById("root");
if (root === null) throw new Error("No #root element found");
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -0,0 +1,143 @@
/**
* User and admin gated pages. The auth gate is enforced by
* `<RequireAuth>` / `<RequireRole>` in the route definitions; these
* components only need to assume `useSession().session` is non-null
* and render the placeholder dashboards.
*
* The session-card surface and the settings form are composed from
* `@suaveplan/react-ui` primitives (`<Card>`, `<Field>`, `<Input>`,
* `<FieldDescription>`). The preferred-locale picker keeps a native
* `<select>` because the chassis `<Select>` is a Radix-portal popover
* with overlay state we do not need for a placeholder form.
*/
import {
Card,
CardContent,
CardHeader,
CardTitle,
Field,
FieldDescription,
FieldGroup,
FieldLabel,
Input,
} from "@suaveplan/react-ui";
import React from "react";
import { PageShell } from "../components/page-shell.js";
import { useMessages } from "../i18n/locale-context.js";
import { useSession } from "../lib/session-context.js";
function SessionCard(): React.JSX.Element {
const { session } = useSession();
if (session === null) return <></>;
return (
<Card>
<CardHeader>
<CardTitle>Session</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
<div>
<dt className="font-medium">User id</dt>
<dd className="font-mono text-xs">{session.user.id}</dd>
</div>
<div>
<dt className="font-medium">Email</dt>
<dd>{session.user.email}</dd>
</div>
{session.user.name !== undefined && session.user.name !== null ? (
<div>
<dt className="font-medium">Name</dt>
<dd>{session.user.name}</dd>
</div>
) : null}
{session.user.role !== undefined && session.user.role !== null ? (
<div>
<dt className="font-medium">Role</dt>
<dd>{session.user.role}</dd>
</div>
) : null}
{session.expiresAt !== undefined ? (
<div>
<dt className="font-medium">Session expires</dt>
<dd>{session.expiresAt}</dd>
</div>
) : null}
</dl>
</CardContent>
</Card>
);
}
export function UserControlPanelPage(): React.JSX.Element {
const m = useMessages().userPanel;
return (
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
<SessionCard />
</PageShell>
);
}
export function UserSettingsPage(): React.JSX.Element {
const m = useMessages().userSettings;
return (
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
<Card>
<CardContent>
<form>
<FieldGroup>
<Field>
<FieldLabel htmlFor="displayName">Display name</FieldLabel>
<Input id="displayName" name="displayName" />
</Field>
<Field>
<FieldLabel htmlFor="preferredLocale">Preferred locale</FieldLabel>
{/*
Native <select> is intentional: the chassis Select is a
Radix-portal popover with overlay state we do not need
for a placeholder form. Visual treatment matches the
chassis Input via the shared input cva class names.
*/}
<select
id="preferredLocale"
name="preferredLocale"
className="block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="en">English</option>
<option value="zh">中文</option>
<option value="ar">العربية</option>
<option value="cy">Cymraeg</option>
<option value="ru">Русский</option>
</select>
<FieldDescription>
This is a placeholder — settings are not persisted in the demo.
</FieldDescription>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
</PageShell>
);
}
export function AdminControlPanelPage(): React.JSX.Element {
const m = useMessages().adminPanel;
return (
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
<SessionCard />
</PageShell>
);
}
export function AdminSettingsPage(): React.JSX.Element {
const m = useMessages().adminSettings;
return (
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
<Card>
<CardContent>
<p className="text-sm">Admin-only configuration placeholder.</p>
</CardContent>
</Card>
</PageShell>
);
}

View File

@@ -0,0 +1,283 @@
/**
* Auth pages — Login, Register, ForgotPassword, Logout.
*
* Each posts to the better-auth HTTP endpoint mounted on the server
* under `/api/auth/*` via the thin `auth-client.ts` shim. On success
* the session context is refreshed and the user is sent to their
* control panel.
*
* Forms are composed from `@suaveplan/react-ui` primitives:
* `<Card>` / `<CardContent>` shell, `<Field>` + `<FieldLabel>` +
* `<Input>` + `<FieldError>` per control, and `<Button>` for the
* submit action. The chassis owns all visual variant resolution; the
* demo carries no hand-rolled Tailwind classes for these primitives.
*/
import {
Alert,
AlertDescription,
Button,
Card,
CardContent,
Field,
FieldError,
FieldGroup,
FieldLabel,
Input,
} from "@suaveplan/react-ui";
import React, { useEffect, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { PageShell } from "../components/page-shell.js";
import { useLocale } from "../i18n/locale-context.js";
import {
forgetPassword,
signInEmail,
signOut,
signUpEmail,
} from "../lib/auth-client.js";
import { useSession } from "../lib/session-context.js";
export function LoginPage(): React.JSX.Element {
const { base, messages } = useLocale();
const m = messages.login;
const { refresh } = useSession();
const navigate = useNavigate();
const [search] = useSearchParams();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function handleSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
setBusy(true);
setError(null);
const result = await signInEmail({ email, password });
if (result.ok) {
await refresh();
navigate(search.get("return") ?? `/${base}/user/control-panel`);
} else {
setError(result.error);
}
setBusy(false);
}
return (
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
<Card>
<CardContent>
<form onSubmit={handleSubmit} data-testid="login-form">
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">{m.emailLabel}</FieldLabel>
<Input
id="email"
name="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="password">{m.passwordLabel}</FieldLabel>
<Input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Field>
{error !== null ? (
<Alert variant="destructive" data-testid="login-error">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<Button type="submit" disabled={busy}>
{m.submit}
</Button>
<div className="flex flex-wrap justify-between gap-2 text-sm">
<Link to={`/${base}/auth/register`}>{m.linkToRegister}</Link>
<Link to={`/${base}/auth/forgot-password`}>{m.linkToForgot}</Link>
</div>
</FieldGroup>
</form>
</CardContent>
</Card>
</PageShell>
);
}
export function RegisterPage(): React.JSX.Element {
const { base, messages } = useLocale();
const m = messages.register;
const { refresh } = useSession();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function handleSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
if (password !== confirm) {
setError("Passwords do not match.");
return;
}
setBusy(true);
setError(null);
const result = await signUpEmail({ email, password });
if (result.ok) {
await refresh();
navigate(`/${base}/user/control-panel`);
} else {
setError(result.error);
}
setBusy(false);
}
const mismatch = password !== "" && confirm !== "" && password !== confirm;
return (
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
<Card>
<CardContent>
<form onSubmit={handleSubmit} data-testid="register-form">
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">{m.emailLabel}</FieldLabel>
<Input
id="email"
name="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="password">{m.passwordLabel}</FieldLabel>
<Input
id="password"
name="password"
type="password"
required
autoComplete="new-password"
minLength={6}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Field>
<Field invalid={mismatch}>
<FieldLabel htmlFor="confirm">{m.confirmLabel}</FieldLabel>
<Input
id="confirm"
name="confirm"
type="password"
required
autoComplete="new-password"
minLength={6}
invalid={mismatch}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
{mismatch ? (
<FieldError>Passwords do not match.</FieldError>
) : null}
</Field>
{error !== null ? (
<Alert variant="destructive" data-testid="register-error">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<Button type="submit" disabled={busy}>
{m.submit}
</Button>
<div className="text-sm">
<Link to={`/${base}/auth/login`}>{m.linkToLogin}</Link>
</div>
</FieldGroup>
</form>
</CardContent>
</Card>
</PageShell>
);
}
export function ForgotPasswordPage(): React.JSX.Element {
const { base, messages } = useLocale();
const m = messages.forgot;
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [busy, setBusy] = useState(false);
async function handleSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
setBusy(true);
await forgetPassword({ email });
setSent(true);
setBusy(false);
}
return (
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
<Card>
<CardContent>
{sent ? (
<Alert variant="default" data-testid="forgot-sent">
<AlertDescription>{m.sentNotice}</AlertDescription>
</Alert>
) : (
<form onSubmit={handleSubmit} data-testid="forgot-form">
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">{m.emailLabel}</FieldLabel>
<Input
id="email"
name="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
<Button type="submit" disabled={busy}>
{m.submit}
</Button>
</FieldGroup>
</form>
)}
<div className="mt-4 text-sm">
<Link to={`/${base}/auth/login`}>{m.linkToLogin}</Link>
</div>
</CardContent>
</Card>
</PageShell>
);
}
export function LogoutPage(): React.JSX.Element {
const { base, messages } = useLocale();
const m = messages.logout;
const { refresh } = useSession();
const navigate = useNavigate();
useEffect(() => {
void (async () => {
await signOut();
await refresh();
navigate(`/${base}/home`);
})();
// We want this effect to run exactly once on mount; the locale
// base is captured at mount time.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
}

View File

@@ -0,0 +1,39 @@
/**
* 404 page — also doubles as the unknown-locale handler. Offers a
* language picker so a user landing on `/de/home` can switch to a
* supported language without backtracking.
*
* Each language choice renders as a `<Button asChild variant="outline">`
* wrapping a router `<Link>` so the chassis owns the visual treatment
* (border, focus ring, hover state, motion tokens) while the navigation
* stays SPA-internal.
*/
import { Button } from "@suaveplan/react-ui";
import React from "react";
import { Link } from "react-router-dom";
import { PageShell } from "../components/page-shell.js";
import { LOCALE_ENTRIES, useLocale } from "../i18n/locale-context.js";
import { CATALOGS } from "../i18n/messages.js";
export function NotFoundPage(): React.JSX.Element {
// We may be rendered outside a valid locale context (e.g. for
// `/de/home`). The LocaleProvider falls back to "en" in that case,
// so we can safely read messages.
const { availableLocales, messages: ctxMessages } = useLocale();
const m = ctxMessages.notFound;
return (
<PageShell title={m.title} heading={m.heading} lead={m.lead}>
<ul className="mt-4 flex flex-wrap gap-3">
{availableLocales.map((loc) => (
<li key={loc}>
<Button asChild variant="outline" size="sm">
<Link to={`/${loc}/home`}>
{LOCALE_ENTRIES[loc].nativeName} — {CATALOGS[loc].nav.home}
</Link>
</Button>
</li>
))}
</ul>
</PageShell>
);
}

View File

@@ -0,0 +1,32 @@
/**
* Five public information pages — Home, About, Contact, Privacy,
* Terms. Each renders through `<PageShell>` using the typed catalog.
*/
import React from "react";
import { PageShell } from "../components/page-shell.js";
import { useMessages } from "../i18n/locale-context.js";
export function HomePage(): React.JSX.Element {
const m = useMessages().home;
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
}
export function AboutPage(): React.JSX.Element {
const m = useMessages().about;
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
}
export function ContactPage(): React.JSX.Element {
const m = useMessages().contact;
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
}
export function PrivacyPage(): React.JSX.Element {
const m = useMessages().privacy;
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
}
export function TermsPage(): React.JSX.Element {
const m = useMessages().terms;
return <PageShell title={m.title} heading={m.heading} lead={m.lead} />;
}

View File

@@ -0,0 +1,57 @@
@import "tailwindcss";
:root {
--color-bg: #ffffff;
--color-fg: #0a0a0a;
--color-muted: #71717a;
--color-border: #e4e4e7;
--color-accent: #6366f1;
--color-accent-fg: #ffffff;
--color-surface: #fafafa;
}
[data-theme="dark"] {
--color-bg: #09090b;
--color-fg: #fafafa;
--color-muted: #a1a1aa;
--color-border: #27272a;
--color-accent: #818cf8;
--color-accent-fg: #0a0a0a;
--color-surface: #18181b;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
padding: 0;
min-height: 100vh;
background-color: var(--color-bg);
color: var(--color-fg);
font-family:
"Inter",
system-ui,
-apple-system,
"Segoe UI",
sans-serif;
-webkit-font-smoothing: antialiased;
}
[dir="rtl"] {
text-align: right;
}
a {
color: var(--color-accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,185 @@
/**
* Demo authentication boot.
*
* Composes the canonical genesis plugin pipeline:
*
* 1. A live `SurrealAdapter` (`@suaveplan/surrealdb`) — connects to
* `SURREAL_URL` (default `ws://localhost:8000`) with namespace +
* database from env. The driver does not bundle an in-process
* engine in this version, so an external SurrealDB must be
* reachable.
* 2. A `betterAuthSurrealAdapter` (`@suaveplan/better-auth-surrealdb`)
* built against that client — this is the persistence backend
* Better-Auth itself talks to.
* 3. A `SuaveApp` (`@suaveplan/api`) assembled via `defineApp()`. Two
* plugins register, in order:
* a. A tiny inline `demo-db-seed` plugin that drops the live
* `SurrealAdapter` into the `ServiceRegistry["db"]` slot.
* b. `createAuthPlugin(...)` from `@suaveplan/api-plugin-auth`
* — its 6-step `onInit` then resolves the `db` slot, applies
* the canonical 17-table Better-Auth SurrealQL schema,
* constructs the Better-Auth instance, registers the
* `authService`, mounts the 9 OpenAPI routes (and the
* catch-all Better-Auth handler) under `/api/auth/*`, and
* installs the global session-resolver middleware.
*
* The output is `{ app, surreal }` so the HTTP server can mount the
* SPA fallback + custom routes onto `app.hono` and shut down the
* SurrealDB client cleanly on stop.
*/
import { defineApp, type SuaveApp } from "@suaveplan/api";
import { definePlugin } from "@suaveplan/api-plugin";
import type { AuthService } from "@suaveplan/api-plugin-auth";
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb";
import { SurrealAdapter } from "@suaveplan/surrealdb";
// ServiceRegistry augmentation — re-declared locally because the
// vite-plugin-dts emitted package-root `.d.ts` files for
// `@suaveplan/api` and `@suaveplan/api-plugin-auth` strip the
// side-effect import of the per-package `registry-augmentation.ts`
// where these `declare module` blocks live. Without this re-declaration
// `ServiceRegistry` resolves to `Record<never, never>` and
// `ctx.registerService("db", ...)` / `ctx.getService("authService")`
// fail to typecheck. The augmentation is interface-merged with the
// package-internal ones at runtime, so the typing matches what the
// auth plugin actually populates.
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
db?: SurrealAdapter;
authService: AuthService;
}
}
/** Read a string env var with a non-empty fallback. */
function envOr(name: string, fallback: string): string {
const raw = process.env[name];
return raw !== undefined && raw.length > 0 ? raw : fallback;
}
/** Resolved SurrealDB connection parameters for the demo. */
export interface SurrealConfig {
readonly url: string;
readonly namespace: string;
readonly database: string;
readonly username: string;
readonly password: string;
}
/** Build the connection config from env vars (with defaults). */
export function resolveSurrealConfig(): SurrealConfig {
return {
url: envOr("SURREAL_URL", "ws://localhost:8000"),
namespace: envOr("SURREAL_NS", "demo"),
database: envOr("SURREAL_DB", "demo"),
username: envOr("SURREAL_USER", "root"),
password: envOr("SURREAL_PASS", "root"),
};
}
/** Result of {@link bootAuth}. */
export interface DemoAuthBoot {
readonly app: SuaveApp;
readonly surreal: SurrealAdapter;
readonly authService: AuthService;
}
/**
* Construct + initialize the demo `SuaveApp` with the auth plugin
* wired against a live SurrealDB. Returns the initialized `SuaveApp`
* (ready to receive requests via `app.fetch`) plus the underlying
* `SurrealAdapter` so the caller can `await surreal.disconnect()` on
* shutdown.
*/
export async function bootAuth(): Promise<DemoAuthBoot> {
const cfg = resolveSurrealConfig();
const surreal = new SurrealAdapter({
url: cfg.url,
namespace: cfg.namespace,
database: cfg.database,
username: cfg.username,
password: cfg.password,
});
await surreal.connect();
const betterAuthAdapter = betterAuthSurrealAdapter({
client: surreal,
});
/**
* Inline plugin — seeds the `db` slot on the `ServiceRegistry`
* so the auth plugin's `onInit` (which runs after this one in
* registration order) can resolve a connected SurrealDB client
* for `applyBetterAuthSchema(db)`.
*/
const dbSeedPlugin = definePlugin<Record<string, never>>({
name: "demo-db-seed",
version: "0.1.0",
description:
"Demo-only plugin: seeds the live SurrealAdapter into ServiceRegistry['db'] for the auth plugin to consume. Runs in `onRegister` so the `db` slot is populated before any plugin's `onInit` phase (including the auth plugin's).",
defaults: {},
onRegister: async (ctx) => {
ctx.registerService("db", surreal);
},
});
const authPlugin = createAuthPlugin({
betterAuth: {
database: betterAuthAdapter as unknown as Record<string, unknown>,
baseURL: envOr("BETTER_AUTH_URL", "http://localhost:3300"),
secret: envOr(
"BETTER_AUTH_SECRET",
"demo-secret-please-set-BETTER_AUTH_SECRET-in-prod-do-not-use"
),
emailAndPassword: {
enabled: true,
autoSignIn: true,
minPasswordLength: 6,
},
trustedOrigins: ["http://localhost:3300"],
},
routePrefix: "/api/auth",
applySchemaOnInit: true,
});
/**
* Inline capture plugin — registered AFTER `authPlugin` so its
* `onInit` resolves the typed `authService` slot the auth plugin
* just populated, and stashes it on `captured.authService` for the
* demo's `/api/me` handler.
*/
const captured: { authService: AuthService | undefined } = {
authService: undefined,
};
const capturePlugin = definePlugin<Record<string, never>>({
name: "demo-auth-capture",
version: "0.1.0",
description:
"Demo-only plugin: captures the resolved authService from the plugin registry for use by /api/me. Runs in `onReady` so the `authService` slot populated by the auth plugin's `onInit` is guaranteed visible.",
defaults: {},
onReady: async (ctx) => {
captured.authService = ctx.getService("authService");
},
});
const app = await defineApp()
.name("@suaveplan/demo")
.version("0.1.0")
.skipConfigFile()
.openapi({ title: "@suaveplan/demo", version: "0.1.0" })
.healthRoutes({ healthPath: "/api/health" })
.use(dbSeedPlugin)
.use(authPlugin)
.use(capturePlugin)
.build();
await app.initialize();
if (captured.authService === undefined) {
throw new Error(
"demo boot: authService was not populated by api-plugin-auth"
);
}
return { app, surreal, authService: captured.authService };
}
export type AppAuth = SuaveApp;

View File

@@ -0,0 +1,55 @@
/**
* Unified Bun + Hono server for the demo app.
*
* Built around `@suaveplan/api`'s `SuaveApp`:
*
* - `/api/health` — registered by `registerHealthRoutes` on the
* `SuaveApp` Hono.
* - `/api/auth/*` — mounted by `@suaveplan/api-plugin-auth` (9 OpenAPI
* routes + the Better-Auth catch-all).
* - `/api/me` — demo convenience JSON view of the current session,
* sourced from the captured `authService.getSession(...)`.
* - everything else falls through to the Vite-built React SPA in
* `dist/client/`, with a wildcard fallback to `index.html` so the
* client-side router owns locale-prefixed routes.
*/
import { serveStatic } from "hono/bun";
import { bootAuth } from "./auth.js";
const { app, surreal, authService } = await bootAuth();
const hono = app.hono;
hono.get("/api/me", async (c) => {
const result = await authService.getSession(c.req.raw.headers);
return c.json({ session: result });
});
hono.use("/*", serveStatic({ root: "./dist/client" }));
hono.get("*", serveStatic({ path: "./dist/client/index.html" }));
const port = Number(process.env["PORT"] ?? 3300);
// eslint-disable-next-line no-console
console.log(`[@suaveplan/demo] listening on http://localhost:${port}`);
const shutdown = async (): Promise<void> => {
try {
await app.stop();
} catch {
// best-effort
}
try {
await surreal.disconnect();
} catch {
// best-effort
}
};
process.on("SIGTERM", () => {
void shutdown().then(() => process.exit(0));
});
process.on("SIGINT", () => {
void shutdown().then(() => process.exit(0));
});
export default { port, fetch: app.fetch };

12
apps/demo/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["node", "react", "react-dom"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "e2e/**/*.ts"],
"exclude": ["dist", "node_modules", "coverage"]
}

14
apps/demo/vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
build: {
outDir: "dist/client",
emptyOutDir: true,
sourcemap: true,
},
root: ".",
publicDir: "public",
});