@brendanbank/create-atrium-host 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENCE.md +25 -0
  2. package/README.md +94 -0
  3. package/bin/index.js +8 -0
  4. package/package.json +40 -0
  5. package/src/cli.js +279 -0
  6. package/src/git.js +48 -0
  7. package/src/names.js +91 -0
  8. package/src/render.js +90 -0
  9. package/template/.env.example +33 -0
  10. package/template/.github/dependabot.yml +56 -0
  11. package/template/.github/workflows/ci.yml +103 -0
  12. package/template/Dockerfile +46 -0
  13. package/template/Makefile +67 -0
  14. package/template/README.md +124 -0
  15. package/template/backend/alembic/env.py +116 -0
  16. package/template/backend/alembic/script.py.mako +27 -0
  17. package/template/backend/alembic/versions/0001_init.py +92 -0
  18. package/template/backend/alembic.ini +50 -0
  19. package/template/backend/pyproject.toml +17 -0
  20. package/template/backend/src/__HOST_PKG__/__init__.py +0 -0
  21. package/template/backend/src/__HOST_PKG__/bootstrap.py +52 -0
  22. package/template/backend/src/__HOST_PKG__/models.py +51 -0
  23. package/template/backend/src/__HOST_PKG__/router.py +73 -0
  24. package/template/backend/src/__HOST_PKG__/scripts/__init__.py +0 -0
  25. package/template/backend/src/__HOST_PKG__/scripts/seed_host_bundle.py +48 -0
  26. package/template/backend/tests/test_smoke.py +47 -0
  27. package/template/compose.yaml +96 -0
  28. package/template/frontend/package.json +36 -0
  29. package/template/frontend/src/__BRAND_PASCAL__AdminTab.tsx +32 -0
  30. package/template/frontend/src/__BRAND_PASCAL__Page.tsx +34 -0
  31. package/template/frontend/src/__BRAND_PASCAL__ProfileItem.tsx +38 -0
  32. package/template/frontend/src/__BRAND_PASCAL__Widget.tsx +117 -0
  33. package/template/frontend/src/api.ts +38 -0
  34. package/template/frontend/src/main.tsx +55 -0
  35. package/template/frontend/src/queryClient.ts +11 -0
  36. package/template/frontend/src/test/__BRAND_PASCAL__Widget.test.tsx +102 -0
  37. package/template/frontend/src/test/setup.ts +21 -0
  38. package/template/frontend/tsconfig.json +21 -0
  39. package/template/frontend/vite.config.ts +7 -0
  40. package/template/frontend/vitest.config.ts +15 -0
