Skip to content
bitzorcas
中EN

Reference

Web admin app

@bitz/app is the React 19 + Vite 8 admin SPA. It composes widgets, the platform-sdk context, Jotai and React Query, and reaches the backend same-origin through a Vite dev proxy.

Last updated

@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

ModeBehavior
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 setFrontend calls the backend directly; the backend must allow the origin (CORS configured on the Host). The proxy block is absent in this mode.
yarn buildtsc --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.

vite.config.ts — proxy is conditional, not always present
// 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

VariableDefaultEffect
VITE_API_BASE_URL''Empty → dev-proxy mode (same-origin). Set → direct mode, backend must allow origin. Also feeds PlatformSdkConfig.baseUrl.
VITE_PROXY_TARGEThttp://localhost:6881Dev-proxy target for /api, /health, /hubs; code default 6881, but the repo .env.example pre-fills 5192 — adjust after copying.
VITE_CLIENT_PLATFORMWebSent as the X-Client-Platform header.
VITE_API_TIMEOUT_MS30000Per-request timeout (ms), feeds PlatformSdkConfig.defaultTimeoutMs.
VITE_LOGIN_TEMPLATElegal-misSign-in template: mini-program, website-admin, business, or legal-mis.
VITE_BRAND_THEMElegal-navyFrozen brand theme; invalid values fall back to the default.
VITE_COLOR_MODEsystemlight, dark, or system.
VITE_DENSITYdensedense, compact, comfortable, or spacious.
VITE_SHELL_PRESETsideAuthenticated 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:

ConcernLibraryRole in the app
Routingreact-router-dom 7lazy routes via import.meta.glob; RouteGuard wraps authenticated routes
Client stateJotaiatoms for UI-local state
URL statenuqsquery-param state in the URL (sorting, filters)
Server state@tanstack/react-query 5the only data-fetching surface; cache keys derive from API calls
Auth/HTTP@bitz/platform-sdkmounted once as PlatformProvider; pages read usePlatform()
UI primitives@bitz/componentsshadcn primitive barrel (dozens of atoms) + cn
Composite UI@bitz/widgetsAppShell, ServerDataTable, FormField, ConfirmAction, states
StylingTailwind v4@tailwindcss/vite
PWAvite-plugin-pwaregisterType: prompt; manifest name FD WORK
DXunplugin-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:

RoutemetaWhat renders
/loginPublicDesign System 1.2 sign-in screen: credentials, CAPTCHA, MFA, external providers
/forgot-password, /reset-passwordPublicPassword recovery and reset
/accept-invitation, /activatePublicInvitation acceptance and account activation
/verify-email, /verify-phonePublicContact confirmation
/external-login/complete, /session-expiredPublicExternal-sign-in completion and session recovery
/requiresAuth<RouteGuard> → <AppShell>
/ (index)title: Workspace overview, requiresAuthhome — permission-driven workspace directory rendering api.getNavigation()
/userspermission: ['identity.user.search']user management list
/change-passwordrequiresAuthVoluntary or forced password change
/settings/securityrequiresAuthMFA, verification, and session security
/identity/accessrequiresAuthInvitation and admission management
/identity/organizationidentity.organization-unit.viewLarge organization directory, bulk move, and same-name child creation
/identity/applicationsplatform.oauth.manage etc.OAuth, API key, and HMAC application credentials
/host/tenantspermission: ['operations.tenants.view'], surface: hostTenant governance (Host surface)
*title: Page not found, requiresAuthlazy 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:

WidgetHides
AppShell + navigation + HeaderBarSide/Top shells; server-authorized navigation; collapsing Page Context; real notification center; account menu and sign-out
WorkspacePage + WorkspaceLocalTabspage safe area, module-navigation deduplication, Page Header collapse, and sticky in-page tabs
ServerDataTableserver-side pagination, sorting, column model, and the PlatformResult mapping
FormFieldlabel/error/help wiring for text inputs
ConfirmActiondestructive-action confirmation flow
QueryState / EmptyState / ErrorStatethe loading/empty/error branches of any query (ErrorState maps AppErrorType → i18n key)
PermissionGatedeclarative permission gating (widgets wrapper around the SDK gate)

ServerDataTable<T> props

PropTypeNotes
query{ isLoading, isError, error, data?: PlatformResult<Page<T>>, refetch? }a TanStack Query result shape
columnsreadonly DataTableColumn<T>[]{ key, header, cell, sortable?, className? }
getRowId(row: T) => stringrow key
pageIndexnumber1-based
pageSizenumber
onPageChange(pageIndex: number) => void
sortFieldstring | nullnull = unsorted
sortOrder'asc' | 'desc'
onSortChange(sortField, sortOrder) => voidclick 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

PropTypeDefault
name, label, value, onChangerequired
error?stringdrives 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

PropTypeDefault
trigger, title, onConfirmrequired (onConfirm: () => Promise<void>)
description?string
confirmText? / cancelText?string'Confirm' / 'Cancel'
destructive?booleantrue

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:

Button-level gating (pages/users/users-list.tsx)
import { PermissionGate } from "@bitz/widgets";
<PermissionGate require="identity.user.search" showFallback>
{/* the whole users page */}
</PermissionGate>;
  • Route-level: RouteGuard reads meta.permission from routes.tsx and redirects unauthorized users.
  • Button-level: the PermissionGate widget hides/enables UI based on currentUser?.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

FileWhat it demonstrates
pages/login/login.tsxFull 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.tsxthe reference data screen: ServerDataTable + usePlatform().api.searchUsers + ConfirmAction for enable/disable, URL-synced params
pages/home.tsxPermission-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

  1. Confirm any new HTTP call goes through usePlatform().api — not a page-level fetch.
  2. Confirm every new backend type is imported from @bitz/platform-sdk and ultimately aliases a generated schema — never hand-written.
  3. If a new data screen is added, prefer composing @bitz/widgets (ServerDataTable, QueryState) over rewriting the states.
  4. If a new route is added, give it meta.requiresAuth / meta.permission so RouteGuard enforces it.
  5. Run yarn workspace @bitz/app build before commit — tsc --noEmit runs first and fails on type errors, and the bundle must pass the verify-bundle-budget.mjs size gate.

11. Source review

Terminal window
# 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.tsx
sed -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

Back to Frontend · Platform contract layer · Mobile shell

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%