# 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
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Cameleer SaaS</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
```

- [ ] **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<string, unknown>;
  deployedAt: string | null;
  stoppedAt: string | null;
  createdAt: string;
}

export interface LicenseResponse {
  id: string;
  tenantId: string;
  tier: string;
  features: Record<string, boolean>;
  limits: Record<string, number>;
  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(
  <React.StrictMode>
    <ThemeProvider>
      <ToastProvider>
        <BreadcrumbProvider>
          <QueryClientProvider client={queryClient}>
            <BrowserRouter>
              <AppRouter />
            </BrowserRouter>
          </QueryClientProvider>
        </BreadcrumbProvider>
      </ToastProvider>
    </ThemeProvider>
  </React.StrictMode>
);
```

- [ ] **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<string, unknown> {
  try {
    const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
    return JSON.parse(atob(base64));
  } catch {
    return {};
  }
}

export const useAuthStore = create<AuthState>((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 (
    <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
      <div style={{ textAlign: 'center' }}>
        <h1>Cameleer SaaS</h1>
        <p style={{ marginBottom: '2rem', color: 'var(--color-text-secondary)' }}>
          Managed Apache Camel Runtime
        </p>
        <Button onClick={handleLogin}>Sign in with Logto</Button>
      </div>
    </div>
  );
}
```

- [ ] **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 (
    <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
      <Spinner />
    </div>
  );
}
```

- [ ] **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 <Navigate to="/login" replace />;
  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<T>(path: string, options: RequestInit = {}): Promise<T> {
  const token = useAuthStore.getState().accessToken;
  const headers: Record<string, string> = {
    ...(options.headers as Record<string, string> || {}),
  };
  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: <T>(path: string) => apiFetch<T>(path),
  post: <T>(path: string, body?: unknown) =>
    apiFetch<T>(path, {
      method: 'POST',
      body: body instanceof FormData ? body : JSON.stringify(body),
    }),
  patch: <T>(path: string, body: unknown) =>
    apiFetch<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
  put: <T>(path: string, body: FormData) =>
    apiFetch<T>(path, { method: 'PUT', body }),
  delete: <T>(path: string) => apiFetch<T>(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<TenantResponse>(`/tenants/${tenantId}`),
    enabled: !!tenantId,
  });
}

// License
export function useLicense(tenantId: string) {
  return useQuery({
    queryKey: ['license', tenantId],
    queryFn: () => api.get<LicenseResponse>(`/tenants/${tenantId}/license`),
    enabled: !!tenantId,
  });
}

// Environments
export function useEnvironments(tenantId: string) {
  return useQuery({
    queryKey: ['environments', tenantId],
    queryFn: () => api.get<EnvironmentResponse[]>(`/tenants/${tenantId}/environments`),
    enabled: !!tenantId,
  });
}

export function useCreateEnvironment(tenantId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (data: { slug: string; displayName: string }) =>
      api.post<EnvironmentResponse>(`/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<EnvironmentResponse>(`/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<AppResponse[]>(`/environments/${environmentId}/apps`),
    enabled: !!environmentId,
  });
}

export function useApp(environmentId: string, appId: string) {
  return useQuery({
    queryKey: ['app', appId],
    queryFn: () => api.get<AppResponse>(`/environments/${environmentId}/apps/${appId}`),
    enabled: !!appId,
  });
}

export function useCreateApp(environmentId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (formData: FormData) =>
      api.post<AppResponse>(`/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<AppResponse>(`/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<DeploymentResponse>(`/apps/${appId}/deploy`),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }),
  });
}

export function useDeployments(appId: string) {
  return useQuery({
    queryKey: ['deployments', appId],
    queryFn: () => api.get<DeploymentResponse[]>(`/apps/${appId}/deployments`),
    enabled: !!appId,
  });
}

export function useDeployment(appId: string, deploymentId: string) {
  return useQuery({
    queryKey: ['deployment', deploymentId],
    queryFn: () => api.get<DeploymentResponse>(`/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<DeploymentResponse>(`/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<DeploymentResponse>(`/apps/${appId}/restart`),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['deployments', appId] }),
  });
}

// Observability
export function useAgentStatus(appId: string) {
  return useQuery({
    queryKey: ['agent-status', appId],
    queryFn: () => api.get<AgentStatusResponse>(`/apps/${appId}/agent-status`),
    enabled: !!appId,
    refetchInterval: 15_000,
  });
}

export function useObservabilityStatus(appId: string) {
  return useQuery({
    queryKey: ['observability-status', appId],
    queryFn: () => api.get<ObservabilityStatusResponse>(`/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<LogEntry[]>(`/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<string, string[]> = {
  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<string>();
  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<string, string> = {
  BUILDING: 'warning',
  STARTING: 'warning',
  RUNNING: 'success',
  FAILED: 'error',
  STOPPED: 'default',
};

export function DeploymentStatusBadge({ status }: { status: string }) {
  const variant = STATUS_COLORS[status] || 'default';
  return <Badge variant={variant as any}>{status}</Badge>;
}
```

- [ ] **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 `<Outlet />` 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 (
    <Routes>
      <Route path="/login" element={<LoginPage />} />
      <Route path="/callback" element={<CallbackPage />} />
      <Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
        <Route index element={<DashboardPage />} />
        <Route path="environments" element={<EnvironmentsPage />} />
        <Route path="environments/:envId" element={<EnvironmentDetailPage />} />
        <Route path="environments/:envId/apps/:appId" element={<AppDetailPage />} />
        <Route path="license" element={<LicensePage />} />
      </Route>
    </Routes>
  );
}
```

- [ ] **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 |
