- backend/app/routers/storage_config.py: 5 admin-only endpoints proxying storage-service config + migration API (GET/PATCH/POST/DELETE) - backend/app/main.py: register storage_config router - frontend/src/api/client.ts: StorageStatus, MigrationStatus, StorageBackendConfig interfaces + 5 API functions - frontend/src/pages/StorageAdminPage.tsx: full admin UI — backend health dot, driver selector (local/S3/WebDAV), conditional credential fields, Test & Migrate button, live 2s-poll migration progress bar, Cancel - frontend/src/App.tsx: /admin/storage route (AdminRoute guard) - CLAUDE.md: storage enforcement rule, updated Docker tables (6 services, 3 volumes), §20 in merge checklist - backend/CLAUDE.md, frontend/CLAUDE.md, doc-service/CLAUDE.md, ai-service/CLAUDE.md: updated to reflect storage-service integration - tests/ALL_TESTS.md + tests/storage-service_tests.md: §20 (20 tests) - backend/STATUS.md, frontend/STATUS.md: updated with new endpoints/routes - changelog/2026-04-20_storage-service.md: full change log Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
8.0 KiB
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.
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
│ │ └── StorageAdminPage.tsx ← Admin storage backend config + live migration progress
│ ├── 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 |
/admin/storage |
StorageAdminPage |
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 usedangerouslySetInnerHTMLwith user-supplied content. - Server-side
sanitize_strprovides 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
/apibase URL - Injects
Authorization: Bearer {token}fromlocalStorageon every request - Global 401 handler: clears
localStoragetoken and redirects to/loginviawindow.location.href— this is the expired-session redirect - Throws
ApiError(status, detail)on non-2xx responses (detail parsed from JSON body) - Returns
undefinedon 204 No Content - Supports
blob: truefor file download/preview responses
// The internal api object — use these methods in exported functions:
api.get<T>(path, params?) // GET with optional query params object
api.post<T>(path, json?) // POST with JSON body
api.postForm<T>(path, URLSearchParams) // POST with form-encoded body (login)
api.postFile<T>(path, FormData) // POST with multipart body (file upload)
api.patch<T>(path, json?) // PATCH with JSON body
api.delete<T>(path) // DELETE
api.getBlob(path) // GET → Blob (download / view)
Adding a new API call:
- Define a TypeScript interface for the response if it's new.
- Add a named export function (
getX,createX,updateX,deleteX). - 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):
["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:
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:
useQuery({ queryKey: ["services"], queryFn: getServices,
refetchInterval: 30_000, refetchIntervalInBackground: true });
Route guards
// 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
useStatefor UI-only state (edit mode, pending values, open/closed). - Server state via
useQuery/useMutation— no duplicated local copies. cn()fromlib/utils.tsfor conditional Tailwind classes.lucide-reactfor all icons.- Never use
dangerouslySetInnerHTMLwith user-supplied content.
Naming & Code Conventions
- TypeScript strict mode — no
any. - API response types inferred from interfaces in
client.tsonly. - 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 |