# 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
- `<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
- "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
