# JARVIS Web Frontend (Phase 3b) 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/Vite web frontend (Chat + Dashboard) for JARVIS, protected by a shared-secret gate, served on `jarvis.mbo-tech-it.de` while the API moves to `api.jarvis.mbo-tech-it.de`.
**Architecture:** A shared-secret FastAPI dependency (`require_admin_key`) protects the existing API endpoints. A separate React/Vite SPA (new `jarvis-web` Docker service, nginx-served) calls that API from the browser, storing the shared secret in `localStorage` and attaching it as `X-Admin-Key` on every call.
**Tech Stack:** FastAPI (existing), React + Vite + TypeScript (new), Vitest (frontend unit tests), pytest + FastAPI TestClient + httpx (backend unit test), nginx:alpine (static serving), Docker multi-stage build.
**Spec:** `docs/superpowers/specs/2026-09-12-web-frontend-design.md`
## Global Constraints
- No dedicated git repository for this project (see prior plans' Global Constraints) — every "Commit" step below is replaced by "confirm the file is saved"; do not run `git add`/`git commit`.
- Runtime is the live VPS at `72.61.186.98`. SSH: `ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98`. Local backend files live in `Claude outputs/` (note the space — quote the path), local frontend files go in a new `web/` directory at the project root (`C:\Users\Jonny\Projekte\Claude\JARVIS\web`).
- Reuse the existing `API_KEY_ADMIN` secret already generated in the deployed `.env` — do not create a new secret.
- Auth header name is `X-Admin-Key` (exact casing as written; HTTP headers are case-insensitive but FastAPI's `Header()` parameter name `x_admin_key` maps to it automatically).
- Domain split: `jarvis.mbo-tech-it.de` → frontend, `api.jarvis.mbo-tech-it.de` → API. Both are covered by the existing `*.jarvis.mbo-tech-it.de` wildcard DNS record — no new DNS entries needed.
- `GET /health` stays unprotected (monitoring). Every other `/api/v1/*` route gets the auth dependency.
- Local toolchain confirmed available: Node v24.14.0 / npm 11.9.0 (frontend, runs natively on this Windows machine); Python 3.14 locally cannot build `psycopg2-binary` (no wheel yet) — backend tests that import `main.py` must run inside a `python:3.11-slim` container, same pattern as the knowledge-base plan: `docker run --rm -v
:/app -w /app python:3.11-slim bash -c '...'` (or via SSH on the VPS, which already has this image cached, if local Docker Desktop isn't running).
---
### Task 1: Backend access control
**Files:**
- Modify: `Claude outputs/main.py` (CORS, `API_KEY_ADMIN` config, `require_admin_key` dependency, route decorators)
- Modify: `Claude outputs/docker-compose.yml` (jarvis-api environment)
- Create: `Claude outputs/requirements-dev.txt` (add `httpx` if not already present)
- Test: `Claude outputs/tests/test_auth.py`
**Interfaces:**
- Produces: `require_admin_key` FastAPI dependency in `main.py`, applied via `dependencies=[Depends(require_admin_key)]` on every protected route. Later tasks (frontend) rely on the header name `X-Admin-Key` and on `GET /health` remaining open.
- [ ] **Step 1: Add `httpx` to the dev requirements** (needed for FastAPI's `TestClient`)
Read `Claude outputs/requirements-dev.txt` first (it currently contains `pytest==8.3.3` from the knowledge-base plan). Append:
```
httpx==0.27.2
```
- [ ] **Step 2: Write the failing tests**
Create `Claude outputs/tests/test_auth.py`:
```python
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import pytest
from fastapi.testclient import TestClient
import main
main.API_KEY_ADMIN = "test-secret"
@pytest.fixture()
def client():
with TestClient(main.app) as c:
yield c
def test_missing_key_returns_401(client):
response = client.get("/api/v1/admin/stats")
assert response.status_code == 401
def test_wrong_key_returns_401(client):
response = client.get("/api/v1/admin/stats", headers={"X-Admin-Key": "nope"})
assert response.status_code == 401
def test_correct_key_passes_auth_gate(client):
response = client.get("/api/v1/admin/stats", headers={"X-Admin-Key": "test-secret"})
assert response.status_code != 401
def test_health_stays_open_without_key(client):
response = client.get("/health")
assert response.status_code != 401
```
- [ ] **Step 3: Run tests to verify they fail**
Using a local Docker container (fastest path if Docker Desktop is running):
```bash
docker run --rm -v "C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs:/app" -w /app python:3.11-slim bash -c "pip install -q -r requirements-dev.txt -r requirements.txt && python -m pytest tests/test_auth.py -v"
```
If Docker Desktop isn't running locally, run the same command over SSH against the VPS instead (copy the files to a scratch dir first, matching the pattern used for the chunking tests):
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
ssh $SSHOPTS jarvis-core@72.61.186.98 "mkdir -p /tmp/jarvis-test/tests"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" "$OUT/requirements.txt" "$OUT/requirements-dev.txt" jarvis-core@72.61.186.98:/tmp/jarvis-test/
scp $SSHOPTS "$OUT/tests/test_auth.py" jarvis-core@72.61.186.98:/tmp/jarvis-test/tests/
ssh $SSHOPTS jarvis-core@72.61.186.98 "docker run --rm -v /tmp/jarvis-test:/app -w /app python:3.11-slim bash -c 'pip install -q -r requirements-dev.txt -r requirements.txt && python -m pytest tests/test_auth.py -v'"
```
Expected: FAIL — `require_admin_key` doesn't exist yet, or (once it exists but isn't wired up) the 401 assertions fail because routes aren't protected yet.
- [ ] **Step 4: Add the config constant and dependency**
In `Claude outputs/main.py`, change the fastapi import line:
```python
from fastapi import FastAPI, HTTPException
```
to:
```python
from fastapi import FastAPI, HTTPException, Depends, Header
```
Add the config constant next to the other `# ============ CONFIG ============` constants:
```python
API_KEY_ADMIN = os.getenv("API_KEY_ADMIN")
```
Add the dependency function right after the CORS `app.add_middleware(...)` block:
```python
async def require_admin_key(x_admin_key: Optional[str] = Header(default=None)):
if not API_KEY_ADMIN or x_admin_key != API_KEY_ADMIN:
raise HTTPException(status_code=401, detail="Invalid or missing admin key")
```
- [ ] **Step 5: Tighten CORS**
In `Claude outputs/main.py`, change:
```python
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
```
to:
```python
app.add_middleware(
CORSMiddleware,
allow_origins=["https://jarvis.mbo-tech-it.de"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
```
- [ ] **Step 6: Protect every `/api/v1/*` route**
In `Claude outputs/main.py`, change each of these decorator lines (leave the function bodies untouched):
```python
@app.post("/api/v1/chat", response_model=ChatResponse)
```
→
```python
@app.post("/api/v1/chat", response_model=ChatResponse, dependencies=[Depends(require_admin_key)])
```
```python
@app.get("/api/v1/conversations/{conversation_id}")
```
→
```python
@app.get("/api/v1/conversations/{conversation_id}", dependencies=[Depends(require_admin_key)])
```
```python
@app.post("/api/v1/tasks", response_model=TaskResponse)
```
→
```python
@app.post("/api/v1/tasks", response_model=TaskResponse, dependencies=[Depends(require_admin_key)])
```
```python
@app.get("/api/v1/tasks")
```
→
```python
@app.get("/api/v1/tasks", dependencies=[Depends(require_admin_key)])
```
```python
@app.post("/api/v1/documents")
```
→
```python
@app.post("/api/v1/documents", dependencies=[Depends(require_admin_key)])
```
```python
@app.get("/api/v1/documents")
```
→
```python
@app.get("/api/v1/documents", dependencies=[Depends(require_admin_key)])
```
```python
@app.post("/api/v1/workflows/trigger")
```
→
```python
@app.post("/api/v1/workflows/trigger", dependencies=[Depends(require_admin_key)])
```
```python
@app.get("/api/v1/admin/stats")
```
→
```python
@app.get("/api/v1/admin/stats", dependencies=[Depends(require_admin_key)])
```
```python
@app.get("/api/v1/admin/health/detailed")
```
→
```python
@app.get("/api/v1/admin/health/detailed", dependencies=[Depends(require_admin_key)])
```
Leave `@app.get("/health")` exactly as-is (unprotected).
- [ ] **Step 7: Run tests to verify they pass**
Re-run the same command from Step 3. Expected: 4 passed.
- [ ] **Step 8: Wire `API_KEY_ADMIN` into the deployed container environment**
In `Claude outputs/docker-compose.yml`, under `jarvis-api: environment:`, add a line next to `CLAUDE_API_KEY`:
```yaml
- API_KEY_ADMIN=${API_KEY_ADMIN:-}
```
- [ ] **Step 9: Deploy and verify against the live API**
```bash
SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key"
OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs"
scp $SSHOPTS "$OUT/main.py" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/api/main.py
scp $SSHOPTS "$OUT/docker-compose.yml" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/docker-compose.yml
ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose up -d jarvis-api && sleep 8 && docker logs --tail 20 jarvis-api"
```
Expected: log ends with `Application startup complete.` (no traceback).
Then confirm the gate is live (replace `` with the real value from the deployed `.env`'s `API_KEY_ADMIN`):
```bash
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -o /dev/null -w 'no key: %{http_code}\n' http://localhost:8000/api/v1/admin/stats"
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -o /dev/null -w 'health (open): %{http_code}\n' http://localhost:8000/health"
ssh $SSHOPTS jarvis-core@72.61.186.98 "grep API_KEY_ADMIN /home/jarvis-core/jarvis/.env"
```
Take the printed `API_KEY_ADMIN` value and confirm it now works:
```bash
ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -o /dev/null -w 'with key: %{http_code}\n' -H 'X-Admin-Key: ' http://localhost:8000/api/v1/admin/stats"
```
Expected: `no key: 401`, `health (open): 200`, `with key: 200`.
- [ ] **Step 10: Confirm files saved** (no git repo for this project — see Global Constraints)
---
### Task 2: Frontend scaffold + API client
**Files:**
- Create: `web/package.json`, `web/tsconfig.json`, `web/vite.config.ts`, `web/index.html`, `web/.env.production`
- Create: `web/src/main.tsx`, `web/src/App.tsx` (placeholder), `web/src/index.css`
- Create: `web/src/api.ts`
- Test: `web/src/api.test.ts`
**Interfaces:**
- Produces: `apiFetch(path, options?, keyOverride?)`, `getStoredKey()`, `storeKey(key)`, `clearKey()`, `logout()`, `onUnauthorized(handler)`, `UnauthorizedError` — all exported from `web/src/api.ts`. Task 3 (Login/App) and Tasks 4-5 (Chat/Dashboard) import these.
- [ ] **Step 1: Create the project scaffold**
Create `web/package.json`:
```json
{
"name": "jarvis-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"test": "vitest run"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"jsdom": "^25.0.1",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vitest": "^2.1.4"
}
}
```
Create `web/tsconfig.json`:
```json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"types": ["vitest/globals"]
},
"include": ["src"]
}
```
Create `web/vite.config.ts`:
```typescript
///
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
},
});
```
Create `web/index.html`:
```html
JARVIS
```
Create `web/.env.production`:
```
VITE_API_URL=https://api.jarvis.mbo-tech-it.de
```
Create `web/src/index.css`:
```css
body { font-family: system-ui, sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; }
.login { display: flex; flex-direction: column; gap: 0.75rem; max-width: 320px; margin: 4rem auto; padding: 2rem; background: #1e293b; border-radius: 8px; }
.login input, .login button { padding: 0.5rem; font-size: 1rem; }
.error { color: #f87171; }
.nav { display: flex; gap: 0.5rem; padding: 1rem; background: #1e293b; }
.nav button { padding: 0.5rem 1rem; }
.chat { display: flex; flex-direction: column; height: calc(100vh - 64px); padding: 1rem; box-sizing: border-box; }
.messages { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 0.5rem; }
.message { padding: 0.5rem 0.75rem; border-radius: 8px; max-width: 70%; }
.message.user { align-self: flex-end; background: #2563eb; }
.message.assistant { align-self: flex-start; background: #334155; }
.message.system { align-self: center; background: #7f1d1d; font-size: 0.875rem; }
.input-row { display: flex; gap: 0.5rem; margin-top: 1rem; }
.input-row input { flex: 1; padding: 0.5rem; }
.dashboard { padding: 1rem; }
.stat-cards { display: flex; gap: 1rem; margin: 1rem 0; }
.stat-card { background: #1e293b; padding: 1rem; border-radius: 8px; flex: 1; text-align: center; }
.stat-card span { display: block; font-size: 2rem; font-weight: bold; }
.health-table { width: 100%; border-collapse: collapse; }
.health-table td, .health-table th { padding: 0.5rem; border-bottom: 1px solid #334155; text-align: left; }
.badge-ok { color: #4ade80; }
.badge-error { color: #f87171; }
```
Create `web/src/App.tsx` (placeholder, replaced in Task 3):
```tsx
export default function App() {
return
JARVIS placeholder
;
}
```
Create `web/src/main.tsx`:
```tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
);
```
- [ ] **Step 2: Install dependencies**
```bash
cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web"
npm install
```
- [ ] **Step 3: Write the failing tests for the API client**
Create `web/src/api.test.ts`:
```typescript
import { describe, it, expect, beforeEach, vi } from "vitest";
import { apiFetch, storeKey, getStoredKey, UnauthorizedError } from "./api";
describe("apiFetch", () => {
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
it("attaches the stored admin key as X-Admin-Key header", async () => {
storeKey("secret123");
const fetchMock = vi.fn().mockResolvedValue(new Response("{}", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await apiFetch("/api/v1/admin/stats");
const [, options] = fetchMock.mock.calls[0];
const headers = options.headers as Headers;
expect(headers.get("X-Admin-Key")).toBe("secret123");
});
it("clears the stored key and throws UnauthorizedError on 401", async () => {
storeKey("wrong-key");
const fetchMock = vi.fn().mockResolvedValue(new Response("{}", { status: 401 }));
vi.stubGlobal("fetch", fetchMock);
await expect(apiFetch("/api/v1/admin/stats")).rejects.toBeInstanceOf(UnauthorizedError);
expect(getStoredKey()).toBeNull();
});
});
```
- [ ] **Step 4: Run tests to verify they fail**
```bash
cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web"
npm test
```
Expected: FAIL — `./api` module doesn't exist yet.
- [ ] **Step 5: Implement `api.ts`**
Create `web/src/api.ts`:
```typescript
const STORAGE_KEY = "jarvis_admin_key";
const UNAUTHORIZED_EVENT = "jarvis:unauthorized";
export class UnauthorizedError extends Error {}
function getApiBase(): string {
return import.meta.env.VITE_API_URL as string;
}
export function getStoredKey(): string | null {
return localStorage.getItem(STORAGE_KEY);
}
export function storeKey(key: string): void {
localStorage.setItem(STORAGE_KEY, key);
}
export function clearKey(): void {
localStorage.removeItem(STORAGE_KEY);
}
export function logout(): void {
clearKey();
window.dispatchEvent(new Event(UNAUTHORIZED_EVENT));
}
export function onUnauthorized(handler: () => void): () => void {
window.addEventListener(UNAUTHORIZED_EVENT, handler);
return () => window.removeEventListener(UNAUTHORIZED_EVENT, handler);
}
export async function apiFetch(
path: string,
options: RequestInit = {},
keyOverride?: string
): Promise {
const key = keyOverride ?? getStoredKey();
const headers = new Headers(options.headers);
if (key) {
headers.set("X-Admin-Key", key);
}
const response = await fetch(`${getApiBase()}${path}`, { ...options, headers });
if (response.status === 401) {
clearKey();
window.dispatchEvent(new Event(UNAUTHORIZED_EVENT));
throw new UnauthorizedError("Invalid or missing admin key");
}
return response;
}
```
- [ ] **Step 6: Run tests to verify they pass**
```bash
cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web"
npm test
```
Expected: 2 passed.
- [ ] **Step 7: Verify the build works end to end**
```bash
cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web"
npm run build
```
Expected: exits 0, produces a `dist/` directory.
- [ ] **Step 8: Confirm files saved** (no git repo for this project — see Global Constraints)
---
### Task 3: Login screen + app gate
**Files:**
- Create: `web/src/components/Login.tsx`
- Modify: `web/src/App.tsx` (replace placeholder)
**Interfaces:**
- Consumes: `apiFetch`, `storeKey`, `getStoredKey`, `onUnauthorized`, `logout`, `UnauthorizedError` from `./api` (Task 2).
- Produces: `App` default export used by `main.tsx` (Task 2, unchanged); `Login` component with props `{ onSuccess: () => void }`, used by `App.tsx` and later referenced by Tasks 4-5's parent (`App.tsx` renders `Chat`/`Dashboard` once authenticated — those components take no props).
- [ ] **Step 1: Create the Login component**
Create `web/src/components/Login.tsx`:
```tsx
import { useState, FormEvent } from "react";
import { apiFetch, storeKey, UnauthorizedError } from "../api";
export default function Login({ onSuccess }: { onSuccess: () => void }) {
const [password, setPassword] = useState("");
const [error, setError] = useState(null);
const [checking, setChecking] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setChecking(true);
try {
const response = await apiFetch("/api/v1/admin/stats", {}, password);
if (response.ok) {
storeKey(password);
onSuccess();
} else {
setError("Login fehlgeschlagen.");
}
} catch (err) {
if (err instanceof UnauthorizedError) {
setError("Falsches Passwort.");
} else {
setError("Server nicht erreichbar.");
}
} finally {
setChecking(false);
}
}
return (
);
}
```
- [ ] **Step 2: Replace the `App.tsx` placeholder**
Replace the contents of `web/src/App.tsx`:
```tsx
import { useState, useEffect } from "react";
import { getStoredKey, onUnauthorized, logout } from "./api";
import Login from "./components/Login";
import Chat from "./components/Chat";
import Dashboard from "./components/Dashboard";
type View = "chat" | "dashboard";
export default function App() {
const [authenticated, setAuthenticated] = useState(() => getStoredKey() !== null);
const [view, setView] = useState("chat");
useEffect(() => onUnauthorized(() => setAuthenticated(false)), []);
if (!authenticated) {
return setAuthenticated(true)} />;
}
return (
{view === "chat" ? : }
);
}
```
Note: this references `./components/Chat` and `./components/Dashboard`, which don't exist until Tasks 4 and 5. The build will fail until both exist — that's expected; Step 3 below uses a temporary stub to verify Login in isolation first.
- [ ] **Step 3: Verify with temporary stubs, then leave real work to Tasks 4-5**
Create temporary placeholder files so the build can succeed and Login can be verified in isolation (Tasks 4 and 5 will overwrite these with the real implementation):
Create `web/src/components/Chat.tsx`:
```tsx
export default function Chat() {
return