# frontend — Claude context React 18 SPA built with Vite, port 5173 dev / 80 prod, served by nginx-unprivileged in production. All `/api/*` requests are proxied to `backend:8000`. See root `CLAUDE.md` for architecture, Docker, and project-wide workflows. --- ## Commands All commands run inside Docker — never on the host. ```bash docker compose exec frontend npm run typecheck docker compose exec frontend npm run lint ``` --- ## File & Folder Tree ``` frontend/ ├── src/ │ ├── main.tsx ← React root, QueryClientProvider, BrowserRouter │ ├── App.tsx ← Route tree, PrivateRoute, AdminRoute │ ├── api/client.ts ← Native fetch wrapper (`request()`) + ALL API functions (single source of truth); no Axios │ ├── hooks/ │ │ ├── useAuth.ts ← Token state (localStorage), login/logout │ │ └── useTheme.ts ← Theme toggle │ ├── components/ │ │ ├── AppShell.tsx ← Layout: Sidebar + SourcePanel (on /apps/documents) + main │ │ ├── Sidebar.tsx ← Collapsible nav (icons ↔ icons+labels) │ │ ├── SourcePanel.tsx ← Views + searchable category tree (docs route only) │ │ ├── ManageCategoriesDialog.tsx ← Category CRUD modal (rename, delete) │ │ ├── DocumentSlideOver.tsx ← Right slide-over: detail, edit, share, AI suggestions │ │ ├── ThemeToggle.tsx ← Light/dark mode toggle │ │ ├── PluginSchemaForm.tsx ← JSON Schema → React form (boolean/string/number/readOnly) │ │ └── ui/ ← shadcn/ui components (Button, Input, …) │ ├── pages/ ← One file per route │ │ ├── DocServiceSettingsPage.tsx ← Combined doc-service settings: upload limits + watch directory │ │ └── PluginSettingsPage.tsx ← Generic plugin settings page driven by manifest │ ├── lib/utils.ts ← cn() = clsx + tailwind-merge │ └── styles/theme.css ← CSS custom properties, Tailwind setup ├── vite.config.ts ← /api/* proxied to backend:8000 ├── tailwind.config.ts ├── components.json ← shadcn/ui config ├── Dockerfile ← Multi-stage: Node build → nginx-unprivileged └── STATUS.md ``` --- ## Frontend Routes | Path | Component | Guard | |------|-----------|-------| | `/login` | `LoginPage` | Public | | `/` | `DashboardPage` | PrivateRoute | | `/apps` | `AppsPage` | PrivateRoute | | `/apps/documents` | `DocumentsPage` | PrivateRoute | | `/apps/documents/settings` | `DocServiceSettingsPage` | ServiceAdminRoute (is_admin OR doc-service-admin member) | | `/apps/ai/settings` | `AIAdminSettingsPage` | ServiceAdminRoute (is_admin OR ai-service-admin member) | | `/profile` | `ProfilePage` | PrivateRoute | | `/settings` | `SettingsPage` | PrivateRoute | | `/settings/plugins/:id` | `PluginSettingsPage` | PrivateRoute (auth enforced per-plugin by backend) | | `/admin` | `AdminPage` (→ `/admin/users`) | AdminRoute | | `/admin/users` | `AdminUsersPage` | AdminRoute | | `/admin/groups` | `AdminGroupsPage` | AdminRoute | | `/admin/appearance` | `AdminAppearancePage` | AdminRoute | | `*` | redirect to `/` | — | `PrivateRoute` — checks `token` from `useAuth`, redirects to `/login` if absent. `AdminRoute` — checks token AND queries `GET /api/users/me` for `is_admin`; waits for query to avoid flash; redirects to `/login` (not `/`) if not admin. --- ## Security Standards ### XSS prevention - React JSX text interpolation (`{value}`) is HTML-escaped by the DOM renderer — **never** use `dangerouslySetInnerHTML` with user-supplied content. - Server-side `sanitize_str` provides defense-in-depth (control char stripping, max length). --- ## Frontend Patterns & Conventions ### API client (`src/api/client.ts`) **No Axios** — uses a thin native `fetch` wrapper. All API calls live here, nowhere else. The core `request()` function handles: - Prepends `/api` base URL - Injects `Authorization: Bearer {token}` from `localStorage` on every request - **Global 401 handler**: clears `localStorage` token and redirects to `/login` via `window.location.href` — this is the expired-session redirect - Throws `ApiError(status, detail)` on non-2xx responses (detail parsed from JSON body) - Returns `undefined` on 204 No Content - Supports `blob: true` for file download/preview responses ```typescript // The internal api object — use these methods in exported functions: api.get(path, params?) // GET with optional query params object api.post(path, json?) // POST with JSON body api.postForm(path, URLSearchParams) // POST with form-encoded body (login) api.postFile(path, FormData) // POST with multipart body (file upload) api.patch(path, json?) // PATCH with JSON body api.delete(path) // DELETE api.getBlob(path) // GET → Blob (download / view) ``` Adding a new API call: 1. Define a TypeScript interface for the response if it's new. 2. Add a named export function (`getX`, `createX`, `updateX`, `deleteX`). 3. Use the appropriate `api.*` method — return the promise directly (no `.then((r) => r.data)`). Error handling in components: catch blocks receive an `ApiError` instance with `.status` and `.message` (the detail string). ### TanStack Query conventions **Query keys** (flat arrays, lowercase): ```typescript ["me"] // current user ["services"] // service health list ["dashboard-prefs"] // user dashboard preferences ["categories"] // document categories ["documents", params] // document list (params object for cache isolation) ["documents-shared", params] // shared-with-me list ["document", id] // single document ["document-shares", id] // share list for a specific document ["my-groups"] // current user's group memberships (for share picker) ["plugins"] // accessible plugin list (filtered by user access) ["plugin-manifest", id] // plugin manifest (cached) ["plugin-settings", id] // plugin current settings ``` **Mutation pattern**: ```typescript const mutation = useMutation({ mutationFn: apiFunction, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["affected-key"] }); // additional side effects (close dialog, reset form, etc.) }, }); // Usage: mutation.mutate(data); mutation.isPending // show spinner / disable button mutation.isError // show error message ``` **Polling**: ```typescript useQuery({ queryKey: ["services"], queryFn: getServices, refetchInterval: 30_000, refetchIntervalInBackground: true }); ``` ### Route guards ```typescript // PrivateRoute — redirect to /login if no token // AdminRoute — redirect to /login if no token OR not admin // (waits for getMe() query to avoid flash; uses 404 semantics) ``` ### Component patterns - Functional components only. - Local `useState` for UI-only state (edit mode, pending values, open/closed). - Server state via `useQuery` / `useMutation` — no duplicated local copies. - `cn()` from `lib/utils.ts` for conditional Tailwind classes. - `lucide-react` for all icons. - Never use `dangerouslySetInnerHTML` with user-supplied content. --- ## Naming & Code Conventions - TypeScript strict mode — no `any`. - API response types inferred from interfaces in `client.ts` only. - Error messages displayed inline (no alert); loading shown as disabled state or "…" text. - All user-facing text: safe via React JSX rendering (not innerHTML). --- ## Default Values & Limits | Parameter | Value | Location | |-----------|-------|----------| | Token localStorage key | `"token"` | `useAuth.ts` |