diff --git a/docs/superpowers/plans/2026-04-04-phase-9-frontend-react-shell.md b/docs/superpowers/plans/2026-04-04-phase-9-frontend-react-shell.md
new file mode 100644
index 0000000..159623f
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-04-phase-9-frontend-react-shell.md
@@ -0,0 +1,1167 @@
+# Phase 9: Frontend React Shell — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build a React SPA for managing tenants, environments, apps, and deployments. All backend APIs exist — this is the UI layer.
+
+**Architecture:** React 19 + Vite + React Router + Zustand + TanStack Query + @cameleer/design-system. Sidebar layout matching cameleer3-server SPA. Shared Logto OIDC session. RBAC on all actions. Lives in `ui/` directory, built into Spring Boot static resources.
+
+**Tech Stack:** React 19, Vite 8, TypeScript, React Router 7, Zustand, TanStack React Query, @cameleer/design-system 0.1.31, Lucide React
+
+---
+
+## File Structure
+
+All new files under `ui/`:
+
+```
+ui/
+├── index.html
+├── package.json
+├── vite.config.ts
+├── tsconfig.json
+├── .npmrc
+├── src/
+│ ├── main.tsx
+│ ├── router.tsx
+│ ├── auth/
+│ │ ├── auth-store.ts
+│ │ ├── LoginPage.tsx
+│ │ ├── CallbackPage.tsx
+│ │ └── ProtectedRoute.tsx
+│ ├── api/
+│ │ ├── client.ts
+│ │ └── hooks.ts
+│ ├── hooks/
+│ │ └── usePermissions.ts
+│ ├── components/
+│ │ ├── RequirePermission.tsx
+│ │ ├── Layout.tsx
+│ │ ├── EnvironmentTree.tsx
+│ │ └── DeploymentStatusBadge.tsx
+│ ├── pages/
+│ │ ├── DashboardPage.tsx
+│ │ ├── EnvironmentsPage.tsx
+│ │ ├── EnvironmentDetailPage.tsx
+│ │ ├── AppDetailPage.tsx
+│ │ └── LicensePage.tsx
+│ └── types/
+│ └── api.ts
+```
+
+Also modify:
+- `src/main/java/.../config/SpaController.java` (new — catch-all for SPA routes)
+- `docker-compose.yml` (Traefik SPA route)
+- `.gitea/workflows/ci.yml` (add frontend build step)
+- `HOWTO.md` (add frontend dev instructions)
+
+---
+
+## Task 1: Project Scaffolding
+
+**Files:**
+- Create: `ui/package.json`
+- Create: `ui/.npmrc`
+- Create: `ui/index.html`
+- Create: `ui/vite.config.ts`
+- Create: `ui/tsconfig.json`
+- Create: `ui/src/main.tsx`
+- Create: `ui/src/types/api.ts`
+
+- [ ] **Step 1: Create package.json**
+
+```json
+{
+ "name": "cameleer-saas-ui",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@cameleer/design-system": "0.1.31",
+ "@tanstack/react-query": "^5.90.0",
+ "lucide-react": "^1.7.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "react-router": "^7.13.0",
+ "zustand": "^5.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@vitejs/plugin-react": "^4.4.0",
+ "typescript": "^5.9.0",
+ "vite": "^6.3.0"
+ }
+}
+```
+
+- [ ] **Step 2: Create .npmrc**
+
+```
+@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
+```
+
+- [ ] **Step 3: Create index.html**
+
+```html
+
+
+
+
+
+ Cameleer SaaS
+
+
+
+
+
+
+```
+
+- [ ] **Step 4: Create vite.config.ts**
+
+```typescript
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:8080',
+ changeOrigin: true,
+ },
+ },
+ },
+ build: {
+ outDir: '../src/main/resources/static',
+ emptyOutDir: true,
+ },
+});
+```
+
+- [ ] **Step 5: Create tsconfig.json**
+
+```json
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false
+ },
+ "include": ["src"]
+}
+```
+
+- [ ] **Step 6: Create src/types/api.ts**
+
+TypeScript types matching backend DTOs:
+
+```typescript
+export interface TenantResponse {
+ id: string;
+ name: string;
+ slug: string;
+ tier: string;
+ status: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface EnvironmentResponse {
+ id: string;
+ tenantId: string;
+ slug: string;
+ displayName: string;
+ status: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface AppResponse {
+ id: string;
+ environmentId: string;
+ slug: string;
+ displayName: string;
+ jarOriginalFilename: string | null;
+ jarSizeBytes: number | null;
+ jarChecksum: string | null;
+ exposedPort: number | null;
+ routeUrl: string | null;
+ currentDeploymentId: string | null;
+ previousDeploymentId: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface DeploymentResponse {
+ id: string;
+ appId: string;
+ version: number;
+ imageRef: string;
+ desiredStatus: string;
+ observedStatus: string;
+ errorMessage: string | null;
+ orchestratorMetadata: Record;
+ deployedAt: string | null;
+ stoppedAt: string | null;
+ createdAt: string;
+}
+
+export interface LicenseResponse {
+ id: string;
+ tenantId: string;
+ tier: string;
+ features: Record;
+ limits: Record;
+ issuedAt: string;
+ expiresAt: string;
+ token: string;
+}
+
+export interface AgentStatusResponse {
+ registered: boolean;
+ state: string;
+ lastHeartbeat: string | null;
+ routeIds: string[];
+ applicationId: string;
+ environmentId: string;
+}
+
+export interface ObservabilityStatusResponse {
+ hasTraces: boolean;
+ hasMetrics: boolean;
+ hasDiagrams: boolean;
+ lastTraceAt: string | null;
+ traceCount24h: number;
+}
+
+export interface LogEntry {
+ appId: string;
+ deploymentId: string;
+ timestamp: string;
+ stream: string;
+ message: string;
+}
+```
+
+- [ ] **Step 7: Create src/main.tsx**
+
+```tsx
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { BrowserRouter } from 'react-router';
+import { ThemeProvider, ToastProvider, BreadcrumbProvider } from '@cameleer/design-system';
+import '@cameleer/design-system/style.css';
+import { AppRouter } from './router';
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: 1,
+ staleTime: 10_000,
+ },
+ },
+});
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+```
+
+- [ ] **Step 8: Install dependencies**
+
+```bash
+cd ui && npm install
+```
+
+- [ ] **Step 9: Verify dev server starts**
+
+```bash
+cd ui && npx vite --host 2>&1 | head -5
+```
+
+Expected: Vite dev server starts on port 5173.
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add ui/
+git commit -m "feat: scaffold React SPA with Vite, design system, and TypeScript types"
+```
+
+---
+
+## Task 2: Auth Store + Login + Protected Route
+
+**Files:**
+- Create: `ui/src/auth/auth-store.ts`
+- Create: `ui/src/auth/LoginPage.tsx`
+- Create: `ui/src/auth/CallbackPage.tsx`
+- Create: `ui/src/auth/ProtectedRoute.tsx`
+
+- [ ] **Step 1: Create auth-store.ts**
+
+Zustand store for auth state. Same localStorage keys as cameleer3-server SPA for SSO.
+
+```typescript
+import { create } from 'zustand';
+
+interface AuthState {
+ accessToken: string | null;
+ refreshToken: string | null;
+ username: string | null;
+ roles: string[];
+ isAuthenticated: boolean;
+ login: (accessToken: string, refreshToken: string) => void;
+ logout: () => void;
+ loadFromStorage: () => void;
+}
+
+function parseJwt(token: string): Record {
+ try {
+ const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
+ return JSON.parse(atob(base64));
+ } catch {
+ return {};
+ }
+}
+
+export const useAuthStore = create((set) => ({
+ accessToken: null,
+ refreshToken: null,
+ username: null,
+ roles: [],
+ isAuthenticated: false,
+
+ login: (accessToken: string, refreshToken: string) => {
+ localStorage.setItem('cameleer-access-token', accessToken);
+ localStorage.setItem('cameleer-refresh-token', refreshToken);
+ const claims = parseJwt(accessToken);
+ const username = (claims.sub as string) || (claims.email as string) || 'user';
+ const roles = (claims.roles as string[]) || [];
+ localStorage.setItem('cameleer-username', username);
+ set({ accessToken, refreshToken, username, roles, isAuthenticated: true });
+ },
+
+ logout: () => {
+ localStorage.removeItem('cameleer-access-token');
+ localStorage.removeItem('cameleer-refresh-token');
+ localStorage.removeItem('cameleer-username');
+ set({ accessToken: null, refreshToken: null, username: null, roles: [], isAuthenticated: false });
+ },
+
+ loadFromStorage: () => {
+ const accessToken = localStorage.getItem('cameleer-access-token');
+ const refreshToken = localStorage.getItem('cameleer-refresh-token');
+ const username = localStorage.getItem('cameleer-username');
+ if (accessToken) {
+ const claims = parseJwt(accessToken);
+ const roles = (claims.roles as string[]) || [];
+ set({ accessToken, refreshToken, username, roles, isAuthenticated: true });
+ }
+ },
+}));
+```
+
+- [ ] **Step 2: Create LoginPage.tsx**
+
+```tsx
+import { useEffect } from 'react';
+import { Button } from '@cameleer/design-system';
+
+export function LoginPage() {
+ const logtoEndpoint = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001';
+ const clientId = import.meta.env.VITE_LOGTO_CLIENT_ID || '';
+ const redirectUri = `${window.location.origin}/callback`;
+
+ const handleLogin = () => {
+ const params = new URLSearchParams({
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ response_type: 'code',
+ scope: 'openid profile email offline_access',
+ });
+ window.location.href = `${logtoEndpoint}/oidc/auth?${params}`;
+ };
+
+ return (
+
+
+
Cameleer SaaS
+
+ Managed Apache Camel Runtime
+
+
+
+
+ );
+}
+```
+
+- [ ] **Step 3: Create CallbackPage.tsx**
+
+```tsx
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router';
+import { useAuthStore } from './auth-store';
+import { Spinner } from '@cameleer/design-system';
+
+export function CallbackPage() {
+ const navigate = useNavigate();
+ const login = useAuthStore((s) => s.login);
+
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ const code = params.get('code');
+ if (!code) {
+ navigate('/login');
+ return;
+ }
+
+ const logtoEndpoint = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001';
+ const clientId = import.meta.env.VITE_LOGTO_CLIENT_ID || '';
+ const redirectUri = `${window.location.origin}/callback`;
+
+ fetch(`${logtoEndpoint}/oidc/token`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ grant_type: 'authorization_code',
+ code,
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ }),
+ })
+ .then((r) => r.json())
+ .then((data) => {
+ if (data.access_token) {
+ login(data.access_token, data.refresh_token || '');
+ navigate('/');
+ } else {
+ navigate('/login');
+ }
+ })
+ .catch(() => navigate('/login'));
+ }, [login, navigate]);
+
+ return (
+
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Create ProtectedRoute.tsx**
+
+```tsx
+import { Navigate } from 'react-router';
+import { useAuthStore } from './auth-store';
+
+export function ProtectedRoute({ children }: { children: React.ReactNode }) {
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+ if (!isAuthenticated) return ;
+ return <>{children}>;
+}
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ui/src/auth/
+git commit -m "feat: add auth store, login, callback, and protected route"
+```
+
+---
+
+## Task 3: API Client + React Query Hooks
+
+**Files:**
+- Create: `ui/src/api/client.ts`
+- Create: `ui/src/api/hooks.ts`
+
+- [ ] **Step 1: Create client.ts**
+
+Fetch wrapper with Bearer token injection and 401 handling.
+
+```typescript
+import { useAuthStore } from '../auth/auth-store';
+
+const API_BASE = '/api';
+
+async function apiFetch(path: string, options: RequestInit = {}): Promise {
+ const token = useAuthStore.getState().accessToken;
+ const headers: Record = {
+ ...(options.headers as Record || {}),
+ };
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`;
+ }
+ if (!headers['Content-Type'] && !(options.body instanceof FormData)) {
+ headers['Content-Type'] = 'application/json';
+ }
+
+ const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
+
+ if (response.status === 401) {
+ useAuthStore.getState().logout();
+ window.location.href = '/login';
+ throw new Error('Unauthorized');
+ }
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`API error ${response.status}: ${text}`);
+ }
+
+ if (response.status === 204) return undefined as T;
+ return response.json();
+}
+
+export const api = {
+ get: (path: string) => apiFetch(path),
+ post: (path: string, body?: unknown) =>
+ apiFetch(path, {
+ method: 'POST',
+ body: body instanceof FormData ? body : JSON.stringify(body),
+ }),
+ patch: (path: string, body: unknown) =>
+ apiFetch(path, { method: 'PATCH', body: JSON.stringify(body) }),
+ put: (path: string, body: FormData) =>
+ apiFetch(path, { method: 'PUT', body }),
+ delete: (path: string) => apiFetch(path, { method: 'DELETE' }),
+};
+```
+
+- [ ] **Step 2: Create hooks.ts**
+
+All React Query hooks for the backend API:
+
+```typescript
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { api } from './client';
+import type {
+ TenantResponse, EnvironmentResponse, AppResponse,
+ DeploymentResponse, LicenseResponse, AgentStatusResponse,
+ ObservabilityStatusResponse, LogEntry,
+} from '../types/api';
+
+// Tenant
+export function useTenant(tenantId: string) {
+ return useQuery({
+ queryKey: ['tenant', tenantId],
+ queryFn: () => api.get(`/tenants/${tenantId}`),
+ enabled: !!tenantId,
+ });
+}
+
+// License
+export function useLicense(tenantId: string) {
+ return useQuery({
+ queryKey: ['license', tenantId],
+ queryFn: () => api.get(`/tenants/${tenantId}/license`),
+ enabled: !!tenantId,
+ });
+}
+
+// Environments
+export function useEnvironments(tenantId: string) {
+ return useQuery({
+ queryKey: ['environments', tenantId],
+ queryFn: () => api.get(`/tenants/${tenantId}/environments`),
+ enabled: !!tenantId,
+ });
+}
+
+export function useCreateEnvironment(tenantId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (data: { slug: string; displayName: string }) =>
+ api.post(`/tenants/${tenantId}/environments`, data),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['environments', tenantId] }),
+ });
+}
+
+export function useUpdateEnvironment(tenantId: string, envId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (data: { displayName: string }) =>
+ api.patch(`/tenants/${tenantId}/environments/${envId}`, data),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['environments', tenantId] }),
+ });
+}
+
+export function useDeleteEnvironment(tenantId: string, envId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: () => api.delete(`/tenants/${tenantId}/environments/${envId}`),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['environments', tenantId] }),
+ });
+}
+
+// Apps
+export function useApps(environmentId: string) {
+ return useQuery({
+ queryKey: ['apps', environmentId],
+ queryFn: () => api.get(`/environments/${environmentId}/apps`),
+ enabled: !!environmentId,
+ });
+}
+
+export function useApp(environmentId: string, appId: string) {
+ return useQuery({
+ queryKey: ['app', appId],
+ queryFn: () => api.get(`/environments/${environmentId}/apps/${appId}`),
+ enabled: !!appId,
+ });
+}
+
+export function useCreateApp(environmentId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (formData: FormData) =>
+ api.post(`/environments/${environmentId}/apps`, formData),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['apps', environmentId] }),
+ });
+}
+
+export function useDeleteApp(environmentId: string, appId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: () => api.delete(`/environments/${environmentId}/apps/${appId}`),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['apps', environmentId] }),
+ });
+}
+
+export function useUpdateRouting(environmentId: string, appId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (data: { exposedPort: number | null }) =>
+ api.patch(`/environments/${environmentId}/apps/${appId}/routing`, data),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['app', appId] }),
+ });
+}
+
+// Deployments
+export function useDeploy(appId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: () => api.post(`/apps/${appId}/deploy`),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }),
+ });
+}
+
+export function useDeployments(appId: string) {
+ return useQuery({
+ queryKey: ['deployments', appId],
+ queryFn: () => api.get(`/apps/${appId}/deployments`),
+ enabled: !!appId,
+ });
+}
+
+export function useDeployment(appId: string, deploymentId: string) {
+ return useQuery({
+ queryKey: ['deployment', deploymentId],
+ queryFn: () => api.get(`/apps/${appId}/deployments/${deploymentId}`),
+ enabled: !!deploymentId,
+ refetchInterval: (query) => {
+ const status = query.state.data?.observedStatus;
+ return status === 'BUILDING' || status === 'STARTING' ? 3000 : false;
+ },
+ });
+}
+
+export function useStop(appId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: () => api.post(`/apps/${appId}/stop`),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ['deployments', appId] });
+ qc.invalidateQueries({ queryKey: ['app'] });
+ },
+ });
+}
+
+export function useRestart(appId: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: () => api.post(`/apps/${appId}/restart`),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }),
+ });
+}
+
+// Observability
+export function useAgentStatus(appId: string) {
+ return useQuery({
+ queryKey: ['agent-status', appId],
+ queryFn: () => api.get(`/apps/${appId}/agent-status`),
+ enabled: !!appId,
+ refetchInterval: 15_000,
+ });
+}
+
+export function useObservabilityStatus(appId: string) {
+ return useQuery({
+ queryKey: ['observability-status', appId],
+ queryFn: () => api.get(`/apps/${appId}/observability-status`),
+ enabled: !!appId,
+ refetchInterval: 30_000,
+ });
+}
+
+export function useLogs(appId: string, params?: { since?: string; limit?: number; stream?: string }) {
+ return useQuery({
+ queryKey: ['logs', appId, params],
+ queryFn: () => {
+ const qs = new URLSearchParams();
+ if (params?.since) qs.set('since', params.since);
+ if (params?.limit) qs.set('limit', String(params.limit));
+ if (params?.stream) qs.set('stream', params.stream);
+ const query = qs.toString();
+ return api.get(`/apps/${appId}/logs${query ? `?${query}` : ''}`);
+ },
+ enabled: !!appId,
+ });
+}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add ui/src/api/
+git commit -m "feat: add API client with auth middleware and React Query hooks"
+```
+
+---
+
+## Task 4: RBAC Hooks + Components
+
+**Files:**
+- Create: `ui/src/hooks/usePermissions.ts`
+- Create: `ui/src/components/RequirePermission.tsx`
+- Create: `ui/src/components/DeploymentStatusBadge.tsx`
+
+- [ ] **Step 1: Create usePermissions.ts**
+
+```typescript
+import { useAuthStore } from '../auth/auth-store';
+
+const ROLE_PERMISSIONS: Record = {
+ OWNER: ['tenant:manage', 'billing:manage', 'team:manage', 'apps:manage', 'apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug', 'settings:manage'],
+ ADMIN: ['team:manage', 'apps:manage', 'apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug', 'settings:manage'],
+ DEVELOPER: ['apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug'],
+ VIEWER: ['observe:read'],
+};
+
+export function usePermissions() {
+ const roles = useAuthStore((s) => s.roles);
+
+ const permissions = new Set();
+ for (const role of roles) {
+ const perms = ROLE_PERMISSIONS[role];
+ if (perms) perms.forEach((p) => permissions.add(p));
+ }
+
+ return {
+ has: (permission: string) => permissions.has(permission),
+ canManageApps: permissions.has('apps:manage'),
+ canDeploy: permissions.has('apps:deploy'),
+ canManageTenant: permissions.has('tenant:manage'),
+ canViewObservability: permissions.has('observe:read'),
+ roles,
+ };
+}
+```
+
+- [ ] **Step 2: Create RequirePermission.tsx**
+
+```tsx
+import { usePermissions } from '../hooks/usePermissions';
+
+interface Props {
+ permission: string;
+ children: React.ReactNode;
+ fallback?: React.ReactNode;
+}
+
+export function RequirePermission({ permission, children, fallback }: Props) {
+ const { has } = usePermissions();
+ if (!has(permission)) return fallback ? <>{fallback}> : null;
+ return <>{children}>;
+}
+```
+
+- [ ] **Step 3: Create DeploymentStatusBadge.tsx**
+
+```tsx
+import { Badge } from '@cameleer/design-system';
+
+const STATUS_COLORS: Record = {
+ BUILDING: 'warning',
+ STARTING: 'warning',
+ RUNNING: 'success',
+ FAILED: 'error',
+ STOPPED: 'default',
+};
+
+export function DeploymentStatusBadge({ status }: { status: string }) {
+ const variant = STATUS_COLORS[status] || 'default';
+ return {status};
+}
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add ui/src/hooks/ ui/src/components/RequirePermission.tsx ui/src/components/DeploymentStatusBadge.tsx
+git commit -m "feat: add RBAC hooks and permission-gated components"
+```
+
+---
+
+## Task 5: Layout + Router
+
+**Files:**
+- Create: `ui/src/components/Layout.tsx`
+- Create: `ui/src/components/EnvironmentTree.tsx`
+- Create: `ui/src/router.tsx`
+
+- [ ] **Step 1: Create Layout.tsx**
+
+The main layout using AppShell + Sidebar from the design system. This provides the sidebar navigation shell wrapping all protected pages.
+
+The sidebar contains:
+- Logo / brand
+- Dashboard link
+- Environments section with expandable tree (EnvironmentTree component)
+- License link
+- Divider
+- "View Dashboard" external link to `/dashboard`
+- User info + logout at bottom
+
+Use the `AppShell` and `Sidebar` components from the design system. If those components expect specific props, adapt accordingly — read the design system exports to understand the expected API.
+
+The layout wraps a React Router `` for nested routes.
+
+- [ ] **Step 2: Create EnvironmentTree.tsx**
+
+Fetches environments + apps and renders a collapsible tree in the sidebar. Each environment expands to show its apps. Clicking an app navigates to the app detail page.
+
+Uses the `TreeView` component from the design system if available, or a simple nested list otherwise.
+
+- [ ] **Step 3: Create router.tsx**
+
+```tsx
+import { Routes, Route } from 'react-router';
+import { useEffect } from 'react';
+import { useAuthStore } from './auth/auth-store';
+import { LoginPage } from './auth/LoginPage';
+import { CallbackPage } from './auth/CallbackPage';
+import { ProtectedRoute } from './auth/ProtectedRoute';
+import { Layout } from './components/Layout';
+import { DashboardPage } from './pages/DashboardPage';
+import { EnvironmentsPage } from './pages/EnvironmentsPage';
+import { EnvironmentDetailPage } from './pages/EnvironmentDetailPage';
+import { AppDetailPage } from './pages/AppDetailPage';
+import { LicensePage } from './pages/LicensePage';
+
+export function AppRouter() {
+ const loadFromStorage = useAuthStore((s) => s.loadFromStorage);
+ useEffect(() => { loadFromStorage(); }, [loadFromStorage]);
+
+ return (
+
+ } />
+ } />
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add ui/src/components/Layout.tsx ui/src/components/EnvironmentTree.tsx ui/src/router.tsx
+git commit -m "feat: add sidebar layout, environment tree, and router"
+```
+
+---
+
+## Task 6: Dashboard Page
+
+**Files:**
+- Create: `ui/src/pages/DashboardPage.tsx`
+
+- [ ] **Step 1: Create DashboardPage.tsx**
+
+Shows tenant overview:
+- Tenant name + tier badge
+- KPI strip: environment count, total apps, running apps, failed apps
+- Recent deployments table (across all environments)
+- Quick action buttons: "New Environment" (ADMIN+), "View Observability Dashboard" (link to /dashboard)
+
+Uses design system components: `Card`, `StatCard`/`KpiStrip`, `DataTable`, `Badge`, `Button`.
+
+The page needs the tenant ID. Since we're in single-tenant Docker mode, the tenant can be fetched via the first tenant from the API, or stored in a context after login. For simplicity, fetch environments list and derive stats from it. Use `useEnvironments` and `useApps` hooks.
+
+**Implementation approach:** Fetch all environments for the tenant. For each environment, fetch apps. Aggregate counts. Display in KPI strip + table.
+
+Note: The tenant ID needs to come from somewhere. Options:
+- Extract from the JWT `organization_id` claim
+- Fetch `/api/tenants` and use the first result
+- Store in a tenant context after initial load
+
+Use the JWT `organization_id` claim approach — the auth store already parses the JWT. Add `tenantId` to the auth store extracted from the `organization_id` claim. If not present, fall back to fetching the first tenant from the API.
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add ui/src/pages/DashboardPage.tsx
+git commit -m "feat: add dashboard page with tenant overview and KPI stats"
+```
+
+---
+
+## Task 7: Environments Page + Environment Detail Page
+
+**Files:**
+- Create: `ui/src/pages/EnvironmentsPage.tsx`
+- Create: `ui/src/pages/EnvironmentDetailPage.tsx`
+
+- [ ] **Step 1: Create EnvironmentsPage.tsx**
+
+List of environments with:
+- DataTable: display name, slug, status, app count, created date
+- "Create Environment" button (ADMIN+ only) — opens a modal/dialog with slug + display name fields
+- Click row → navigate to `/environments/:id`
+
+Uses: `DataTable`, `Button`, `Modal`, `RequirePermission`.
+
+- [ ] **Step 2: Create EnvironmentDetailPage.tsx**
+
+Environment detail with:
+- Header: display name (inline editable for ADMIN+), slug, status badge
+- App list table: display name, slug, deployment status, agent status, last deployed
+- "New App" button (DEVELOPER+) — opens JAR upload dialog (file picker + slug/displayName fields)
+- "Delete Environment" button (ADMIN+, disabled if apps exist)
+- Click app row → navigate to `/environments/:eid/apps/:aid`
+
+Uses: `Card`, `DataTable`, `Button`, `Modal`, `InlineEdit`, `RequirePermission`, `DeploymentStatusBadge`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add ui/src/pages/EnvironmentsPage.tsx ui/src/pages/EnvironmentDetailPage.tsx
+git commit -m "feat: add environments list and environment detail pages"
+```
+
+---
+
+## Task 8: App Detail Page
+
+**Files:**
+- Create: `ui/src/pages/AppDetailPage.tsx`
+
+- [ ] **Step 1: Create AppDetailPage.tsx**
+
+The main app management page with sections:
+
+**Header:** App name, slug, breadcrumb (Environment > App)
+
+**Status card:** Current deployment status with auto-refreshing badge. Shows version number, image ref. Polls every 3s while BUILDING/STARTING.
+
+**Action bar:** Deploy, Stop, Restart buttons (DEVELOPER+). Re-upload JAR button (DEVELOPER+). Delete app button (ADMIN+). Each action uses the corresponding mutation hook with confirmation dialogs for destructive actions.
+
+**Agent status card:** Registered/not, state badge, route IDs list. "View in Dashboard" link to `/dashboard`.
+
+**Routing card:** Exposed port, route URL (clickable link). "Edit Routing" button (ADMIN+) with port input.
+
+**Deployment history:** DataTable with version, status badge, deployed/stopped timestamps, error message.
+
+**Container logs:** LogViewer or simple code block showing log lines. Stream filter (stdout/stderr/both). Limit control. Auto-refresh.
+
+Uses: `Card`, `Button`, `DataTable`, `Badge`, `CodeBlock`, `Modal`, `ConfirmDialog`, `Tabs`, `RequirePermission`, `DeploymentStatusBadge`, and the mutation hooks (useDeploy, useStop, useRestart, useDeleteApp, useUpdateRouting).
+
+This is the largest page. Keep it well-structured with clear sections.
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add ui/src/pages/AppDetailPage.tsx
+git commit -m "feat: add app detail page with deploy, logs, and status"
+```
+
+---
+
+## Task 9: License Page
+
+**Files:**
+- Create: `ui/src/pages/LicensePage.tsx`
+
+- [ ] **Step 1: Create LicensePage.tsx**
+
+Simple page showing:
+- Tier badge (LOW/MID/HIGH/BUSINESS)
+- Feature matrix: topology, lineage, correlation, debugger, replay — each with enabled/disabled badge
+- Limits: max agents, retention days, max environments
+- Expiry date + days remaining
+- License token (collapsed, expandable code block)
+
+Uses: `Card`, `Badge`, `CodeBlock`.
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add ui/src/pages/LicensePage.tsx
+git commit -m "feat: add license page with tier features and limits"
+```
+
+---
+
+## Task 10: Spring Boot SPA Controller + Traefik + CI
+
+**Files:**
+- Create: `src/main/java/net/siegeln/cameleer/saas/config/SpaController.java`
+- Modify: `docker-compose.yml`
+- Modify: `.gitea/workflows/ci.yml`
+- Modify: `HOWTO.md`
+
+- [ ] **Step 1: Create SpaController.java**
+
+```java
+package net.siegeln.cameleer.saas.config;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+
+@Controller
+public class SpaController {
+
+ @GetMapping(value = {"/", "/login", "/callback", "/environments/**", "/license"})
+ public String spa() {
+ return "forward:/index.html";
+ }
+}
+```
+
+- [ ] **Step 2: Update docker-compose.yml — add SPA route**
+
+Add to cameleer-saas labels (lowest priority, catches everything not matched by other routes):
+
+```yaml
+ - traefik.http.routers.spa.rule=PathPrefix(`/`)
+ - traefik.http.routers.spa.priority=1
+ - traefik.http.services.spa.loadbalancer.server.port=8080
+```
+
+- [ ] **Step 3: Update CI pipeline**
+
+Add frontend build step before Maven in the build job:
+
+```yaml
+ - name: Build Frontend
+ run: |
+ cd ui
+ npm ci
+ npm run build
+```
+
+This outputs to `src/main/resources/static/` (configured in vite.config.ts). The subsequent `mvn clean verify` packages the SPA into the JAR.
+
+Add Vite environment variables for the build:
+
+```yaml
+ env:
+ VITE_LOGTO_ENDPOINT: ${{ secrets.LOGTO_ENDPOINT }}
+ VITE_LOGTO_CLIENT_ID: ${{ secrets.LOGTO_CLIENT_ID }}
+```
+
+- [ ] **Step 4: Update HOWTO.md**
+
+Add a "Frontend Development" section after the existing "Development" section:
+
+- How to run the frontend dev server (`cd ui && npm run dev`)
+- How to build for production (`cd ui && npm run build`)
+- Environment variables needed: `VITE_LOGTO_ENDPOINT`, `VITE_LOGTO_CLIENT_ID`
+- Note about Vite proxy for API calls in dev mode
+
+- [ ] **Step 5: Verify Maven compilation**
+
+```bash
+mvn compile -B -q
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/main/java/net/siegeln/cameleer/saas/config/SpaController.java \
+ docker-compose.yml .gitea/workflows/ci.yml HOWTO.md
+git commit -m "feat: add SPA controller, Traefik route, CI frontend build, and HOWTO update"
+```
+
+---
+
+## Summary of Spec Coverage
+
+| Spec Requirement | Task |
+|---|---|
+| Project scaffolding (Vite, React, TS, design system) | Task 1 |
+| TypeScript API types | Task 1 |
+| Auth store (Zustand, same keys as cameleer3-server) | Task 2 |
+| Login / Logto OIDC redirect / callback | Task 2 |
+| Protected route | Task 2 |
+| API client with auth middleware | Task 3 |
+| React Query hooks for all endpoints | Task 3 |
+| RBAC permissions hook | Task 4 |
+| RequirePermission component | Task 4 |
+| DeploymentStatusBadge | Task 4 |
+| Sidebar layout (AppShell + Sidebar) | Task 5 |
+| Environment tree navigation | Task 5 |
+| Router with all page routes | Task 5 |
+| Dashboard page (tenant overview, KPIs) | Task 6 |
+| Environments list page | Task 7 |
+| Environment detail page (app list, create app) | Task 7 |
+| App detail page (deploy, stop, logs, status, routing) | Task 8 |
+| License page | Task 9 |
+| Spring Boot SPA controller | Task 10 |
+| Traefik SPA routing | Task 10 |
+| CI frontend build | Task 10 |
+| HOWTO.md updates | Task 10 |