2026-04-04 21:36:45 +02:00
# Phase 9: Frontend React Shell
**Date:** 2026-04-04
**Status:** Draft
**Depends on:** Phase 4 (Observability Pipeline + Inbound Routing)
**Gitea issue:** #31
## Context
2026-04-15 15:28:44 +02:00
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 cameleer-server observability dashboard is already served at `/dashboard` . But there is no management UI — all operations require curl/API calls.
2026-04-04 21:36:45 +02:00
2026-04-15 15:28:44 +02:00
Phase 9 adds the SaaS management shell: a React SPA for managing tenants, environments, apps, and deployments. The observability UI is already handled by cameleer-server — this shell covers everything else.
2026-04-04 21:36:45 +02:00
## Key Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
2026-04-15 15:28:44 +02:00
| Location | `ui/` directory in cameleer-saas repo | Matches cameleer-server pattern. Single build pipeline. Spring Boot serves the SPA. |
2026-04-04 21:36:45 +02:00
| Relationship to dashboard | Two separate SPAs, linked via navigation | SaaS shell at `/` , observability at `/dashboard` . Same design system = cohesive feel. No coupling. |
2026-04-15 15:28:44 +02:00
| Layout | Sidebar navigation | Consistent with cameleer-server dashboard. Same AppShell pattern from design system. |
2026-04-04 21:36:45 +02:00
| Auth | Shared Logto OIDC session | Same client ID, same localStorage keys. True SSO between SaaS shell and observability dashboard. |
2026-04-15 15:28:44 +02:00
| Tech stack | React 19 + Vite + React Router + Zustand + TanStack Query | Identical to cameleer-server SPA. Same patterns, same libraries, same conventions. |
2026-04-04 21:36:45 +02:00
| 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
2026-04-15 15:28:44 +02:00
5. Tokens stored with same keys as cameleer-server SPA: `cameleer-access-token` , `cameleer-refresh-token`
2026-04-04 21:36:45 +02:00
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
- `<RequirePermission permission="apps:deploy">` 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
2026-04-15 15:28:44 +02:00
- "View Dashboard" is an external link to `/dashboard` (cameleer-server SPA)
2026-04-04 21:36:45 +02:00
- 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/
2026-04-15 15:28:44 +02:00
│ │ ├── auth-store.ts — Zustand store (same keys as cameleer-server)
2026-04-04 21:36:45 +02:00
│ │ ├── 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
2026-04-15 15:28:44 +02:00
9. "View Dashboard" link navigates to `/dashboard` (cameleer-server SPA)
2026-04-04 21:36:45 +02:00
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
2026-04-15 15:28:44 +02:00
- No changes to cameleer-server or its SPA
2026-04-04 21:36:45 +02:00
- No billing UI (Phase 6)
- No team management (Logto org admin — deferred)
- No tenant settings/profile page
- No super-admin multi-tenant view