@@ -0,0 +1,50 @@
1
+ # Host-side alembic config. Atrium owns its own chain in the runtime
2
+ # image (version table: alembic_version). The host owns this chain in
3
+ # alembic_version_app — see env.py. Run with:
4
+ #
5
+ # alembic -c /opt/host_app/alembic.ini upgrade head
6
+ #
7
+ # inside the api container after the atrium chain is up.
8
+
9
+ [alembic]
10
+ # %(here)s resolves to the directory of this ini file. Without it,
11
+ # script_location is interpreted relative to the CWD, which means
12
+ # `alembic -c /opt/host_app/alembic.ini ...` run from /app would load
13
+ # /app/alembic (atrium's chain) instead of the host's.
14
+ script_location = %(here)s/alembic
15
+ prepend_sys_path = %(here)s/src
16
+ sqlalchemy.url =
17
+
18
+ [loggers]
19
+ keys = root,sqlalchemy,alembic
20
+
21
+ [handlers]
22
+ keys = console
23
+
24
+ [formatters]
25
+ keys = generic
26
+
27
+ [logger_root]
28
+ level = WARN
29
+ handlers = console
30
+ qualname =
31
+
32
+ [logger_sqlalchemy]
33
+ level = WARN
34
+ handlers =
35
+ qualname = sqlalchemy.engine
36
+
37
+ [logger_alembic]
38
+ level = INFO
39
+ handlers =
40
+ qualname = alembic
41
+
42
+ [handler_console]
43
+ class = StreamHandler
44
+ args = (sys.stderr,)
45
+ level = NOTSET
46
+ formatter = generic
47
+
48
+ [formatter_generic]
49
+ format = %(levelname)-5.5s [%(name)s] %(message)s
50
+ datefmt = %H:%M:%S
@@ -0,0 +1,17 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "__HOST_NAME__-host"
7
+ version = "0.1.0"
8
+ description = "__BRAND_NAME__ host extension for Atrium"
9
+ requires-python = ">=3.12"
10
+
11
+ # All runtime deps come from the atrium image (FastAPI, SQLAlchemy,
12
+ # Alembic, structlog). Listing them here would just shadow atrium's
13
+ # pinned versions. The package is installed via `pip install ./backend`
14
+ # inside the image so `__HOST_PKG__` is importable on PYTHONPATH.
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ packages = ["src/__HOST_PKG__"]
File without changes
@@ -0,0 +1,52 @@
1
+ """Atrium host bootstrap entry points.
2
+
3
+ The atrium image imports this module on startup when the operator sets
4
+ ``ATRIUM_HOST_MODULE=__HOST_PKG__.bootstrap``:
5
+
6
+ - ``init_app(app)`` runs once during ``create_app()`` — after every
7
+ atrium router is included and before the ASGI app starts serving.
8
+ We use it to mount our router; permissions are seeded by the alembic
9
+ migration (``alembic/versions/0001_init.py``).
10
+ - ``init_worker(host)`` runs on worker startup, after atrium's
11
+ built-in handlers register and before APScheduler starts. ``host``
12
+ is a :class:`~app.host_sdk.worker.HostWorkerCtx` that exposes the
13
+ APScheduler instance plus a typed ``register_job_handler`` for
14
+ ``scheduled_jobs`` dispatch.
15
+
16
+ Both functions are optional — a module that defines neither is
17
+ allowed (atrium logs ``host.init_app.absent`` and continues). Delete
18
+ either when you don't need it.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from fastapi import FastAPI
23
+
24
+ from app.host_sdk.worker import HostWorkerCtx
25
+
26
+
27
+ def init_app(app: FastAPI) -> None:
28
+ from .router import router
29
+
30
+ app.include_router(router)
31
+
32
+
33
+ def init_worker(host: HostWorkerCtx) -> None:
34
+ # Recurring APScheduler tick — fires inside the worker process,
35
+ # NOT the api process. Use this for stateless idempotent work.
36
+ #
37
+ # from .schedule import tick
38
+ # host.scheduler.add_job(
39
+ # tick, "interval", seconds=30,
40
+ # id="__HOST_PKG__-tick", coalesce=True, max_instances=1,
41
+ # )
42
+
43
+ # Durable scheduled_jobs handler — each row is claimed FOR UPDATE
44
+ # SKIP LOCKED by exactly one worker, retried on failure.
45
+ #
46
+ # from .handlers import handle_thing
47
+ # host.register_job_handler(
48
+ # kind="__HOST_PKG__.thing",
49
+ # handler=handle_thing,
50
+ # description="Drain __HOST_PKG__.thing scheduled_jobs rows",
51
+ # )
52
+ _ = host # marker so type-checkers don't warn about the unused arg
@@ -0,0 +1,51 @@
1
+ """Host-side ORM models.
2
+
3
+ The host owns its own ``DeclarativeBase`` (and therefore its own
4
+ ``MetaData``) so atrium's alembic chain never sees this table. The
5
+ host's alembic chain manages it under a separate version table
6
+ (``alembic_version_app``); see ``alembic/env.py``.
7
+
8
+ Rules of engagement:
9
+
10
+ - **Never** parent host tables on ``app.db.Base``. The next atrium
11
+ upgrade may collide with whatever you added.
12
+ - Cross-base foreign keys (a host column referencing ``users.id`` or
13
+ any other atrium table) need ``HostForeignKey`` from
14
+ ``app.host_sdk.db`` plus ``emit_host_foreign_keys`` wired into
15
+ ``alembic/env.py`` — both already configured in the scaffolded
16
+ files. See ``docs/host-models.md`` in the atrium repo for the full
17
+ rationale.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from datetime import datetime
22
+
23
+ from sqlalchemy import BigInteger, Integer, String, text
24
+ from sqlalchemy.dialects.mysql import DATETIME as MysqlDATETIME
25
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
26
+
27
+
28
+ class HostBase(DeclarativeBase):
29
+ """Host metadata, separate from atrium's ``app.db.Base``."""
30
+
31
+
32
+ class __BRAND_PASCAL__State(HostBase):
33
+ """Singleton row (id=1) for the demo widget. Replace with your
34
+ real domain models — and remove the corresponding migration row
35
+ when you do."""
36
+
37
+ __tablename__ = "__HOST_PKG___state"
38
+
39
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=False)
40
+ message: Mapped[str] = mapped_column(
41
+ String(255), nullable=False, default="Welcome to __BRAND_NAME__"
42
+ )
43
+ counter: Mapped[int] = mapped_column(
44
+ BigInteger, nullable=False, default=0
45
+ )
46
+ updated_at: Mapped[datetime] = mapped_column(
47
+ MysqlDATETIME(fsp=6),
48
+ nullable=False,
49
+ server_default=text("CURRENT_TIMESTAMP(6)"),
50
+ server_onupdate=text("CURRENT_TIMESTAMP(6)"),
51
+ )
@@ -0,0 +1,73 @@
1
+ """Demo HTTP surface.
2
+
3
+ - ``GET /__HOST_PKG__/state`` — read-only, auth required.
4
+ - ``POST /__HOST_PKG__/bump`` — gated by ``__HOST_PKG__.write``,
5
+ increments the demo counter and writes an audit row.
6
+
7
+ Replace these with your real routes. The auth dependencies and the
8
+ audit/notify helpers (imported from ``app.*``) are the surface a
9
+ host calls atrium through.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from fastapi import APIRouter, Depends
14
+ from pydantic import BaseModel
15
+ from sqlalchemy import select
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+
18
+ from app.auth.rbac import require_perm
19
+ from app.auth.users import current_user
20
+ from app.db import get_session
21
+ from app.models.auth import User
22
+ from app.services.audit import record as record_audit
23
+
24
+ from .models import __BRAND_PASCAL__State
25
+
26
+ router = APIRouter(prefix="/__HOST_PKG__", tags=["__HOST_PKG__"])
27
+
28
+
29
+ class StateOut(BaseModel):
30
+ message: str
31
+ counter: int
32
+
33
+
34
+ async def _load_state(session: AsyncSession) -> __BRAND_PASCAL__State:
35
+ state = (
36
+ await session.execute(
37
+ select(__BRAND_PASCAL__State).where(__BRAND_PASCAL__State.id == 1)
38
+ )
39
+ ).scalar_one_or_none()
40
+ if state is None:
41
+ raise RuntimeError(
42
+ "__HOST_PKG___state row id=1 missing — run the host alembic upgrade",
43
+ )
44
+ return state
45
+
46
+
47
+ @router.get("/state", response_model=StateOut)
48
+ async def get_state(
49
+ _user: User = Depends(current_user),
50
+ session: AsyncSession = Depends(get_session),
51
+ ) -> StateOut:
52
+ state = await _load_state(session)
53
+ return StateOut(message=state.message, counter=state.counter)
54
+
55
+
56
+ @router.post("/bump", response_model=StateOut)
57
+ async def bump(
58
+ user: User = Depends(require_perm("__HOST_PKG__.write")),
59
+ session: AsyncSession = Depends(get_session),
60
+ ) -> StateOut:
61
+ state = await _load_state(session)
62
+ before = state.counter
63
+ state.counter += 1
64
+ await record_audit(
65
+ session,
66
+ actor_user_id=user.id,
67
+ entity="__HOST_PKG___state",
68
+ entity_id=state.id,
69
+ action="bump",
70
+ diff={"counter": {"before": before, "after": state.counter}},
71
+ )
72
+ await session.commit()
73
+ return StateOut(message=state.message, counter=state.counter)
@@ -0,0 +1,48 @@
1
+ """One-shot CLI: write ``system.host_bundle_url`` so atrium picks up
2
+ the host frontend bundle on the next page load.
3
+
4
+ Run inside the api container after migrations + admin seeding:
5
+
6
+ python -m __HOST_PKG__.scripts.seed_host_bundle /host/main.js
7
+
8
+ Idempotent. Preserves other ``system`` fields by JSON-merging onto
9
+ whatever's already there.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import sys
15
+
16
+ from sqlalchemy import select
17
+ from sqlalchemy.dialects.mysql import insert
18
+
19
+ from app.db import get_session_factory
20
+ from app.models.ops import AppSetting
21
+
22
+
23
+ async def _seed(url: str) -> None:
24
+ factory = get_session_factory()
25
+ async with factory() as session:
26
+ existing = (
27
+ await session.execute(
28
+ select(AppSetting).where(AppSetting.key == "system")
29
+ )
30
+ ).scalar_one_or_none()
31
+ merged = {**(existing.value if existing else {}), "host_bundle_url": url}
32
+
33
+ stmt = insert(AppSetting).values(key="system", value=merged)
34
+ stmt = stmt.on_duplicate_key_update(value=merged)
35
+ await session.execute(stmt)
36
+ await session.commit()
37
+ print(f"system.host_bundle_url set to {url!r}")
38
+
39
+
40
+ def main() -> None:
41
+ if len(sys.argv) != 2:
42
+ print("usage: seed_host_bundle <url>", file=sys.stderr)
43
+ sys.exit(2)
44
+ asyncio.run(_seed(sys.argv[1]))
45
+
46
+
47
+ if __name__ == "__main__":
48
+ main()
@@ -0,0 +1,47 @@
1
+ """Smoke tests that don't need a running database.
2
+
3
+ The atrium runtime image strips ``pytest`` to keep the prod image
4
+ small; install it on demand with the ``test-backend`` Make target.
5
+ Tests here verify the package imports cleanly and the bootstrap
6
+ entry points have the right signatures — enough for CI to catch a
7
+ broken extension before it hits the api container.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import inspect
12
+
13
+
14
+ def test_package_imports() -> None:
15
+ import __HOST_PKG__ # noqa: F401
16
+
17
+
18
+ def test_bootstrap_signatures() -> None:
19
+ from __HOST_PKG__.bootstrap import init_app, init_worker
20
+
21
+ # Shape match against what atrium calls. The signatures are part
22
+ # of the contract; if they drift, fail loudly here rather than on
23
+ # api boot.
24
+ assert list(inspect.signature(init_app).parameters) == ["app"]
25
+ assert list(inspect.signature(init_worker).parameters) == ["host"]
26
+
27
+
28
+ def test_router_mounted() -> None:
29
+ from __HOST_PKG__.router import router
30
+
31
+ paths = {route.path for route in router.routes}
32
+ assert "/__HOST_PKG__/state" in paths
33
+ assert "/__HOST_PKG__/bump" in paths
34
+
35
+
36
+ def test_models_isolated_from_atrium_base() -> None:
37
+ """HostBase must not share metadata with atrium's app.db.Base.
38
+
39
+ Sharing would mean autogenerate sees atrium tables and proposes
40
+ drop_table ops on every revision — the most painful failure
41
+ mode of the host-extension model. Catch it here.
42
+ """
43
+ from app.db import Base as AtriumBase
44
+
45
+ from __HOST_PKG__.models import HostBase
46
+
47
+ assert HostBase.metadata is not AtriumBase.metadata
@@ -0,0 +1,96 @@
1
+ # __BRAND_NAME__ — self-contained prod compose.
2
+ #
3
+ # Pulls atrium from GHCR, builds the host extension on top, brings up
4
+ # api + worker + mysql. No proxy, no TLS — atrium speaks plain HTTP on
5
+ # :8000 by design. Put your own TLS terminator (Caddy, nginx, the load
6
+ # balancer at your edge) in front for production.
7
+ #
8
+ # Usage (from this directory):
9
+ #
10
+ # cp .env.example .env # fill in JWT_SECRET, DB password
11
+ # docker compose up -d --build
12
+ # make migrate
13
+ # make seed-admin EMAIL=you@example.com PASSWORD=...
14
+ #
15
+ # Environment variables come from .env via ${VAR} interpolation.
16
+
17
+ services:
18
+ api:
19
+ build:
20
+ context: .
21
+ dockerfile: Dockerfile
22
+ args:
23
+ ATRIUM_IMAGE: ${ATRIUM_IMAGE:-ghcr.io/brendanbank/atrium:__ATRIUM_VERSION__}
24
+ image: __HOST_NAME__:latest
25
+ pull_policy: never
26
+ restart: unless-stopped
27
+ environment:
28
+ ENVIRONMENT: ${ENVIRONMENT:-prod}
29
+ APP_TIMEZONE: ${APP_TIMEZONE:-UTC}
30
+ APP_SECRET_KEY: ${APP_SECRET_KEY}
31
+ APP_BASE_URL: ${APP_BASE_URL:-http://localhost:8000}
32
+ DATABASE_URL: ${DATABASE_URL}
33
+ JWT_SECRET: ${JWT_SECRET}
34
+ JWT_ACCESS_TOKEN_EXPIRE_MINUTES: ${JWT_ACCESS_TOKEN_EXPIRE_MINUTES:-10080}
35
+ WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}
36
+ WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-__BRAND_NAME__}
37
+ WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-http://localhost:8000}
38
+ MAIL_BACKEND: ${MAIL_BACKEND:-console}
39
+ MAIL_FROM: ${MAIL_FROM:-no-reply@example.com}
40
+ ATRIUM_HOST_MODULE: __HOST_PKG__.bootstrap
41
+ # Host alembic runs with cwd=/opt/host_app (via -c
42
+ # /opt/host_app/alembic.ini) and its env.py imports from
43
+ # atrium's `app` package. Atrium's source lives at /app — put
44
+ # it on PYTHONPATH so the import resolves no matter where the
45
+ # caller's cwd is.
46
+ PYTHONPATH: /app
47
+ ports:
48
+ - "8000:8000"
49
+ depends_on:
50
+ mysql:
51
+ condition: service_healthy
52
+
53
+ worker:
54
+ image: __HOST_NAME__:latest
55
+ pull_policy: never
56
+ restart: unless-stopped
57
+ environment:
58
+ ENVIRONMENT: ${ENVIRONMENT:-prod}
59
+ APP_TIMEZONE: ${APP_TIMEZONE:-UTC}
60
+ APP_SECRET_KEY: ${APP_SECRET_KEY}
61
+ APP_BASE_URL: ${APP_BASE_URL:-http://localhost:8000}
62
+ DATABASE_URL: ${DATABASE_URL}
63
+ JWT_SECRET: ${JWT_SECRET}
64
+ MAIL_BACKEND: ${MAIL_BACKEND:-console}
65
+ MAIL_FROM: ${MAIL_FROM:-no-reply@example.com}
66
+ ATRIUM_HOST_MODULE: __HOST_PKG__.bootstrap
67
+ PYTHONPATH: /app
68
+ command: ["python", "-m", "app.worker"]
69
+ depends_on:
70
+ mysql:
71
+ condition: service_healthy
72
+ api:
73
+ condition: service_started
74
+ healthcheck:
75
+ disable: true
76
+
77
+ mysql:
78
+ image: mysql:8.0
79
+ restart: unless-stopped
80
+ command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --default-time-zone=+00:00
81
+ environment:
82
+ MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
83
+ MYSQL_DATABASE: ${MYSQL_DATABASE}
84
+ MYSQL_USER: ${MYSQL_USER}
85
+ MYSQL_PASSWORD: ${MYSQL_PASSWORD}
86
+ volumes:
87
+ - mysql_data:/var/lib/mysql
88
+ healthcheck:
89
+ test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
90
+ interval: 10s
91
+ timeout: 5s
92
+ retries: 10
93
+
94
+ volumes:
95
+ mysql_data:
96
+ name: __HOST_PKG___mysql_data
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "__HOST_NAME__-frontend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Host extension bundle for atrium — registers a home widget, route, nav item, admin tab, and profile slot.",
7
+ "scripts": {
8
+ "build": "vite build",
9
+ "build:watch": "vite build --watch",
10
+ "typecheck": "tsc --noEmit",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest"
13
+ },
14
+ "dependencies": {
15
+ "@brendanbank/atrium-host-bundle-utils": "^__ATRIUM_VERSION__",
16
+ "@brendanbank/atrium-host-types": "^__ATRIUM_VERSION__",
17
+ "@mantine/core": "^9.1.0",
18
+ "@mantine/hooks": "^9.1.0",
19
+ "@tabler/icons-react": "^3.41.1",
20
+ "@tanstack/react-query": "^5.100.1",
21
+ "react": "^19.2.5",
22
+ "react-dom": "^19.2.5"
23
+ },
24
+ "devDependencies": {
25
+ "@brendanbank/atrium-test-utils": "^__ATRIUM_VERSION__",
26
+ "@testing-library/jest-dom": "^6.9.1",
27
+ "@testing-library/react": "^16.3.2",
28
+ "@types/react": "^19.0.0",
29
+ "@types/react-dom": "^19.0.0",
30
+ "jsdom": "^29.0.2",
31
+ "typescript": "^6.0.3",
32
+ "vite": "^8.0.10",
33
+ "vite-plugin-css-injected-by-js": "^3.5.2",
34
+ "vitest": "^4.1.5"
35
+ }
36
+ }
@@ -0,0 +1,32 @@
1
+ import { MantineProvider, Stack, Text, Title } from '@mantine/core';
2
+ import { QueryClientProvider } from '@tanstack/react-query';
3
+ import { AtriumProvider } from '@brendanbank/atrium-host-bundle-utils/react';
4
+
5
+ import { __BRAND_PASCAL__Widget } from './__BRAND_PASCAL__Widget';
6
+ import { queryClient } from './queryClient';
7
+
8
+ function __BRAND_PASCAL__AdminTabInner() {
9
+ return (
10
+ <Stack gap="md">
11
+ <Title order={3}>__BRAND_NAME__ admin</Title>
12
+ <Text c="dimmed" size="sm">
13
+ Permission-gated tab in the admin shell. Atrium hides this tab
14
+ for users without ``__HOST_PKG__.write``; the API enforces the
15
+ same on every write.
16
+ </Text>
17
+ <__BRAND_PASCAL__Widget />
18
+ </Stack>
19
+ );
20
+ }
21
+
22
+ export function __BRAND_PASCAL__AdminTab() {
23
+ return (
24
+ <MantineProvider>
25
+ <QueryClientProvider client={queryClient}>
26
+ <AtriumProvider>
27
+ <__BRAND_PASCAL__AdminTabInner />
28
+ </AtriumProvider>
29
+ </QueryClientProvider>
30
+ </MantineProvider>
31
+ );
32
+ }
@@ -0,0 +1,34 @@
1
+ import { Container, Stack, Text, Title } from '@mantine/core';
2
+ import { MantineProvider } from '@mantine/core';
3
+ import { QueryClientProvider } from '@tanstack/react-query';
4
+ import { AtriumProvider } from '@brendanbank/atrium-host-bundle-utils/react';
5
+
6
+ import { __BRAND_PASCAL__Widget } from './__BRAND_PASCAL__Widget';
7
+ import { queryClient } from './queryClient';
8
+
9
+ function __BRAND_PASCAL__PageInner() {
10
+ return (
11
+ <Container size="md" py="xl">
12
+ <Stack gap="md">
13
+ <Title order={2}>__BRAND_NAME__</Title>
14
+ <Text c="dimmed">
15
+ Dedicated route registered by the host bundle. Replace this
16
+ page with your real domain UI.
17
+ </Text>
18
+ <__BRAND_PASCAL__Widget />
19
+ </Stack>
20
+ </Container>
21
+ );
22
+ }
23
+
24
+ export function __BRAND_PASCAL__Page() {
25
+ return (
26
+ <MantineProvider>
27
+ <QueryClientProvider client={queryClient}>
28
+ <AtriumProvider>
29
+ <__BRAND_PASCAL__PageInner />
30
+ </AtriumProvider>
31
+ </QueryClientProvider>
32
+ </MantineProvider>
33
+ );
34
+ }
@@ -0,0 +1,38 @@
1
+ import { Card, MantineProvider, Stack, Text, Title } from '@mantine/core';
2
+ import { QueryClientProvider } from '@tanstack/react-query';
3
+ import { AtriumProvider, useMe } from '@brendanbank/atrium-host-bundle-utils/react';
4
+
5
+ import { queryClient } from './queryClient';
6
+
7
+ function __BRAND_PASCAL__ProfileItemInner() {
8
+ const { data: me } = useMe();
9
+ return (
10
+ <Card withBorder padding="lg" radius="md">
11
+ <Stack gap="xs">
12
+ <Title order={4}>__BRAND_NAME__</Title>
13
+ <Text size="sm" c="dimmed">
14
+ Per-user host extension card slotted after the role list.
15
+ Use this slot for app-specific identity bits — preferred
16
+ location, notification preferences, opt-in features.
17
+ </Text>
18
+ {me && (
19
+ <Text size="sm">
20
+ Signed in as <strong>{me.email}</strong>.
21
+ </Text>
22
+ )}
23
+ </Stack>
24
+ </Card>
25
+ );
26
+ }
27
+
28
+ export function __BRAND_PASCAL__ProfileItem() {
29
+ return (
30
+ <MantineProvider>
31
+ <QueryClientProvider client={queryClient}>
32
+ <AtriumProvider>
33
+ <__BRAND_PASCAL__ProfileItemInner />
34
+ </AtriumProvider>
35
+ </QueryClientProvider>
36
+ </MantineProvider>
37
+ );
38
+ }