# Phase 9: Frontend React Shell **Date:** 2026-04-04 **Status:** Draft **Depends on:** Phase 4 (Observability Pipeline + Inbound Routing) **Gitea issue:** #31 ## Context Phases 1-4 built the complete backend: tenants, licensing, environments, app deployment with JAR upload, async deployment pipeline, container logs, agent status, observability status, and inbound HTTP routing. The cameleer3-server observability dashboard is already served at `/dashboard`. But there is no management UI — all operations require curl/API calls. Phase 9 adds the SaaS management shell: a React SPA for managing tenants, environments, apps, and deployments. The observability UI is already handled by cameleer3-server — this shell covers everything else. ## Key Decisions | Decision | Choice | Rationale | |----------|--------|-----------| | Location | `ui/` directory in cameleer-saas repo | Matches cameleer3-server pattern. Single build pipeline. Spring Boot serves the SPA. | | Relationship to dashboard | Two separate SPAs, linked via navigation | SaaS shell at `/`, observability at `/dashboard`. Same design system = cohesive feel. No coupling. | | Layout | Sidebar navigation | Consistent with cameleer3-server dashboard. Same AppShell pattern from design system. | | Auth | Shared Logto OIDC session | Same client ID, same localStorage keys. True SSO between SaaS shell and observability dashboard. | | Tech stack | React 19 + Vite + React Router + Zustand + TanStack Query | Identical to cameleer3-server SPA. Same patterns, same libraries, same conventions. | | Design system | `@cameleer/design-system` v0.1.31 | Shared component library. CSS Modules + design tokens. Dark theme. | | RBAC | Frontend role-based visibility | Roles from JWT claims. Hide/disable UI for unauthorized actions. Backend enforces — frontend is UX only. | ## Tech Stack - **React 19** + TypeScript - **Vite 8** (bundler + dev server) - **React Router 7** (client-side routing) - **Zustand** (auth state store) - **TanStack React Query** (data fetching + caching) - **@cameleer/design-system** (UI components) - **Lucide React** (icons) ## Auth Flow 1. User navigates to `/` — `ProtectedRoute` checks `useAuthStore.isAuthenticated` 2. If not authenticated, redirect to Logto OIDC authorize endpoint 3. Logto callback at `/callback` — exchange code for tokens 4. Store `accessToken`, `refreshToken`, `username`, `roles` in Zustand + localStorage 5. Tokens stored with same keys as cameleer3-server SPA: `cameleer-access-token`, `cameleer-refresh-token` 6. API client injects `Authorization: Bearer {token}` on all requests 7. On 401, attempt token refresh; on failure, redirect to login ## RBAC Model Roles from JWT or API response: | Role | Permissions | UI Access | |------|------------|-----------| | **OWNER** | All | Everything + tenant settings | | **ADMIN** | All except tenant:manage, billing:manage | Environments CRUD, apps CRUD, routing, deploy | | **DEVELOPER** | apps:deploy, secrets:manage, observe:read, observe:debug | Deploy, stop, restart, re-upload JAR, view logs | | **VIEWER** | observe:read | View-only: dashboard, app status, logs, deployment history | Frontend RBAC implementation: - `usePermissions()` hook reads roles from auth store, returns permission checks - `` wrapper component hides children if unauthorized - Buttons/actions disabled with tooltip "Insufficient permissions" for unauthorized roles - Navigation items hidden entirely if user has no access to any action on that page ## Pages ### Login (`/login`) - Logto OIDC redirect button - Handles callback at `/callback` - Stores tokens, redirects to `/` ### Dashboard (`/`) - Tenant overview: name, tier badge, license expiry - Environment count, total app count - Running/failed/stopped app summary (KPI strip) - Recent deployments table (last 10) - Quick actions: "New Environment", "View Observability Dashboard" - **All roles** can view ### Environments (`/environments`) - Table: name (display_name), slug, app count, status badge - "Create Environment" button (ADMIN+ only, enforces tier limit) - Click row → navigate to environment detail - **All roles** can view list ### Environment Detail (`/environments/:id`) - Environment name (editable inline for ADMIN+), slug, status - App list table: name, slug, deployment status, agent status, last deployed - "New App" button (DEVELOPER+ only) — opens JAR upload dialog - "Delete Environment" button (ADMIN+ only, disabled if apps exist) - **All roles** can view ### App Detail (`/environments/:eid/apps/:aid`) - Header: app name, slug, environment breadcrumb - **Status card**: current deployment status (BUILDING/STARTING/RUNNING/FAILED/STOPPED) with auto-refresh polling (3s) - **Agent status card**: registered/not, state, route IDs, link to observability dashboard - **JAR info**: filename, size, checksum, upload date - **Routing card**: exposed port, route URL (clickable), edit button (ADMIN+) - **Actions bar**: - Deploy (DEVELOPER+) — triggers new deployment - Stop (DEVELOPER+) - Restart (DEVELOPER+) - Re-upload JAR (DEVELOPER+) — file picker dialog - Delete app (ADMIN+) — confirmation dialog - **Deployment history**: table with version, status, timestamps, error messages - **Container logs**: LogViewer component from design system, auto-refresh, stream filter (stdout/stderr) - **All roles** can view status/logs/history ### License (`/license`) - Current tier badge, features enabled/disabled, limits - Expiry date, days remaining - **All roles** can view ## Sidebar Navigation ``` 🐪 Cameleer SaaS ───────────────── 📊 Dashboard 🌍 Environments └ {env-name} (expandable, shows apps) └ {app-name} 📄 License ───────────────── 👁 View Dashboard → (links to /dashboard) ───────────────── 🔒 Logged in as {name} Logout ``` - Sidebar uses `Sidebar` + `TreeView` components from design system - Environment → App hierarchy is collapsible - "View Dashboard" is an external link to `/dashboard` (cameleer3-server SPA) - Sidebar collapses on small screens (responsive) ## API Integration The SaaS shell talks to cameleer-saas REST API. All endpoints already exist from Phases 1-4. ### API Client Setup - Vite proxy: `/api` → `http://localhost:8080` (dev mode) - Production: Traefik routes `/api` to cameleer-saas - Auth middleware injects Bearer token - Handles 401/403 with refresh + redirect ### React Query Hooks ``` useTenant() → GET /api/tenants/{id} useLicense(tenantId) → GET /api/tenants/{tid}/license useEnvironments(tenantId) → GET /api/tenants/{tid}/environments useCreateEnvironment(tenantId) → POST /api/tenants/{tid}/environments useUpdateEnvironment(tenantId, eid) → PATCH /api/tenants/{tid}/environments/{eid} useDeleteEnvironment(tenantId, eid) → DELETE /api/tenants/{tid}/environments/{eid} useApps(environmentId) → GET /api/environments/{eid}/apps useCreateApp(environmentId) → POST /api/environments/{eid}/apps (multipart) useDeleteApp(environmentId, appId) → DELETE /api/environments/{eid}/apps/{aid} useUpdateRouting(environmentId, aid) → PATCH /api/environments/{eid}/apps/{aid}/routing useDeploy(appId) → POST /api/apps/{aid}/deploy useDeployments(appId) → GET /api/apps/{aid}/deployments useDeployment(appId, did) → GET /api/apps/{aid}/deployments/{did} (poll 3s) useStop(appId) → POST /api/apps/{aid}/stop useRestart(appId) → POST /api/apps/{aid}/restart useAgentStatus(appId) → GET /api/apps/{aid}/agent-status useObservabilityStatus(appId) → GET /api/apps/{aid}/observability-status useLogs(appId) → GET /api/apps/{aid}/logs ``` ## File Structure ``` ui/ ├── index.html ├── package.json ├── vite.config.ts ├── tsconfig.json ├── src/ │ ├── main.tsx — React root + providers │ ├── router.tsx — React Router config │ ├── auth/ │ │ ├── auth-store.ts — Zustand store (same keys as cameleer3-server) │ │ ├── LoginPage.tsx │ │ ├── CallbackPage.tsx │ │ └── ProtectedRoute.tsx │ ├── api/ │ │ ├── client.ts — fetch wrapper with auth middleware │ │ └── hooks.ts — React Query hooks for all endpoints │ ├── hooks/ │ │ └── usePermissions.ts — RBAC permission checks │ ├── components/ │ │ ├── RequirePermission.tsx — RBAC wrapper │ │ ├── Layout.tsx — AppShell + Sidebar + Breadcrumbs │ │ ├── EnvironmentTree.tsx — Sidebar tree (envs → apps) │ │ └── DeploymentStatusBadge.tsx │ ├── pages/ │ │ ├── DashboardPage.tsx │ │ ├── EnvironmentsPage.tsx │ │ ├── EnvironmentDetailPage.tsx │ │ ├── AppDetailPage.tsx │ │ └── LicensePage.tsx │ └── types/ │ └── api.ts — TypeScript types matching backend DTOs ``` ## Traefik Routing ```yaml cameleer-saas: labels: # Existing API routes: - traefik.http.routers.api.rule=PathPrefix(`/api`) - traefik.http.routers.forwardauth.rule=Path(`/auth/verify`) # New SPA route: - traefik.http.routers.spa.rule=PathPrefix(`/`) - traefik.http.routers.spa.priority=1 - traefik.http.services.spa.loadbalancer.server.port=8080 ``` Spring Boot serves the SPA from `src/main/resources/static/` (built by Vite into this directory). A catch-all controller returns `index.html` for all non-API routes (SPA client-side routing). ## Build Integration ### Vite Build → Spring Boot Static Resources ```bash # In ui/ npm run build # Output: ui/dist/ # Copy to Spring Boot static resources cp -r ui/dist/* src/main/resources/static/ ``` This can be automated in the Maven build via `frontend-maven-plugin` or a simple shell script in CI. ### CI Pipeline Add a `ui-build` step before `mvn verify`: 1. `cd ui && npm ci && npm run build` 2. Copy `ui/dist/` to `src/main/resources/static/` 3. `mvn clean verify` packages the SPA into the JAR ### Development ```bash # Terminal 1: backend mvn spring-boot:run # Terminal 2: frontend (Vite dev server with API proxy) cd ui && npm run dev ``` Vite dev server proxies `/api` to `localhost:8080`. ## SPA Catch-All Controller Spring Boot needs a catch-all to serve `index.html` for SPA routes: ```java @Controller public class SpaController { @GetMapping(value = {"/", "/login", "/callback", "/environments/**", "/license"}) public String spa() { return "forward:/index.html"; } } ``` This ensures React Router handles client-side routing. API routes (`/api/**`) are not caught — they go to the existing REST controllers. ## Design System Integration ```json { "dependencies": { "@cameleer/design-system": "0.1.31" } } ``` Registry configuration in `.npmrc`: ``` @cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/ ``` Import in `main.tsx`: ```tsx import '@cameleer/design-system/style.css'; import { ThemeProvider, ToastProvider, BreadcrumbProvider } from '@cameleer/design-system'; ``` ## Verification Plan 1. `npm run dev` starts Vite dev server, SPA loads at localhost:5173 2. Login redirects to Logto, callback stores tokens 3. Dashboard shows tenant overview with correct data from API 4. Environment list loads, create/rename/delete works (ADMIN+) 5. App upload (JAR + metadata) works, app appears in list 6. Deploy triggers async deployment, status polls and updates live 7. Agent status shows registered/connected 8. Container logs stream in LogViewer 9. "View Dashboard" link navigates to `/dashboard` (cameleer3-server SPA) 10. Shared auth: no re-login when switching between SPAs 11. RBAC: VIEWER cannot see deploy button, DEVELOPER cannot delete environments 12. Production build: `npm run build` + `mvn package` produces JAR with embedded SPA ## What Phase 9 Does NOT Touch - No changes to cameleer3-server or its SPA - No billing UI (Phase 6) - No team management (Logto org admin — deferred) - No tenant settings/profile page - No super-admin multi-tenant view