@bitz/app (apps/app) is the web admin single-page application. It is a Vite + React 19 app whose only data channel is @bitz/platform-sdk, whose only composite-UI source is @bitz/widgets, and whose only styling system is Tailwind CSS v4. Pages compose widgets and call usePlatform().api.<method>(...); they do not fetch, they do not copy DTOs, and they do not hand-roll loading states.
1. Build & dev topology
| Mode | Behavior |
|---|---|
yarn dev (default) | VITE_API_BASE_URL empty → Vite dev server on 6800 proxies /api/*, /health, and /hubs (WebSocket) to the local Api Host (6881). Browser stays same-origin → no CORS in dev. |
VITE_API_BASE_URL set | Frontend calls the backend directly; the backend must allow the origin (CORS configured on the Host). The proxy block is absent in this mode. |
yarn build | tsc --noEmit && vite build && node ../../scripts/verify-bundle-budget.mjs dist. Type errors fail first; the bundle then passes a size-budget gate. |
The dev proxy is the reason local development has zero CORS friction: the browser only ever sees http://localhost:6800, and Vite forwards the API path to the .NET Host. The proxy is itself conditional — it only exists when command === 'serve' && VITE_API_BASE_URL === '', so the same config works for dev-proxy and direct modes.
// An empty API base keeps browser traffic same-origin.const apiBaseUrl = environment.VITE_API_BASE_URL ?? '';// Create the proxy only for the dev server when direct mode is off.const useDevProxy = command === 'serve' && apiBaseUrl === '';
server: { port: 6800, strictPort: true, ...(useDevProxy ? { proxy: { '/api': { target: environment.VITE_PROXY_TARGET ?? 'http://localhost:6881', changeOrigin: false }, '/health': { target: environment.VITE_PROXY_TARGET ?? 'http://localhost:6881', changeOrigin: false }, // SignalR negotiate + WebSocket must stay same-origin; /hubs gets ws forwarding. '/hubs': { target: environment.VITE_PROXY_TARGET ?? 'http://localhost:6881', changeOrigin: false, ws: true }, }, } : {}),}2. Environment variables
| Variable | Default | Effect |
|---|---|---|
VITE_API_BASE_URL | '' | Empty → dev-proxy mode (same-origin). Set → direct mode, backend must allow origin. Also feeds PlatformSdkConfig.baseUrl. |
VITE_PROXY_TARGET | http://localhost:6881 | Dev-proxy target for /api, /health, /hubs; code default 6881, but the repo .env.example pre-fills 5192 — adjust after copying. |
VITE_CLIENT_PLATFORM | Web | Sent as the X-Client-Platform header. |
VITE_API_TIMEOUT_MS | 30000 | Per-request timeout (ms), feeds PlatformSdkConfig.defaultTimeoutMs. |
VITE_LOGIN_TEMPLATE | legal-mis | Sign-in template: mini-program, website-admin, business, or legal-mis. |
VITE_BRAND_THEME | legal-navy | Frozen brand theme; invalid values fall back to the default. |
VITE_COLOR_MODE | system | light, dark, or system. |
VITE_DENSITY | dense | dense, compact, comfortable, or spacious. |
VITE_SHELL_PRESET | side | Authenticated admin shell: side or top; invalid values fall back to side, and pages must not override it. |
3. Runtime composition
The app assembles a small, fixed set of libraries:
| Concern | Library | Role in the app |
|---|---|---|
| Routing | react-router-dom 7 | lazy routes via import.meta.glob; RouteGuard wraps authenticated routes |
| Client state | Jotai | atoms for UI-local state |
| URL state | nuqs | query-param state in the URL (sorting, filters) |
| Server state | @tanstack/react-query 5 | the only data-fetching surface; cache keys derive from API calls |
| Auth/HTTP | @bitz/platform-sdk | mounted once as PlatformProvider; pages read usePlatform() |
| UI primitives | @bitz/components | shadcn primitive barrel (dozens of atoms) + cn |
| Composite UI | @bitz/widgets | AppShell, ServerDataTable, FormField, ConfirmAction, states |
| Styling | Tailwind v4 | @tailwindcss/vite |
| PWA | vite-plugin-pwa | registerType: prompt; manifest name FD WORK |
| DX | unplugin-auto-import, unplugin-icons, unplugin-svgr, Locator (dev only) | auto-imported React/RR hooks, Lucide icons, SVG-as-component, click-to-component |
4. Entry, providers, and routing
main.tsx wires the provider tree in a fixed order: StrictMode > PlatformProvider(config) > WorkspacePreferencesProvider(injecting safe first-paint brand/colorMode/density/shellPreset defaults) > QueryClientProvider > PrincipalQueryCacheBoundary > RouterProvider + AppToaster. Mount point is #root. There is no root I18nProvider — pages opt into i18n where needed.
Routing lives in src/routes.tsx. Routes extend React Router’s RouteObject with meta (requiresAuth?, permission?, title?) and a string lazy that normalizeRoutes converts to a lazyRoute() glob import:
| Route | meta | What renders |
|---|---|---|
/login | Public | Design System 1.2 sign-in screen: credentials, CAPTCHA, MFA, external providers |
/forgot-password, /reset-password | Public | Password recovery and reset |
/accept-invitation, /activate | Public | Invitation acceptance and account activation |
/verify-email, /verify-phone | Public | Contact confirmation |
/external-login/complete, /session-expired | Public | External-sign-in completion and session recovery |
/ | requiresAuth | <RouteGuard> → <AppShell> |
/ (index) | title: Workspace overview, requiresAuth | home — permission-driven workspace directory rendering api.getNavigation() |
/users | permission: ['identity.user.search'] | user management list |
/change-password | requiresAuth | Voluntary or forced password change |
/settings/security | requiresAuth | MFA, verification, and session security |
/identity/access | requiresAuth | Invitation and admission management |
/identity/organization | identity.organization-unit.view | Large organization directory, bulk move, and same-name child creation |
/identity/applications | platform.oauth.manage etc. | OAuth, API key, and HMAC application credentials |
/host/tenants | permission: ['operations.tenants.view'], surface: host | Tenant governance (Host surface) |
* | title: Page not found, requiresAuth | lazy pages/not-found fallback |
RouteGuard is the route-level gate (complementing the button-level PermissionGate widget). It reads meta.requiresAuth and meta.permission and redirects to /login when unauthenticated or unauthorized. A RouteLoading HydrateFallback covers lazy-chunk fetches.
5. The widget layer
Pages compose from @bitz/widgets, which hides the glue that every data screen otherwise rewrites:
| Widget | Hides |
|---|---|
AppShell + navigation + HeaderBar | Side/Top shells; server-authorized navigation; collapsing Page Context; real notification center; account menu and sign-out |
WorkspacePage + WorkspaceLocalTabs | page safe area, module-navigation deduplication, Page Header collapse, and sticky in-page tabs |
ServerDataTable | server-side pagination, sorting, column model, and the PlatformResult mapping |
FormField | label/error/help wiring for text inputs |
ConfirmAction | destructive-action confirmation flow |
QueryState / EmptyState / ErrorState | the loading/empty/error branches of any query (ErrorState maps AppErrorType → i18n key) |
PermissionGate | declarative permission gating (widgets wrapper around the SDK gate) |
ServerDataTable<T> props
| Prop | Type | Notes |
|---|---|---|
query | { isLoading, isError, error, data?: PlatformResult<Page<T>>, refetch? } | a TanStack Query result shape |
columns | readonly DataTableColumn<T>[] | { key, header, cell, sortable?, className? } |
getRowId | (row: T) => string | row key |
pageIndex | number | 1-based |
pageSize | number | |
onPageChange | (pageIndex: number) => void | |
sortField | string | null | null = unsorted |
sortOrder | 'asc' | 'desc' | |
onSortChange | (sortField, sortOrder) => void | click cycle: asc → desc → clear |
emptyTitle?, ariaLabel? | string |
The widget itself does not touch URLSearchParams — the page owns URL state (see users-list.tsx, which keys params page/size/q/status/sort/order). State machine: isLoading → QueryState; isError or data.ok === false → ErrorState (prefers the PlatformFailure.error, falls back to mapping the thrown error); empty items → EmptyState; else the table with a “N total · page X of Y” pagination footer.
Shell and notifications
AppShell reads the deployment-level VITE_SHELL_PRESET:
side: a 64px module rail plus a 176px current-module panel, with bounded desktop resizing;top: both the primary bar and the context bar take their height from the density token--cmp-shell-header-height(48px dense / 56px compact / 64px comfortable+), not hard-coded pixels; primary entries beyond capacity move into “More”, while narrow screens receive a complete module menu;- both layouts share one
api.getNavigation()query, URL model, permission model, and page recipe.
Identity module routes are rendered once by the Shell. Pages use WorkspaceLocalTabs only for genuine content partitions; while scrolling, Local Tabs stick below the fixed Shell rows, and the title plus primary actions move into the existing Global bar.
The HeaderBar notification entry calls getNotificationInbox, getNotificationUnreadCount, markNotificationRead, and markAllNotificationsRead. These methods bridge generated OpenAPI types, and notification URLs accept only normalized same-origin absolute paths. Search, help, or theme buttons are not rendered as dead controls before they have real owners.
FormField props
| Prop | Type | Default |
|---|---|---|
name, label, value, onChange | required | |
error? | string | drives aria-invalid + error text |
type? | 'text' | 'email' | 'password' | 'search' | 'text' |
placeholder?, required?, disabled? |
Uses useId() to link htmlFor/id. No built-in validation engine — errors are passed in.
ConfirmAction props
| Prop | Type | Default |
|---|---|---|
trigger, title, onConfirm | required (onConfirm: () => Promise<void>) | |
description? | string | |
confirmText? / cancelText? | string | 'Confirm' / 'Cancel' |
destructive? | boolean | true |
Buttons disable while onConfirm is in flight; the dialog closes only after the promise resolves.
6. Permission-aware UI (two levels)
Permission gating happens at two layers, both UX-only:
import { PermissionGate } from "@bitz/widgets";
<PermissionGate require="identity.user.search" showFallback> {/* the whole users page */}</PermissionGate>;- Route-level:
RouteGuardreadsmeta.permissionfromroutes.tsxand redirects unauthorized users. - Button-level: the
PermissionGatewidget hides/enables UI based oncurrentUser?.permissions.
Both read the same permission set, which comes from /api/auth/me — never from localStorage. The backend must enforce the same capabilities in its authorization pipeline.
7. Seed screens
| File | What it demonstrates |
|---|---|
pages/login/login.tsx | Full sign-in state machine; branches into CAPTCHA, MFA, forced password change, or success via LoginOutcome |
pages/identity/* | Passwords, invitations, activation, verification, external sign-in, security settings, sessions, and admissions; see Identity frontend |
pages/users/users-list.tsx | the reference data screen: ServerDataTable + usePlatform().api.searchUsers + ConfirmAction for enable/disable, URL-synced params |
pages/home.tsx | Permission-driven workspace overview: server-authorized menu → entry directory |
These exist so the platform-sdk contract and the widget composition have working examples; extending the admin app means adding routes that follow the same composition pattern.
8. PWA configuration
config/pwa-options.ts configures vite-plugin-pwa: registerType: 'prompt', injectRegister: false, manifest name/short_name: 'FD WORK', display: 'standalone', start_url: '/', two SVG icons (any + maskable). Workbox precaches **/*.{js,mjs,css,html,svg,png,ico,jpg,webp} (no mp4 — large media such as the sign-in video stays out of the precache) up to 10 MB, with cleanupOutdatedCaches and clientsClaim on; skipWaiting: false (the prompt registration flow controls activation). /web/viewer.html* is in navigateFallbackDenyList.
9. Path aliases
apps/app/tsconfig.json extends tsconfig.base.json and maps @/* → ./src/*, plus the @bitz/components, @bitz/i18n, and @bitz/platform-sdk package roots. The remaining @bitz/* resolutions (widgets, editor, hooks, utils, materials, scripts) come from tsconfig.base.json, which maps every workspace package.
10. Reviewing a web-app change
- Confirm any new HTTP call goes through
usePlatform().api— not a page-levelfetch. - Confirm every new backend type is imported from
@bitz/platform-sdkand ultimately aliases a generated schema — never hand-written. - If a new data screen is added, prefer composing
@bitz/widgets(ServerDataTable,QueryState) over rewriting the states. - If a new route is added, give it
meta.requiresAuth/meta.permissionsoRouteGuardenforces it. - Run
yarn workspace @bitz/app buildbefore commit —tsc --noEmitruns first and fails on type errors, and the bundle must pass theverify-bundle-budget.mjssize gate.
11. Source review
# Confirm pages consume the SDK through context and widgets through the barrel.rg -n "usePlatform|from '@bitz/widgets'|from '@bitz/platform-sdk'" \ frontend/apps/app/src/pages
# Confirm the provider tree order and the conditional dev proxy.sed -n '1,40p' frontend/apps/app/src/main.tsxsed -n '1,70p' frontend/apps/app/vite.config.ts
# Confirm routing meta and RouteGuard wiring.rg -n "requiresAuth|permission|RouteGuard" frontend/apps/app/src/routes.tsx