Merge pull request 'colorThemes' (#1) from colorThemes into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -22,5 +22,9 @@ resume.txt
|
||||
# Test fixtures — drop PDFs here for local testing, never commit them
|
||||
features/doc-service/tests/pdfs/*.pdf
|
||||
|
||||
# Feature branch test stacks — never commit these
|
||||
docker-compose.feat-*.yml
|
||||
|
||||
# Don't sync .un files
|
||||
*.un~
|
||||
dev-watch/**/*.pdf
|
||||
|
||||
@@ -78,17 +78,18 @@ docker compose up --build -d
|
||||
├── .githooks/pre-commit ← Runs scripts/security_check.py before every commit
|
||||
├── scripts/security_check.py ← Static analysis: secrets, weak crypto, SQLi, JWT
|
||||
├── changelog/YYYY-MM-DD_<slug>.md ← Per-date change logs
|
||||
├── dev-watch/ ← Dev bind-mount for file watcher testing (.gitkeep only)
|
||||
│
|
||||
├── backend/ ← FastAPI gateway (port 8000, internal)
|
||||
│ ├── app/
|
||||
│ │ ├── main.py ← App factory, router registration, lifespan (health loop)
|
||||
│ │ ├── database.py ← AsyncEngine, AsyncSessionLocal, Base
|
||||
│ │ ├── deps.py ← get_current_user, get_current_admin
|
||||
│ │ ├── deps.py ← get_current_user, get_current_admin, get_service_admin(id), check_plugin_access
|
||||
│ │ ├── core/
|
||||
│ │ │ ├── config.py ← All settings via pydantic-settings (reads .env)
|
||||
│ │ │ ├── security.py ← JWT sign/verify (RS256), bcrypt hash/verify
|
||||
│ │ │ ├── sanitize.py ← Input sanitization helpers (see Security Standards)
|
||||
│ │ │ └── app_config.py ← Per-service config load/save to /config volume
|
||||
│ │ │ └── app_config.py ← Per-service config load/save to /config volume; theme files in /config/themes/
|
||||
│ │ ├── models/
|
||||
│ │ │ ├── __init__.py ← Imports all models (required for Alembic autogenerate)
|
||||
│ │ │ ├── user.py ← User model (see Database Models)
|
||||
@@ -100,16 +101,18 @@ docker compose up --build -d
|
||||
│ │ │ └── group.py ← GroupCreate/Update/Out/DetailOut, GroupMemberOut
|
||||
│ │ ├── routers/
|
||||
│ │ │ ├── auth.py ← POST /register, POST /login
|
||||
│ │ │ ├── users.py ← GET /me, GET+PATCH /me/preferences
|
||||
│ │ │ ├── users.py ← GET /me, GET+PATCH /me/preferences, PATCH /me/color-mode
|
||||
│ │ │ ├── profile.py ← GET+PUT /me (profile)
|
||||
│ │ │ ├── admin.py ← User admin CRUD (admin-only)
|
||||
│ │ │ ├── groups.py ← Group CRUD + member management (admin-only)
|
||||
│ │ │ ├── settings.py ← AI, doc limits, system prompts (admin-only)
|
||||
│ │ │ ├── settings.py ← AI, doc limits, system prompts, appearance, themes (admin-only)
|
||||
│ │ │ ├── services.py ← GET /services (health status)
|
||||
│ │ │ ├── plugins.py ← Generic plugin proxy (GET/PATCH /api/plugins/*)
|
||||
│ │ │ ├── categories_proxy.py ← Transparent proxy → doc-service /categories/*
|
||||
│ │ │ └── documents_proxy.py ← Transparent proxy → doc-service /documents/*
|
||||
│ │ └── services/
|
||||
│ │ └── service_health.py ← Background 30s health-check loop
|
||||
│ │ ├── service_health.py ← Background 30s health-check loop; caches /plugin/manifest per service
|
||||
│ │ └── group_bootstrap.py ← Ensures {service-id}-admin group exists for every registered service at startup
|
||||
│ ├── alembic/
|
||||
│ │ ├── env.py ← Async migration runner
|
||||
│ │ └── versions/ ← Migration chain (see Migrations section)
|
||||
@@ -124,6 +127,7 @@ docker compose up --build -d
|
||||
│ │ │ ├── routers/chat.py ← POST /chat (sync, NORMAL priority queue)
|
||||
│ │ │ ├── routers/health.py ← GET /health
|
||||
│ │ │ ├── routers/queue.py ← GET /queue/status, /pause, /resume, /cancel/{id}
|
||||
│ │ │ └── routers/plugin.py ← GET /plugin/manifest (access rules for ai-service-admin group)
|
||||
│ │ │ ├── providers/base.py ← AIProvider abstract class
|
||||
│ │ │ ├── providers/anthropic_provider.py
|
||||
│ │ │ ├── providers/openai_compat.py ← Ollama / LM Studio
|
||||
@@ -133,7 +137,7 @@ docker compose up --build -d
|
||||
│ │
|
||||
│ └── doc-service/ ← PDF extraction microservice (port 8001, internal)
|
||||
│ ├── app/
|
||||
│ │ ├── main.py
|
||||
│ │ ├── main.py ← FastAPI, lifespan (file watcher start/stop)
|
||||
│ │ ├── database.py ← Same PostgreSQL instance as backend
|
||||
│ │ ├── deps.py ← get_user_id (reads x-user-id header)
|
||||
│ │ ├── models/
|
||||
@@ -144,13 +148,16 @@ docker compose up --build -d
|
||||
│ │ │ ├── document.py ← DocumentOut, DocumentPage, DocumentStatusOut, etc.
|
||||
│ │ │ └── category.py ← CategoryOut, CategoryCreate, CategoryUpdate
|
||||
│ │ ├── routers/
|
||||
│ │ │ ├── documents.py ← Full document CRUD + file serving + reprocess
|
||||
│ │ │ └── categories.py ← Category CRUD
|
||||
│ │ │ ├── documents.py ← Full document CRUD + file serving + reprocess + suggestion endpoints
|
||||
│ │ │ ├── categories.py ← Category CRUD (includes watch-owned categories)
|
||||
│ │ │ └── plugin.py ← GET /plugin/manifest, GET+PATCH /plugin/settings
|
||||
│ │ └── services/
|
||||
│ │ ├── storage.py ← File I/O
|
||||
│ │ ├── ai_client.py ← classify_document() → ai-service:8010/chat
|
||||
│ │ └── config_reader.py
|
||||
│ │ ├── config_reader.py ← Config load/save including storage/watch settings
|
||||
│ │ └── file_watcher.py ← watchdog-based PDF watcher + startup scan + ingestion
|
||||
│ ├── alembic/versions/ ← Doc-service migration chain
|
||||
│ │ └── 0003_add_watch_columns.py ← source, watch_path, suggested_folder, suggested_filename
|
||||
│ ├── Dockerfile
|
||||
│ └── STATUS.md
|
||||
│
|
||||
@@ -166,8 +173,11 @@ docker compose up --build -d
|
||||
│ │ ├── AppShell.tsx ← Layout: Sidebar + scrollable main
|
||||
│ │ ├── Sidebar.tsx ← Collapsible nav (icons ↔ icons+labels)
|
||||
│ │ ├── 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 (see Routes section)
|
||||
│ │ ├── 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
|
||||
@@ -227,6 +237,7 @@ Browser (:5173 dev / :80 prod)
|
||||
| `is_active` | Boolean | default=True | soft-delete flag |
|
||||
| `is_superuser` | Boolean | default=False | admin role; never exposed as-is (serialised as `is_admin`) |
|
||||
| `dashboard_app_ids` | JSON | NOT NULL, default=[] | list of pinned service IDs |
|
||||
| `color_mode` | String | nullable, default=NULL | user's preferred mode: "light" / "dark" / "system" / NULL (use admin default) |
|
||||
|
||||
Relationship: `profile` (one-to-one, cascade all+delete-orphan)
|
||||
|
||||
@@ -282,6 +293,10 @@ Unique constraint: `(group_id, user_id)`
|
||||
| `error_message` | String(500) | nullable | |
|
||||
| `created_at` | DateTime(tz) | server_default=now() | |
|
||||
| `processed_at` | DateTime(tz) | nullable | |
|
||||
| `source` | String(16) | default="upload" | "upload" or "watch" |
|
||||
| `watch_path` | String | nullable | original absolute path in watch directory |
|
||||
| `suggested_folder` | String(128) | nullable | AI-suggested category (pending user confirm) |
|
||||
| `suggested_filename` | String(500) | nullable | AI-suggested title/rename (pending user confirm) |
|
||||
|
||||
**`document_categories`**
|
||||
|
||||
@@ -309,6 +324,7 @@ Unique constraint: `(group_id, user_id)`
|
||||
| `676084df61d1` | `add_profiles_table` |
|
||||
| `a3f9c2d14e87` | `add_groups_and_group_memberships` |
|
||||
| `c7e8f9a0b1d2` | `add_dashboard_app_ids_to_users` |
|
||||
| `dd6ad2f2c211` | `add_color_mode_to_users` |
|
||||
|
||||
**Doc-service**:
|
||||
|
||||
@@ -316,6 +332,7 @@ Unique constraint: `(group_id, user_id)`
|
||||
|--------|------|
|
||||
| `0001` | `create_doc_tables` |
|
||||
| `0002` | `add_document_title` |
|
||||
| `0003` | `add_watch_columns` |
|
||||
|
||||
---
|
||||
|
||||
@@ -335,6 +352,7 @@ Unique constraint: `(group_id, user_id)`
|
||||
| GET | `/api/users/me` | user | Current user info → `UserOut` |
|
||||
| GET | `/api/users/me/preferences` | user | Dashboard pinned app IDs → `{app_ids}` |
|
||||
| PATCH | `/api/users/me/preferences` | user | Save pinned app IDs (max 50, slug-safe) |
|
||||
| PATCH | `/api/users/me/color-mode` | user | Save colour mode preference ("light"/"dark"/"system") |
|
||||
|
||||
### Profile (`/api/profile`) — authenticated
|
||||
|
||||
@@ -375,6 +393,12 @@ Unique constraint: `(group_id, user_id)`
|
||||
| PATCH | `/api/settings/documents/limits` | Update max PDF size |
|
||||
| GET | `/api/settings/system-prompts` | All editable system prompts |
|
||||
| PATCH | `/api/settings/system-prompts/{service_id}` | Update system prompt |
|
||||
| GET | `/api/settings/appearance` | Active theme + default mode (auth) |
|
||||
| PATCH | `/api/settings/appearance` | Update active theme + default mode (admin) |
|
||||
| GET | `/api/settings/themes` | List all themes — built-in + custom (auth) |
|
||||
| POST | `/api/settings/themes` | Create custom theme (admin) |
|
||||
| PATCH | `/api/settings/themes/{id}` | Update custom theme label/colours (admin) |
|
||||
| DELETE | `/api/settings/themes/{id}` | Delete custom theme (admin, 204) |
|
||||
|
||||
### Services (`/api/services`) — authenticated
|
||||
|
||||
@@ -398,6 +422,10 @@ Unique constraint: `(group_id, user_id)`
|
||||
| GET | `/api/documents/{id}/file` | Download PDF (streaming) |
|
||||
| POST | `/api/documents/{id}/categories/{cat_id}` | Assign category |
|
||||
| DELETE | `/api/documents/{id}/categories/{cat_id}` | Remove category |
|
||||
| POST | `/api/documents/{id}/suggestions/folder/confirm` | Confirm AI folder suggestion |
|
||||
| POST | `/api/documents/{id}/suggestions/folder/reject` | Reject AI folder suggestion |
|
||||
| POST | `/api/documents/{id}/suggestions/filename/confirm` | Confirm AI filename suggestion |
|
||||
| POST | `/api/documents/{id}/suggestions/filename/reject` | Reject AI filename suggestion |
|
||||
|
||||
### Categories (`/api/documents/categories/*`) — authenticated, proxied to doc-service
|
||||
|
||||
@@ -408,6 +436,17 @@ Unique constraint: `(group_id, user_id)`
|
||||
| PATCH | `/api/documents/categories/{id}` | Rename |
|
||||
| DELETE | `/api/documents/categories/{id}` | Delete (204) |
|
||||
|
||||
### Plugins (`/api/plugins`) — authenticated, auth-per-plugin
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/plugins` | List plugins accessible to current user |
|
||||
| GET | `/api/plugins/{id}/manifest` | Plugin manifest with settings JSON Schema (auth-gated) |
|
||||
| GET | `/api/plugins/{id}/settings` | Proxy to feature `/plugin/settings` (auth-gated) |
|
||||
| PATCH | `/api/plugins/{id}/settings` | Proxy to feature `/plugin/settings` (auth-gated) |
|
||||
|
||||
Auth: is_superuser OR member of group listed in manifest `required_groups`. Returns 404 (not 403) to hide existence.
|
||||
|
||||
### AI-service (internal only — not exposed to browser)
|
||||
|
||||
| Method | Path | Description |
|
||||
@@ -429,13 +468,15 @@ Unique constraint: `(group_id, user_id)`
|
||||
| `/` | `DashboardPage` | PrivateRoute |
|
||||
| `/apps` | `AppsPage` | PrivateRoute |
|
||||
| `/apps/documents` | `DocumentsPage` | PrivateRoute |
|
||||
| `/apps/documents/settings/admin` | `DocumentAdminSettingsPage` | AdminRoute |
|
||||
| `/apps/ai/settings/admin` | `AIAdminSettingsPage` | AdminRoute |
|
||||
| `/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.
|
||||
@@ -556,6 +597,9 @@ Adding a new API call:
|
||||
["categories"] // document categories
|
||||
["documents", params] // document list (params object for cache isolation)
|
||||
["document", id] // single document
|
||||
["plugins"] // accessible plugin list (filtered by user access)
|
||||
["plugin-manifest", id] // plugin manifest (cached)
|
||||
["plugin-settings", id] // plugin current settings
|
||||
```
|
||||
|
||||
**Mutation pattern**:
|
||||
@@ -669,6 +713,7 @@ Use `validation_alias` when the ORM field name differs from the JSON key (e.g.,
|
||||
| Token localStorage key | `"token"` | `useAuth.ts` |
|
||||
| Health check interval | 30 s | `service_health.py` |
|
||||
| Service poll (frontend) | 30 s | `AppsPage.tsx`, `DashboardPage.tsx` |
|
||||
| User `color_mode` default | NULL (falls back to admin default_mode, then system) | `models/user.py` |
|
||||
| Max dashboard pinned apps | 50 | `schemas/user.py` |
|
||||
| App ID max length | 64 chars | `schemas/user.py` |
|
||||
| App ID allowed chars | `[a-zA-Z0-9_\-]` | `schemas/user.py` |
|
||||
@@ -697,7 +742,7 @@ Use `validation_alias` when the ORM field name differs from the JSON key (e.g.,
|
||||
| `db` | postgres:16-alpine | 5432 | 70:70 | `postgres_data` | backend-net |
|
||||
| `backend` | python:3.12-slim | 8000 | 1001:1001 | `app_config` | backend-net |
|
||||
| `ai-service` | python:3.12-slim | 8010 | 1001:1001 | `app_config` | backend-net |
|
||||
| `doc-service` | python:3.12-slim | 8001 | 1001:1001 | `doc_data`, `app_config` | backend-net |
|
||||
| `doc-service` | python:3.12-slim | 8001 | 1001:1001 | `doc_data`, `watch_data`, `app_config` | backend-net |
|
||||
| `frontend` | nginx-unprivileged:alpine | 8080 | 1001:1001 | — | backend-net, frontend-net |
|
||||
|
||||
### Volumes
|
||||
@@ -706,6 +751,7 @@ Use `validation_alias` when the ORM field name differs from the JSON key (e.g.,
|
||||
|--------|-----------|---------|
|
||||
| `postgres_data` | `/var/lib/postgresql/data` | PostgreSQL data |
|
||||
| `doc_data` | `/data/documents` | Uploaded PDF files |
|
||||
| `watch_data` | `/data/watch` | Watch directory (bind-mount NAS/Nextcloud via docker-compose.override.yml) |
|
||||
| `app_config` | `/config` | Per-service runtime config JSON files |
|
||||
|
||||
### Networks
|
||||
@@ -805,6 +851,100 @@ Always run `git push` immediately after every `git commit`.
|
||||
|
||||
---
|
||||
|
||||
### Feature branch & isolated test environment
|
||||
|
||||
Every non-trivial implementation (anything beyond a one-line fix or doc change) **must** follow this workflow:
|
||||
|
||||
#### 1 — Create a feature branch
|
||||
After the planning phase is approved, branch off `main`:
|
||||
```bash
|
||||
git checkout main && git pull
|
||||
git checkout -b feat/<slug> # e.g. feat/color-mode, feat/admin-appearance
|
||||
```
|
||||
|
||||
#### 2 — Spin up an isolated Docker stack for the feature
|
||||
A dedicated compose stack runs alongside the main dev stack so both can be tested independently.
|
||||
|
||||
**Find the next free port** (main dev stack owns 5173):
|
||||
```bash
|
||||
for port in $(seq 5174 5200); do
|
||||
lsof -iTCP:$port -sTCP:LISTEN -t &>/dev/null || { echo "$port"; break; }
|
||||
done
|
||||
```
|
||||
Use the first free port returned (call it `$PORT`).
|
||||
|
||||
**Create a per-feature override file** at `docker-compose.feat-<slug>.yml` (gitignored):
|
||||
```yaml
|
||||
# docker-compose.feat-<slug>.yml — feature test stack, never committed to main
|
||||
services:
|
||||
frontend:
|
||||
ports:
|
||||
- "$PORT:8080" # e.g. 5174:8080
|
||||
container_name: frontend-<slug>
|
||||
backend:
|
||||
container_name: backend-<slug>
|
||||
doc-service:
|
||||
container_name: doc-service-<slug>
|
||||
ai-service:
|
||||
container_name: ai-service-<slug>
|
||||
db:
|
||||
container_name: db-<slug>
|
||||
|
||||
networks:
|
||||
backend-net:
|
||||
name: backend-net-<slug>
|
||||
frontend-net:
|
||||
name: frontend-net-<slug>
|
||||
```
|
||||
|
||||
**Start the feature stack**:
|
||||
```bash
|
||||
docker compose -f docker-compose.yml \
|
||||
-f docker-compose.dev.yml \
|
||||
-f docker-compose.feat-<slug>.yml \
|
||||
--project-name <slug> up --build
|
||||
```
|
||||
|
||||
The feature frontend is now reachable at `http://localhost:$PORT`.
|
||||
The main dev stack continues running unaffected on `:5173`.
|
||||
|
||||
#### 3 — Develop on the feature branch
|
||||
All code changes happen on `feat/<slug>`. Commit and push normally:
|
||||
```bash
|
||||
git add <files>
|
||||
git commit -m "feat: <description>"
|
||||
git push -u origin feat/<slug>
|
||||
```
|
||||
|
||||
#### 4 — Confirm functionality
|
||||
Before merging, verify all of the following on `http://localhost:$PORT`:
|
||||
- [ ] Login and registration work end-to-end
|
||||
- [ ] The specific feature works as intended
|
||||
- [ ] No regressions visible in the UI
|
||||
- [ ] Backend logs show no unexpected errors: `docker compose -p <slug> logs backend`
|
||||
- [ ] Migrations (if any) applied cleanly: `docker compose -p <slug> exec backend alembic upgrade head`
|
||||
|
||||
#### 5 — Merge to main
|
||||
Once all checks pass:
|
||||
```bash
|
||||
git checkout main
|
||||
git merge --no-ff feat/<slug> -m "Merge feat/<slug>: <description>"
|
||||
git push
|
||||
git branch -d feat/<slug>
|
||||
git push origin --delete feat/<slug>
|
||||
```
|
||||
|
||||
#### 6 — Tear down the feature stack
|
||||
```bash
|
||||
docker compose -f docker-compose.yml \
|
||||
-f docker-compose.dev.yml \
|
||||
-f docker-compose.feat-<slug>.yml \
|
||||
--project-name <slug> down --volumes --remove-orphans
|
||||
rm docker-compose.feat-<slug>.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Infrastructure change protocol
|
||||
|
||||
After **any** change to Dockerfiles, `docker-compose*.yml`, `nginx.conf`, or setup scripts:
|
||||
|
||||
+27
-5
@@ -66,14 +66,18 @@ A background task (`service_health.py`) polls each service's `/health` endpoint
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/settings/ai` | AI service config (masked — API keys redacted) |
|
||||
| `PATCH` | `/api/settings/ai` | Update AI provider / credentials |
|
||||
| `POST` | `/api/settings/ai/test` | Test AI connection (proxies a minimal /chat call) |
|
||||
| `GET` | `/api/settings/documents/limits` | Doc service upload limits |
|
||||
| `PATCH` | `/api/settings/documents/limits` | Update max PDF size |
|
||||
| `GET` | `/api/settings/ai` | AI service config (masked) — superuser OR `ai-service-admin` member |
|
||||
| `PATCH` | `/api/settings/ai` | Update AI provider / credentials — same access |
|
||||
| `POST` | `/api/settings/ai/test` | Test AI connection — same access |
|
||||
| `GET` | `/api/settings/documents/limits` | Doc service upload limits — superuser OR `doc-service-admin` member |
|
||||
| `PATCH` | `/api/settings/documents/limits` | Update max PDF size — same access |
|
||||
| `GET` | `/api/settings/system-prompts` | All editable system prompts — superuser OR `ai-service-admin` member |
|
||||
| `PATCH` | `/api/settings/system-prompts/{id}` | Update system prompt — same access |
|
||||
|
||||
Settings are persisted to JSON files on the `app_config` Docker named volume and read by the respective feature services.
|
||||
|
||||
Access to service-specific settings endpoints is enforced by `get_service_admin(service_id)` in `deps.py` — grants access to superusers OR members of the `{service_id}-admin` group.
|
||||
|
||||
### Feature proxies
|
||||
|
||||
All `/api/documents/*` and `/api/documents/categories/*` requests are transparently proxied to `doc-service:8001` via `httpx.AsyncClient`. The proxy:
|
||||
@@ -82,6 +86,23 @@ All `/api/documents/*` and `/api/documents/categories/*` requests are transparen
|
||||
- Strips hop-by-hop headers + `content-length`, `accept-encoding`, `content-type`
|
||||
- Returns `Response` (not `StreamingResponse`) to avoid content-length/chunked conflicts
|
||||
|
||||
### Plugin system (`/api/plugins`)
|
||||
|
||||
Generic extension/plugin infrastructure — **zero feature-specific code in backend**. Feature containers self-describe via `GET /plugin/manifest`.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/api/plugins` | user | List plugins accessible to current user |
|
||||
| `GET` | `/api/plugins/{id}/manifest` | user | Cached manifest for a plugin (404 if not accessible) |
|
||||
| `GET` | `/api/plugins/{id}/settings` | user | Proxy to feature `GET /plugin/settings` |
|
||||
| `PATCH` | `/api/plugins/{id}/settings` | user | Proxy to feature `PATCH /plugin/settings` |
|
||||
|
||||
Access is controlled by the manifest: `allow_superuser` for admins; `required_groups` for group members. `check_plugin_access(plugin_id, user, db)` in `deps.py` enforces this.
|
||||
|
||||
During each health poll, `service_health.py` also fetches `GET /plugin/manifest` from healthy services and caches it. New feature containers that expose `/plugin/manifest` automatically appear in the plugin list — no backend code changes required.
|
||||
|
||||
**Service admin group bootstrap:** On every startup, `group_bootstrap.py` creates a `{service-id}-admin` group for every registered service (idempotent). Admins add users to these groups via the Admin → Groups UI to delegate service-level administration.
|
||||
|
||||
### Database models
|
||||
|
||||
| Model | Table | Notes |
|
||||
@@ -137,6 +158,7 @@ Browser (port 5173 dev / 80 prod)
|
||||
## Future work
|
||||
|
||||
- [x] Groups system: `groups`, `group_memberships` tables; admin CRUD; add/remove members
|
||||
- [x] Generic plugin infrastructure: manifest contract, `/api/plugins` proxy router, `check_plugin_access`
|
||||
- [ ] App permissions registry: `group_app_permissions` table; AppsPage filtered by group grants
|
||||
- [ ] Doc sharing via group membership
|
||||
- [ ] App permissions registry: `user_app_permissions (user_id, app_key)`; AppsPage filtered by grants
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""add_color_mode_to_users
|
||||
|
||||
Revision ID: dd6ad2f2c211
|
||||
Revises: c7e8f9a0b1d2
|
||||
Create Date: 2026-04-17 23:42:58.222958
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = 'dd6ad2f2c211'
|
||||
down_revision: Union[str, None] = 'c7e8f9a0b1d2'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('users', sa.Column('color_mode', sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('users', 'color_mode')
|
||||
@@ -10,6 +10,7 @@ services never read a partial file.
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel
|
||||
@@ -211,3 +212,212 @@ def _get_service_prompt_defaults(service_id: str) -> dict:
|
||||
d = DocServiceSystemPrompts()
|
||||
return {"system": d.system, "user_template": d.user_template}
|
||||
return {"system": "", "user_template": ""}
|
||||
|
||||
|
||||
# ── Appearance config ──────────────────────────────────────────────────────────
|
||||
|
||||
class AppearanceConfig(BaseModel):
|
||||
theme: str = "default"
|
||||
default_mode: str = "system"
|
||||
|
||||
|
||||
def load_appearance_config() -> AppearanceConfig:
|
||||
path = _CONFIG_DIR / "appearance_config.json"
|
||||
if not path.exists():
|
||||
return AppearanceConfig()
|
||||
with path.open() as f:
|
||||
return AppearanceConfig.model_validate(json.load(f))
|
||||
|
||||
|
||||
def save_appearance_config(config: AppearanceConfig) -> None:
|
||||
path = _CONFIG_DIR / "appearance_config.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(config.model_dump(), indent=2))
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
# ── Theme file management ──────────────────────────────────────────────────────
|
||||
|
||||
_THEMES_DIR = _CONFIG_DIR / "themes"
|
||||
|
||||
# 9 required colour tokens per mode
|
||||
_REQUIRED_TOKENS = frozenset({
|
||||
"primary", "primary_hover", "accent", "accent_hover",
|
||||
"background", "surface", "border", "text_primary", "text_muted",
|
||||
})
|
||||
|
||||
_RGB_RE = re.compile(r"^\d{1,3} \d{1,3} \d{1,3}$")
|
||||
|
||||
_BUILTIN_THEMES: list[dict] = [
|
||||
{
|
||||
"id": "default",
|
||||
"label": "Default",
|
||||
"builtin": True,
|
||||
"light": {
|
||||
"primary": "37 99 235",
|
||||
"primary_hover": "29 78 216",
|
||||
"accent": "234 179 8",
|
||||
"accent_hover": "202 138 4",
|
||||
"background": "248 250 252",
|
||||
"surface": "255 255 255",
|
||||
"border": "226 232 240",
|
||||
"text_primary": "15 23 42",
|
||||
"text_muted": "100 116 139",
|
||||
},
|
||||
"dark": {
|
||||
"primary": "59 130 246",
|
||||
"primary_hover": "37 99 235",
|
||||
"accent": "250 204 21",
|
||||
"accent_hover": "234 179 8",
|
||||
"background": "15 23 42",
|
||||
"surface": "30 41 59",
|
||||
"border": "51 65 85",
|
||||
"text_primary": "203 213 225",
|
||||
"text_muted": "148 163 184",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "pastel",
|
||||
"label": "Pastel",
|
||||
"builtin": True,
|
||||
"light": {
|
||||
"primary": "124 58 237",
|
||||
"primary_hover": "109 40 217",
|
||||
"accent": "236 72 153",
|
||||
"accent_hover": "219 39 119",
|
||||
"background": "253 244 255",
|
||||
"surface": "250 245 255",
|
||||
"border": "233 213 255",
|
||||
"text_primary": "30 27 75",
|
||||
"text_muted": "107 114 128",
|
||||
},
|
||||
"dark": {
|
||||
"primary": "167 139 250",
|
||||
"primary_hover": "196 181 253",
|
||||
"accent": "244 114 182",
|
||||
"accent_hover": "251 164 200",
|
||||
"background": "30 20 51",
|
||||
"surface": "45 27 78",
|
||||
"border": "76 53 117",
|
||||
"text_primary": "233 213 255",
|
||||
"text_muted": "196 181 253",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "high-contrast",
|
||||
"label": "High Contrast",
|
||||
"builtin": True,
|
||||
"light": {
|
||||
"primary": "30 58 138",
|
||||
"primary_hover": "30 64 175",
|
||||
"accent": "220 38 38",
|
||||
"accent_hover": "185 28 28",
|
||||
"background": "255 255 255",
|
||||
"surface": "255 255 255",
|
||||
"border": "156 163 175",
|
||||
"text_primary": "0 0 0",
|
||||
"text_muted": "75 85 99",
|
||||
},
|
||||
"dark": {
|
||||
"primary": "96 165 250",
|
||||
"primary_hover": "147 197 253",
|
||||
"accent": "248 113 113",
|
||||
"accent_hover": "252 165 165",
|
||||
"background": "0 0 0",
|
||||
"surface": "10 10 10",
|
||||
"border": "55 65 81",
|
||||
"text_primary": "255 255 255",
|
||||
"text_muted": "156 163 175",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "ocean",
|
||||
"label": "Ocean Blue",
|
||||
"builtin": True,
|
||||
"light": {
|
||||
"primary": "29 78 216",
|
||||
"primary_hover": "30 58 138",
|
||||
"accent": "8 145 178",
|
||||
"accent_hover": "14 116 144",
|
||||
"background": "239 246 255",
|
||||
"surface": "219 234 254",
|
||||
"border": "147 197 253",
|
||||
"text_primary": "30 58 138",
|
||||
"text_muted": "59 130 246",
|
||||
},
|
||||
"dark": {
|
||||
"primary": "96 165 250",
|
||||
"primary_hover": "147 197 253",
|
||||
"accent": "34 211 238",
|
||||
"accent_hover": "103 232 249",
|
||||
"background": "10 22 40",
|
||||
"surface": "15 36 68",
|
||||
"border": "29 78 216",
|
||||
"text_primary": "219 234 254",
|
||||
"text_muted": "147 197 253",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def seed_builtin_themes() -> None:
|
||||
"""Create /config/themes/ and write built-in theme files if missing."""
|
||||
_THEMES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for theme in _BUILTIN_THEMES:
|
||||
path = _THEMES_DIR / f"{theme['id']}.json"
|
||||
if not path.exists():
|
||||
path.write_text(json.dumps(theme, indent=2))
|
||||
|
||||
|
||||
def load_all_themes() -> list[dict]:
|
||||
"""Return all themes from /config/themes/*.json, built-ins first."""
|
||||
if not _THEMES_DIR.exists():
|
||||
seed_builtin_themes()
|
||||
themes = []
|
||||
for f in sorted(_THEMES_DIR.glob("*.json")):
|
||||
try:
|
||||
themes.append(json.loads(f.read_text()))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
# Sort: built-ins first (preserving their original order), then custom by label
|
||||
builtin_ids = [t["id"] for t in _BUILTIN_THEMES]
|
||||
def sort_key(t: dict) -> tuple:
|
||||
tid = t.get("id", "")
|
||||
try:
|
||||
return (0, builtin_ids.index(tid))
|
||||
except ValueError:
|
||||
return (1, t.get("label", tid).lower())
|
||||
return sorted(themes, key=sort_key)
|
||||
|
||||
|
||||
def validate_theme_tokens(colors: dict) -> list[str]:
|
||||
"""Return a list of validation error messages, empty if valid."""
|
||||
errors = []
|
||||
missing = _REQUIRED_TOKENS - set(colors.keys())
|
||||
if missing:
|
||||
errors.append(f"Missing tokens: {', '.join(sorted(missing))}")
|
||||
for key, val in colors.items():
|
||||
if key in _REQUIRED_TOKENS and not _RGB_RE.match(str(val)):
|
||||
errors.append(f"Token '{key}' must be an RGB triplet like '37 99 235', got: {val!r}")
|
||||
return errors
|
||||
|
||||
|
||||
def save_theme(theme: dict) -> None:
|
||||
"""Write a theme file atomically."""
|
||||
_THEMES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = _THEMES_DIR / f"{theme['id']}.json"
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(theme, indent=2))
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def delete_theme(theme_id: str) -> None:
|
||||
"""Delete a custom theme file. Raises ValueError for built-ins, FileNotFoundError if missing."""
|
||||
path = _THEMES_DIR / f"{theme_id}.json"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(theme_id)
|
||||
data = json.loads(path.read_text())
|
||||
if data.get("builtin"):
|
||||
raise ValueError("Cannot delete a built-in theme")
|
||||
path.unlink()
|
||||
|
||||
@@ -43,3 +43,69 @@ async def get_current_admin(
|
||||
detail="Not found",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def get_service_admin(service_id: str):
|
||||
"""
|
||||
Dependency factory that grants access to service-specific admin endpoints.
|
||||
|
||||
Access is granted if the user is a global superuser OR a member of the
|
||||
'{service_id}-admin' group. Returns 404 (not 403) to hide both the
|
||||
endpoint existence and the permission model.
|
||||
|
||||
Usage:
|
||||
@router.get("/ai")
|
||||
async def get_ai_settings(_: User = Depends(get_service_admin("ai-service"))):
|
||||
"""
|
||||
async def _dep(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
if current_user.is_superuser:
|
||||
return current_user
|
||||
if await check_plugin_access(service_id, current_user, db):
|
||||
return current_user
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
|
||||
return _dep
|
||||
|
||||
|
||||
async def check_plugin_access(
|
||||
plugin_id: str,
|
||||
current_user: User,
|
||||
db: AsyncSession,
|
||||
) -> bool:
|
||||
"""
|
||||
Return True if the user may access the given plugin's settings.
|
||||
|
||||
Access is granted when any of these conditions holds:
|
||||
1. The user is a superuser AND the manifest allows superuser access.
|
||||
2. The user is a member of one of the groups listed in manifest.access.required_groups.
|
||||
|
||||
Returns False (not raises) so callers can decide how to respond.
|
||||
"""
|
||||
from app.models.group import Group, GroupMembership
|
||||
from app.services.service_health import get_cached_manifest
|
||||
|
||||
manifest = get_cached_manifest(plugin_id)
|
||||
if manifest is None:
|
||||
return False
|
||||
|
||||
access = manifest.get("access", {})
|
||||
|
||||
if current_user.is_superuser and access.get("allow_superuser", True):
|
||||
return True
|
||||
|
||||
for group_name in access.get("required_groups", []):
|
||||
result = await db.execute(
|
||||
select(GroupMembership)
|
||||
.join(Group, Group.id == GroupMembership.group_id)
|
||||
.where(
|
||||
Group.name == group_name,
|
||||
GroupMembership.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none() is not None:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
+9
-1
@@ -4,18 +4,25 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.app_config import seed_builtin_themes
|
||||
from app.core.config import settings
|
||||
from app.routers import admin, auth, categories_proxy, documents_proxy, groups, profile, services, users
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.routers import admin, auth, categories_proxy, documents_proxy, groups, plugins, profile, services, users
|
||||
from app.routers import settings as settings_router
|
||||
from app.services.group_bootstrap import ensure_service_admin_groups
|
||||
from app.services.service_health import check_all, health_check_loop, register_services
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await asyncio.to_thread(seed_builtin_themes)
|
||||
register_services(
|
||||
doc_service_url=settings.DOC_SERVICE_URL,
|
||||
ai_service_url=settings.AI_SERVICE_URL,
|
||||
)
|
||||
# Create <service-id>-admin groups for every registered service (idempotent)
|
||||
async with AsyncSessionLocal() as db:
|
||||
await ensure_service_admin_groups(db)
|
||||
# Run an initial check immediately so the first API response is accurate
|
||||
await check_all()
|
||||
task = asyncio.create_task(health_check_loop())
|
||||
@@ -44,6 +51,7 @@ app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
|
||||
app.include_router(groups.router, prefix="/api/admin/groups", tags=["admin"])
|
||||
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
|
||||
app.include_router(services.router, prefix="/api/services", tags=["services"])
|
||||
app.include_router(plugins.router, prefix="/api/plugins", tags=["plugins"])
|
||||
# categories_proxy MUST be registered before documents_proxy —
|
||||
# otherwise /api/documents/{path:path} swallows /api/documents/categories/*
|
||||
app.include_router(
|
||||
|
||||
@@ -23,6 +23,8 @@ class User(Base):
|
||||
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# List of service IDs pinned to the user's home dashboard.
|
||||
dashboard_app_ids: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
|
||||
# User's preferred colour mode: "light", "dark", "system", or None (use admin default).
|
||||
color_mode: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
|
||||
|
||||
profile: Mapped["Profile"] = relationship(
|
||||
"Profile", back_populates="user", uselist=False, cascade="all, delete-orphan"
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Generic plugin proxy.
|
||||
|
||||
Feature containers advertise themselves via GET /plugin/manifest. The backend
|
||||
health-poller caches those manifests. This router exposes them to the browser
|
||||
through auth-gated endpoints so the frontend never needs to know about specific
|
||||
features.
|
||||
|
||||
Routes:
|
||||
GET /api/plugins → list accessible plugins for current user
|
||||
GET /api/plugins/{id}/manifest → cached manifest (404 if not accessible)
|
||||
GET /api/plugins/{id}/settings → proxy to feature /plugin/settings
|
||||
PATCH /api/plugins/{id}/settings → proxy to feature /plugin/settings
|
||||
"""
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import check_plugin_access, get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.service_health import get_cached_manifest, get_registry, get_service_url
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_HOP_BY_HOP = frozenset([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"host",
|
||||
"accept-encoding",
|
||||
])
|
||||
_STRIP_RESPONSE = frozenset([*_HOP_BY_HOP, "content-length", "content-type"])
|
||||
|
||||
|
||||
async def _proxy(plugin_id: str, method: str, path: str, body: bytes | None,
|
||||
content_type: str | None = None) -> Response:
|
||||
"""Forward a request to the feature container's plugin endpoint."""
|
||||
url = get_service_url(plugin_id)
|
||||
if url is None:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
if content_type:
|
||||
headers["content-type"] = content_type
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=url, timeout=30.0) as client:
|
||||
resp = await client.request(method, path, content=body, headers=headers)
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Plugin service unreachable: {exc}")
|
||||
|
||||
resp_headers = {k: v for k, v in resp.headers.items() if k.lower() not in _STRIP_RESPONSE}
|
||||
return Response(
|
||||
content=resp.content,
|
||||
status_code=resp.status_code,
|
||||
headers=resp_headers,
|
||||
media_type=resp.headers.get("content-type", "application/json"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_plugins(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
"""Return the list of plugins the current user may access."""
|
||||
accessible = []
|
||||
for svc in get_registry():
|
||||
manifest = get_cached_manifest(svc.id)
|
||||
if manifest is None:
|
||||
continue
|
||||
if await check_plugin_access(svc.id, current_user, db):
|
||||
accessible.append({
|
||||
"id": manifest["id"],
|
||||
"name": manifest["name"],
|
||||
"icon": manifest.get("icon", "package"),
|
||||
"version": manifest.get("version", ""),
|
||||
})
|
||||
return accessible
|
||||
|
||||
|
||||
@router.get("/{plugin_id}/manifest")
|
||||
async def get_plugin_manifest(
|
||||
plugin_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
if not await check_plugin_access(plugin_id, current_user, db):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
manifest = get_cached_manifest(plugin_id)
|
||||
if manifest is None:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return manifest
|
||||
|
||||
|
||||
@router.get("/{plugin_id}/settings")
|
||||
async def get_plugin_settings(
|
||||
plugin_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
if not await check_plugin_access(plugin_id, current_user, db):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return await _proxy(plugin_id, "GET", "/plugin/settings", None)
|
||||
|
||||
|
||||
@router.patch("/{plugin_id}/settings")
|
||||
async def update_plugin_settings(
|
||||
plugin_id: str,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
if not await check_plugin_access(plugin_id, current_user, db):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
body = await request.body()
|
||||
content_type = request.headers.get("content-type", "application/json")
|
||||
return await _proxy(plugin_id, "PATCH", "/plugin/settings", body, content_type)
|
||||
@@ -5,6 +5,7 @@ All endpoints require the caller to be an admin (Depends(get_current_admin)).
|
||||
Config files live on the shared app_config volume (/config/).
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -12,18 +13,25 @@ from pydantic import BaseModel
|
||||
|
||||
from app.core.app_config import (
|
||||
SYSTEM_PROMPT_SERVICES,
|
||||
AppearanceConfig,
|
||||
_merge_api_key,
|
||||
delete_theme,
|
||||
load_ai_service_config,
|
||||
load_ai_service_config_masked,
|
||||
load_all_system_prompts,
|
||||
load_all_themes,
|
||||
load_appearance_config,
|
||||
save_appearance_config,
|
||||
load_doc_service_config,
|
||||
load_doc_service_config_masked,
|
||||
save_ai_service_config,
|
||||
save_doc_service_config,
|
||||
save_service_system_prompts,
|
||||
save_theme,
|
||||
validate_theme_tokens,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.deps import get_current_admin
|
||||
from app.deps import get_current_admin, get_current_user, get_service_admin
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
@@ -53,12 +61,42 @@ class SystemPromptUpdate(BaseModel):
|
||||
user_template: str
|
||||
|
||||
|
||||
class AppearanceUpdate(BaseModel):
|
||||
theme: str
|
||||
default_mode: str
|
||||
|
||||
|
||||
class ThemeColors(BaseModel):
|
||||
primary: str
|
||||
primary_hover: str
|
||||
accent: str
|
||||
accent_hover: str
|
||||
background: str
|
||||
surface: str
|
||||
border: str
|
||||
text_primary: str
|
||||
text_muted: str
|
||||
|
||||
|
||||
class ThemeCreate(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
light: ThemeColors
|
||||
dark: ThemeColors
|
||||
|
||||
|
||||
class ThemeUpdate(BaseModel):
|
||||
label: str | None = None
|
||||
light: ThemeColors | None = None
|
||||
dark: ThemeColors | None = None
|
||||
|
||||
|
||||
# ── AI settings ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/ai")
|
||||
async def get_ai_settings(
|
||||
_: User = Depends(get_current_admin),
|
||||
_: User = Depends(get_service_admin("ai-service")),
|
||||
) -> dict:
|
||||
return load_ai_service_config_masked()
|
||||
|
||||
@@ -66,7 +104,7 @@ async def get_ai_settings(
|
||||
@router.patch("/ai")
|
||||
async def update_ai_settings(
|
||||
body: AIProviderUpdate,
|
||||
_: User = Depends(get_current_admin),
|
||||
_: User = Depends(get_service_admin("ai-service")),
|
||||
) -> dict:
|
||||
valid_providers = ("anthropic", "ollama", "lmstudio")
|
||||
if body.provider not in valid_providers:
|
||||
@@ -107,7 +145,7 @@ async def update_ai_settings(
|
||||
|
||||
@router.post("/ai/test")
|
||||
async def test_ai_connection(
|
||||
_: User = Depends(get_current_admin),
|
||||
_: User = Depends(get_service_admin("ai-service")),
|
||||
) -> dict:
|
||||
"""Proxy a minimal chat request to ai-service to verify the connection."""
|
||||
try:
|
||||
@@ -133,7 +171,7 @@ async def test_ai_connection(
|
||||
|
||||
@router.get("/documents/limits")
|
||||
async def get_documents_limits(
|
||||
_: User = Depends(get_current_admin),
|
||||
_: User = Depends(get_service_admin("doc-service")),
|
||||
) -> dict:
|
||||
return load_doc_service_config_masked()
|
||||
|
||||
@@ -141,7 +179,7 @@ async def get_documents_limits(
|
||||
@router.patch("/documents/limits")
|
||||
async def update_documents_limits(
|
||||
body: LimitsUpdate,
|
||||
_: User = Depends(get_current_admin),
|
||||
_: User = Depends(get_service_admin("doc-service")),
|
||||
) -> dict:
|
||||
if body.max_pdf_mb < 1 or body.max_pdf_mb > 200:
|
||||
raise HTTPException(status_code=422, detail="max_pdf_mb must be between 1 and 200")
|
||||
@@ -157,7 +195,7 @@ async def update_documents_limits(
|
||||
|
||||
@router.get("/system-prompts")
|
||||
async def get_system_prompts(
|
||||
_: User = Depends(get_current_admin),
|
||||
_: User = Depends(get_service_admin("ai-service")),
|
||||
) -> dict:
|
||||
"""Return all editable system prompts, keyed by service id."""
|
||||
return await asyncio.to_thread(load_all_system_prompts)
|
||||
@@ -167,7 +205,7 @@ async def get_system_prompts(
|
||||
async def update_system_prompt(
|
||||
service_id: str,
|
||||
body: SystemPromptUpdate,
|
||||
_: User = Depends(get_current_admin),
|
||||
_: User = Depends(get_service_admin("ai-service")),
|
||||
) -> dict:
|
||||
"""Update the system prompts for a single service."""
|
||||
if service_id not in SYSTEM_PROMPT_SERVICES:
|
||||
@@ -176,3 +214,108 @@ async def update_system_prompt(
|
||||
save_service_system_prompts, service_id, body.system, body.user_template
|
||||
)
|
||||
return await asyncio.to_thread(load_all_system_prompts)
|
||||
|
||||
|
||||
# ── Appearance (global default — auth read, admin write) ───────────────────────
|
||||
|
||||
import re as _re
|
||||
_THEME_ID_RE = _re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||
|
||||
|
||||
@router.get("/appearance")
|
||||
async def get_appearance(
|
||||
_: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
config = await asyncio.to_thread(load_appearance_config)
|
||||
return config.model_dump()
|
||||
|
||||
|
||||
@router.patch("/appearance")
|
||||
async def update_appearance(
|
||||
body: AppearanceUpdate,
|
||||
_: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
if body.default_mode not in ("light", "dark", "system"):
|
||||
raise HTTPException(status_code=422, detail="default_mode must be 'light', 'dark', or 'system'")
|
||||
themes = await asyncio.to_thread(load_all_themes)
|
||||
theme_ids = {t["id"] for t in themes}
|
||||
if body.theme not in theme_ids:
|
||||
raise HTTPException(status_code=422, detail=f"Unknown theme: {body.theme!r}")
|
||||
config = AppearanceConfig(theme=body.theme, default_mode=body.default_mode)
|
||||
await asyncio.to_thread(save_appearance_config, config)
|
||||
return config.model_dump()
|
||||
|
||||
|
||||
# ── Theme CRUD ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/themes")
|
||||
async def list_themes(
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
return await asyncio.to_thread(load_all_themes)
|
||||
|
||||
|
||||
@router.post("/themes", status_code=201)
|
||||
async def create_theme(
|
||||
body: ThemeCreate,
|
||||
_: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
if not _THEME_ID_RE.match(body.id):
|
||||
raise HTTPException(status_code=422, detail="Theme ID must match [a-z0-9_-]{1,64}")
|
||||
existing = {t["id"] for t in await asyncio.to_thread(load_all_themes)}
|
||||
if body.id in existing:
|
||||
raise HTTPException(status_code=400, detail=f"Theme ID already in use: {body.id!r}")
|
||||
light = body.light.model_dump()
|
||||
dark = body.dark.model_dump()
|
||||
for mode, colors in (("light", light), ("dark", dark)):
|
||||
errors = validate_theme_tokens(colors)
|
||||
if errors:
|
||||
raise HTTPException(status_code=422, detail=f"{mode}: {'; '.join(errors)}")
|
||||
theme = {"id": body.id, "label": body.label, "builtin": False, "light": light, "dark": dark}
|
||||
await asyncio.to_thread(save_theme, theme)
|
||||
return theme
|
||||
|
||||
|
||||
@router.patch("/themes/{theme_id}")
|
||||
async def update_theme(
|
||||
theme_id: str,
|
||||
body: ThemeUpdate,
|
||||
_: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
from app.core.app_config import _THEMES_DIR
|
||||
path = _THEMES_DIR / f"{theme_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="Theme not found")
|
||||
theme = json.loads(path.read_text())
|
||||
if theme.get("builtin"):
|
||||
raise HTTPException(status_code=400, detail="Cannot edit a built-in theme")
|
||||
if body.label is not None:
|
||||
theme["label"] = body.label
|
||||
if body.light is not None:
|
||||
light = body.light.model_dump()
|
||||
errors = validate_theme_tokens(light)
|
||||
if errors:
|
||||
raise HTTPException(status_code=422, detail=f"light: {'; '.join(errors)}")
|
||||
theme["light"] = light
|
||||
if body.dark is not None:
|
||||
dark = body.dark.model_dump()
|
||||
errors = validate_theme_tokens(dark)
|
||||
if errors:
|
||||
raise HTTPException(status_code=422, detail=f"dark: {'; '.join(errors)}")
|
||||
theme["dark"] = dark
|
||||
await asyncio.to_thread(save_theme, theme)
|
||||
return theme
|
||||
|
||||
|
||||
@router.delete("/themes/{theme_id}", status_code=204)
|
||||
async def remove_theme(
|
||||
theme_id: str,
|
||||
_: User = Depends(get_current_admin),
|
||||
) -> None:
|
||||
try:
|
||||
await asyncio.to_thread(delete_theme, theme_id)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Theme not found")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.user import DashboardPrefsOut, DashboardPrefsUpdate, UserOut
|
||||
from app.schemas.user import ColorModeUpdate, DashboardPrefsOut, DashboardPrefsUpdate, UserOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -29,3 +29,15 @@ async def update_preferences(
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
return DashboardPrefsOut(app_ids=current_user.dashboard_app_ids or [])
|
||||
|
||||
|
||||
@router.patch("/me/color-mode", response_model=UserOut)
|
||||
async def update_color_mode(
|
||||
body: ColorModeUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
current_user.color_mode = body.color_mode
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
return current_user
|
||||
|
||||
@@ -71,6 +71,7 @@ class UserOut(BaseModel):
|
||||
# validation_alias reads is_superuser from the ORM object; the JSON key
|
||||
# in the response is the field name "is_admin" (not the alias).
|
||||
is_admin: bool = Field(validation_alias="is_superuser", default=False)
|
||||
color_mode: str | None = None
|
||||
|
||||
model_config = {"from_attributes": True, "populate_by_name": True}
|
||||
|
||||
@@ -104,6 +105,17 @@ class DashboardPrefsOut(BaseModel):
|
||||
app_ids: list[str]
|
||||
|
||||
|
||||
class ColorModeUpdate(BaseModel):
|
||||
color_mode: str
|
||||
|
||||
@field_validator("color_mode")
|
||||
@classmethod
|
||||
def validate_mode(cls, v: str) -> str:
|
||||
if v not in ("light", "dark", "system"):
|
||||
raise ValueError("color_mode must be 'light', 'dark', or 'system'")
|
||||
return v
|
||||
|
||||
|
||||
class DashboardPrefsUpdate(BaseModel):
|
||||
app_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Ensure that every registered service has a corresponding admin group.
|
||||
|
||||
Called once at startup after register_services(). Idempotent — safe to run
|
||||
on every restart, creates nothing if groups already exist.
|
||||
|
||||
Naming convention: "{service_id}-admin" (e.g. "doc-service-admin")
|
||||
"""
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.group import Group
|
||||
from app.services.service_health import get_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def ensure_service_admin_groups(db: AsyncSession) -> None:
|
||||
"""Create a <service-id>-admin group for each registered service if absent."""
|
||||
for svc in get_registry():
|
||||
group_name = f"{svc.id}-admin"
|
||||
result = await db.execute(select(Group).where(Group.name == group_name))
|
||||
if result.scalar_one_or_none() is not None:
|
||||
continue
|
||||
|
||||
import uuid
|
||||
group = Group(
|
||||
id=str(uuid.uuid4()),
|
||||
name=group_name,
|
||||
description=f"Administrators for the {svc.name} service.",
|
||||
)
|
||||
db.add(group)
|
||||
logger.info("[bootstrap] Created admin group %r for service %r", group_name, svc.id)
|
||||
|
||||
await db.commit()
|
||||
@@ -2,8 +2,9 @@
|
||||
Background health-checker for registered feature services.
|
||||
|
||||
Polls each service's /health endpoint every POLL_INTERVAL seconds and stores
|
||||
the result in an in-memory dict. The REST layer reads from that dict — no DB,
|
||||
no blocking calls on the request path.
|
||||
the result in an in-memory dict. Also fetches /plugin/manifest when available
|
||||
and caches it so the plugin proxy can serve it without per-request network calls.
|
||||
The REST layer reads from that dict — no DB, no blocking calls on the request path.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
@@ -35,10 +36,13 @@ _REGISTRY: list[ServiceDefinition] = []
|
||||
# id → True/False/None (None = not yet checked)
|
||||
_health: dict[str, bool | None] = {}
|
||||
|
||||
# id → plugin manifest dict, or None if the service has no plugin manifest
|
||||
_manifests: dict[str, dict | None] = {}
|
||||
|
||||
|
||||
def register_services(doc_service_url: str, ai_service_url: str) -> None:
|
||||
"""Called once during app startup to populate the registry from config."""
|
||||
global _REGISTRY, _health
|
||||
global _REGISTRY, _health, _manifests
|
||||
|
||||
_REGISTRY = [
|
||||
ServiceDefinition(
|
||||
@@ -48,7 +52,7 @@ def register_services(doc_service_url: str, ai_service_url: str) -> None:
|
||||
internal_url=doc_service_url,
|
||||
health_path="/health",
|
||||
app_path="/apps/documents",
|
||||
settings_path="/apps/documents/settings/admin",
|
||||
settings_path="/apps/documents/settings",
|
||||
),
|
||||
ServiceDefinition(
|
||||
id="ai-service",
|
||||
@@ -57,11 +61,12 @@ def register_services(doc_service_url: str, ai_service_url: str) -> None:
|
||||
internal_url=ai_service_url,
|
||||
health_path="/health",
|
||||
app_path="",
|
||||
settings_path="/apps/ai/settings/admin",
|
||||
settings_path="/apps/ai/settings",
|
||||
),
|
||||
]
|
||||
|
||||
_health = {svc.id: None for svc in _REGISTRY}
|
||||
_manifests = {svc.id: None for svc in _REGISTRY}
|
||||
logger.info("Service registry initialised with %d services", len(_REGISTRY))
|
||||
|
||||
|
||||
@@ -88,6 +93,25 @@ async def _check_service(svc: ServiceDefinition) -> None:
|
||||
else:
|
||||
logger.warning("Service %s is now UNHEALTHY", svc.id)
|
||||
|
||||
# Opportunistically fetch plugin manifest when the service is healthy
|
||||
if healthy:
|
||||
await _fetch_manifest(svc)
|
||||
|
||||
|
||||
async def _fetch_manifest(svc: ServiceDefinition) -> None:
|
||||
"""Try to GET /plugin/manifest from the service; cache result (or None)."""
|
||||
url = f"{svc.internal_url}/plugin/manifest"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code == 200:
|
||||
_manifests[svc.id] = resp.json()
|
||||
else:
|
||||
_manifests[svc.id] = None
|
||||
except Exception:
|
||||
# Service doesn't have a plugin manifest — not an error
|
||||
_manifests[svc.id] = None
|
||||
|
||||
|
||||
async def check_all() -> None:
|
||||
"""Run health checks for all registered services concurrently."""
|
||||
@@ -125,3 +149,21 @@ def get_all_statuses() -> list[dict]:
|
||||
}
|
||||
for svc in _REGISTRY
|
||||
]
|
||||
|
||||
|
||||
def get_cached_manifest(service_id: str) -> dict | None:
|
||||
"""Return the cached plugin manifest for a service, or None if unavailable."""
|
||||
return _manifests.get(service_id)
|
||||
|
||||
|
||||
def get_service_url(service_id: str) -> str | None:
|
||||
"""Return the internal URL for a registered service, or None if unknown."""
|
||||
for svc in _REGISTRY:
|
||||
if svc.id == service_id:
|
||||
return svc.internal_url
|
||||
return None
|
||||
|
||||
|
||||
def get_registry() -> list[ServiceDefinition]:
|
||||
"""Return the current service registry (always up-to-date after register_services)."""
|
||||
return _REGISTRY
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# 2026-04-18 — Generic Plugin Architecture + Watch Directory Feature
|
||||
|
||||
**Timestamp:** 2026-04-18T00:00:00Z
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented a generic plugin/extension infrastructure that allows feature containers to self-describe their settings via a manifest contract, with no feature-specific code required in the backend or frontend. Built the watch-directory feature entirely inside the doc-service container as the first plugin consumer.
|
||||
|
||||
## Files Added
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `backend/app/routers/plugins.py` | Generic plugin proxy: `GET/PATCH /api/plugins`, `/api/plugins/{id}/manifest`, `/api/plugins/{id}/settings` |
|
||||
| `frontend/src/components/PluginSchemaForm.tsx` | JSON Schema → React form renderer (boolean/string/number/readOnly) |
|
||||
| `frontend/src/pages/PluginSettingsPage.tsx` | Generic plugin settings page driven by manifest |
|
||||
| `features/doc-service/app/routers/plugin.py` | Doc-service plugin endpoints: `/plugin/manifest`, `/plugin/settings` |
|
||||
| `features/doc-service/app/services/file_watcher.py` | watchdog-based PDF watcher with startup scan, folder-to-category mapping, no-remove policy |
|
||||
| `features/doc-service/alembic/versions/0003_add_watch_columns.py` | Migration: source, watch_path, suggested_folder, suggested_filename |
|
||||
| `dev-watch/.gitkeep` | Dev bind-mount directory for local file watcher testing |
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `backend/app/services/service_health.py` | Also fetches and caches `/plugin/manifest` from healthy services |
|
||||
| `backend/app/deps.py` | Added `check_plugin_access(plugin_id, user, db)` helper |
|
||||
| `backend/app/main.py` | Mounted `/api/plugins` router |
|
||||
| `frontend/src/api/client.ts` | Added plugin API functions and suggestion confirm/reject functions; extended `DocumentOut` with new fields |
|
||||
| `frontend/src/components/Sidebar.tsx` | Added dynamic "Extensions" section populated from `/api/plugins` |
|
||||
| `frontend/src/App.tsx` | Added `/settings/plugins/:id` route |
|
||||
| `features/doc-service/app/models/document.py` | Added 4 new columns: source, watch_path, suggested_folder, suggested_filename |
|
||||
| `features/doc-service/app/schemas/document.py` | Exposed 4 new fields in `DocumentOut` |
|
||||
| `features/doc-service/app/services/config_reader.py` | Added storage config defaults, `get_storage_config()`, `save_storage_config()` |
|
||||
| `features/doc-service/app/routers/documents.py` | Watch-user visibility (`OR user_id = "watch"`); 4 suggestion endpoints |
|
||||
| `features/doc-service/app/routers/categories.py` | Watch-owned categories included in list |
|
||||
| `features/doc-service/app/main.py` | Lifespan watcher start/stop; plugin router mounted |
|
||||
| `features/doc-service/pyproject.toml` | Added `watchdog>=4.0` |
|
||||
| `features/doc-service/Dockerfile` | Pre-create `/data/watch` |
|
||||
| `docker-compose.yml` | Added `watch_data` named volume; mounted to doc-service |
|
||||
| `docker-compose.dev.yml` | Dev bind-mount `./dev-watch:/data/watch` |
|
||||
| `CLAUDE.md` | Updated all affected sections (models, migrations, endpoints, routes, tree, query keys, volumes) |
|
||||
| `backend/STATUS.md` | Plugin system section added |
|
||||
| `features/doc-service/STATUS.md` | Watch feature, plugin endpoints, migration 0003, updated architecture diagram |
|
||||
| `frontend/STATUS.md` | Extensions sidebar, PluginSchemaForm, PluginSettingsPage, new API functions |
|
||||
@@ -0,0 +1,24 @@
|
||||
# 2026-04-18 — Service admin groups + combined settings pages
|
||||
|
||||
**Timestamp:** 2026-04-18T00:00:00Z
|
||||
|
||||
## Summary
|
||||
|
||||
Introduced per-service admin groups that are auto-created at startup, consolidated doc-service and AI-service settings each onto a single page, and collapsed the dual "Settings + Extension" app card buttons into one Settings button visible to admins and service-group members.
|
||||
|
||||
## Files Added
|
||||
|
||||
- `backend/app/services/group_bootstrap.py` — Idempotent startup task: creates `{service_id}-admin` group for every registered service if absent.
|
||||
- `features/ai-service/app/routers/plugin.py` — `GET /plugin/manifest` for ai-service (exposes access rules: `ai-service-admin` group).
|
||||
- `frontend/src/pages/DocServiceSettingsPage.tsx` — Combined doc-service settings page: Upload Limits + Watch Directory (rendered via `PluginSchemaForm`).
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `backend/app/main.py` — Lifespan now calls `ensure_service_admin_groups(db)` after `register_services()`.
|
||||
- `backend/app/deps.py` — Added `get_service_admin(service_id)` factory dependency: grants access to superusers or `{service_id}-admin` group members; returns 404 otherwise.
|
||||
- `backend/app/routers/settings.py` — AI settings (`/ai`, `/ai/test`, `/system-prompts`) and doc limits (`/documents/limits`) now use `get_service_admin(...)` instead of `get_current_admin` — service group members can access them.
|
||||
- `backend/app/services/service_health.py` — `settings_path` for doc-service changed to `/apps/documents/settings`; ai-service to `/apps/ai/settings` (removed `/admin` suffix).
|
||||
- `features/ai-service/app/main.py` — Mounts new `plugin.router` so backend poller can discover ai-service manifest.
|
||||
- `frontend/src/App.tsx` — Added `ServiceAdminRoute` component (checks token + is_admin OR plugin list contains serviceId). Updated doc/AI settings routes to new paths under `ServiceAdminRoute`.
|
||||
- `frontend/src/pages/AppsPage.tsx` — Replaced two-button layout (Settings + Extension) with single Settings button; visible when `user.is_admin || pluginIds.has(svc.id)`.
|
||||
- `backend/STATUS.md`, `frontend/STATUS.md`, `CLAUDE.md` — Updated to reflect all changes above.
|
||||
@@ -0,0 +1 @@
|
||||
# Watch directory for development testing
|
||||
@@ -34,3 +34,4 @@ services:
|
||||
env_file: ./features/doc-service/.env
|
||||
volumes:
|
||||
- ./features/doc-service:/app
|
||||
- ./dev-watch:/data/watch # bind-mount local folder for easy testing
|
||||
|
||||
@@ -70,6 +70,7 @@ services:
|
||||
AI_SERVICE_URL: http://ai-service:8010
|
||||
volumes:
|
||||
- doc_data:/data/documents
|
||||
- watch_data:/data/watch
|
||||
- app_config:/config
|
||||
depends_on:
|
||||
db:
|
||||
@@ -98,6 +99,7 @@ services:
|
||||
volumes:
|
||||
postgres_data:
|
||||
doc_data: # PDF files persisted across restarts
|
||||
watch_data: # Watch directory — bind-mount your NAS/Nextcloud here via docker-compose.override.yml
|
||||
app_config: # Per-service runtime config JSON files
|
||||
|
||||
networks:
|
||||
|
||||
@@ -4,7 +4,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.core.config import settings
|
||||
from app.routers import chat, health
|
||||
from app.routers import chat, health, plugin
|
||||
from app.routers import queue as queue_router
|
||||
from app.services.config_reader import load_ai_config
|
||||
from app.services.queue import queue_service
|
||||
@@ -33,3 +33,4 @@ app = FastAPI(title=settings.PROJECT_NAME, lifespan=lifespan)
|
||||
app.include_router(chat.router, tags=["chat"])
|
||||
app.include_router(health.router, tags=["health"])
|
||||
app.include_router(queue_router.router)
|
||||
app.include_router(plugin.router, tags=["plugin"])
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Plugin manifest endpoint for the AI service.
|
||||
|
||||
Exposes GET /plugin/manifest so the backend health-poller can discover the
|
||||
service's access rules and register it in the plugin system.
|
||||
|
||||
No settings schema is exposed here — the AI service settings are complex
|
||||
(provider selection, conditional fields) and are rendered by a bespoke page
|
||||
rather than the generic PluginSchemaForm.
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_MANIFEST = {
|
||||
"id": "ai-service",
|
||||
"name": "AI Service",
|
||||
"icon": "cpu",
|
||||
"version": "1.0",
|
||||
"access": {
|
||||
"allow_superuser": True,
|
||||
"required_groups": ["ai-service-admin"],
|
||||
},
|
||||
# No settings_schema — the frontend uses a custom settings page
|
||||
"settings_schema": None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/plugin/manifest")
|
||||
async def get_manifest() -> dict:
|
||||
return _MANIFEST
|
||||
@@ -17,7 +17,7 @@ RUN groupadd --gid 1001 appuser && \
|
||||
|
||||
# Pre-create data and config dirs with correct ownership.
|
||||
# Named volumes mounted over these paths will inherit ownership on first creation.
|
||||
RUN mkdir -p /data/documents /config && chown -R appuser:appuser /data /config
|
||||
RUN mkdir -p /data/documents /data/watch /config && chown -R appuser:appuser /data /config
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
## What it is
|
||||
|
||||
PDF document management microservice. Handles upload, storage, async AI-powered extraction, tagging, categorisation, and retrieval of PDF documents on a per-user basis.
|
||||
PDF document management microservice. Handles upload, storage, async AI-powered extraction, tagging, categorisation, and retrieval of PDF documents on a per-user basis. Also supports automatic ingestion from a mounted watch directory (NAS, Nextcloud, Syncthing, etc.).
|
||||
|
||||
Port: `8001` (internal only, not exposed to host). All traffic arrives via the backend proxy (`backend/app/routers/documents_proxy.py`), which injects the authenticated `x-user-id` header.
|
||||
|
||||
Database: shared PostgreSQL instance, isolated via `alembic_version_doc_service` Alembic version table. Storage: `/data/documents/` (Docker named volume `doc_data`).
|
||||
Database: shared PostgreSQL instance, isolated via `alembic_version_doc_service` Alembic version table. Storage: `/data/documents/` (Docker named volume `doc_data`). Watch directory: `/data/watch` (named volume `watch_data` in prod; bind-mount in dev via `docker-compose.dev.yml`).
|
||||
|
||||
---
|
||||
|
||||
@@ -31,13 +31,25 @@ Database: shared PostgreSQL instance, isolated via `alembic_version_doc_service`
|
||||
| `PATCH` | `/documents/{id}/type` | Update document type |
|
||||
| `PATCH` | `/documents/{id}/tags` | Replace tag list (dedup, preserve order) |
|
||||
| `PATCH` | `/documents/{id}/title` | Update editable title |
|
||||
| `GET` | `/documents/categories` | List all categories for the user |
|
||||
| `GET` | `/documents/categories` | List all categories (user + watch) |
|
||||
| `POST` | `/documents/categories` | Create a category; triggers re-analysis of documents in similar categories |
|
||||
| `POST` | `/documents/{id}/reprocess` | Reset status to pending and re-run AI extraction; 409 if already pending/processing |
|
||||
| `PATCH` | `/documents/categories/{id}` | Rename a category |
|
||||
| `DELETE` | `/documents/categories/{id}` | Delete a category |
|
||||
| `POST` | `/documents/{id}/categories/{cat_id}` | Assign category to document |
|
||||
| `DELETE` | `/documents/{id}/categories/{cat_id}` | Remove category from document |
|
||||
| `POST` | `/documents/{id}/suggestions/folder/confirm` | Apply AI folder suggestion → create/find category + assign |
|
||||
| `POST` | `/documents/{id}/suggestions/folder/reject` | Clear AI folder suggestion |
|
||||
| `POST` | `/documents/{id}/suggestions/filename/confirm` | Apply AI filename suggestion → set title |
|
||||
| `POST` | `/documents/{id}/suggestions/filename/reject` | Clear AI filename suggestion |
|
||||
|
||||
### Plugin endpoints (internal — backend calls only)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/plugin/manifest` | Static manifest: metadata, JSON Schema for settings, access rules |
|
||||
| `GET` | `/plugin/settings` | Current watch/storage config values |
|
||||
| `PATCH` | `/plugin/settings` | Update watch/storage config (persisted to `/config/doc_service_config.json`) |
|
||||
|
||||
### Pagination & filtering (`GET /documents`)
|
||||
|
||||
@@ -59,21 +71,27 @@ Response: `{ items: [...], total: N, page: N, pages: N }`
|
||||
### Document schema
|
||||
|
||||
```
|
||||
id UUID
|
||||
user_id string (from x-user-id header)
|
||||
filename original filename
|
||||
title AI-suggested editable title (nullable)
|
||||
file_size bytes
|
||||
status pending | processing | done | failed
|
||||
document_type AI-classified type (nullable)
|
||||
extracted_data JSON string — all AI-extracted fields
|
||||
tags JSON array string — editable tags
|
||||
error_message set if status=failed
|
||||
created_at upload timestamp
|
||||
processed_at when extraction finished
|
||||
categories many-to-many via category_assignments
|
||||
id UUID
|
||||
user_id string (from x-user-id header; "watch" for watch-ingested docs)
|
||||
filename original filename
|
||||
title AI-suggested editable title (nullable)
|
||||
file_size bytes
|
||||
status pending | processing | done | failed
|
||||
document_type AI-classified type (nullable)
|
||||
extracted_data JSON string — all AI-extracted fields
|
||||
tags JSON array string — editable tags
|
||||
error_message set if status=failed
|
||||
created_at upload timestamp
|
||||
processed_at when extraction finished
|
||||
source "upload" (default) or "watch"
|
||||
watch_path original absolute path in watch directory (nullable)
|
||||
suggested_folder AI-suggested category name, pending user confirm (nullable)
|
||||
suggested_filename AI-suggested title/rename, pending user confirm (nullable)
|
||||
categories many-to-many via category_assignments
|
||||
```
|
||||
|
||||
Watch-ingested documents (`user_id = "watch"`) are visible to all authenticated users.
|
||||
|
||||
### AI extraction (via ai-service)
|
||||
|
||||
System prompt and user prompt template are loaded at runtime from `doc_service_config.json` (`system_prompts` key). Defaults are built into the service and used as fallback if the config key is absent. Changes made via the AI Settings UI take effect within 30 seconds (config cache TTL).
|
||||
@@ -93,12 +111,25 @@ Prompt sends the first 50 000 chars of extracted text. Expected JSON response in
|
||||
```
|
||||
Env override: `DOC_MAX_PDF_MB`
|
||||
|
||||
### Watch directory feature
|
||||
|
||||
Controlled via plugin settings (UI accessible to superusers and `doc-service-admin` group members):
|
||||
|
||||
- `watch_enabled` — toggle file watching (default: false)
|
||||
- `watch_path` — mount point (read-only, `/data/watch`; override via Docker volume)
|
||||
- `ai_folder_suggestion` — AI suggests a category for each ingested doc (user confirms)
|
||||
- `ai_folder_default` — default category when AI suggestion is disabled
|
||||
- `ai_rename_suggestion` — AI suggests a title for each ingested doc (user confirms)
|
||||
|
||||
On startup scan, the watcher walks the watch directory and ingests any PDFs not already in the database (idempotency check by `watch_path`). Subfolders are automatically mapped to categories (e.g. `watch/invoices/bill.pdf` → category "invoices"). No-remove policy: deleting a file from the watch directory does not delete the document record.
|
||||
|
||||
### Database migrations
|
||||
|
||||
| Revision | Description |
|
||||
|----------|-------------|
|
||||
| 0001 | Initial schema (documents, categories, category_assignments) |
|
||||
| 0002 | Add `title` column to documents |
|
||||
| 0003 | Add `source`, `watch_path`, `suggested_folder`, `suggested_filename` columns |
|
||||
|
||||
Run automatically on container start via `alembic upgrade head`.
|
||||
|
||||
@@ -109,18 +140,26 @@ Run automatically on container start via `alembic upgrade head`.
|
||||
```
|
||||
backend (proxy) → doc-service:8001
|
||||
│
|
||||
documents.py router
|
||||
│
|
||||
┌────────┴────────┐
|
||||
upload list/get/patch
|
||||
│
|
||||
save_upload() pdfplumber extraction
|
||||
│ │
|
||||
Document(status=pending) ai_client.classify_document()
|
||||
│ │
|
||||
BackgroundTask ai-service:8010/chat
|
||||
│ │
|
||||
process_document() JSON result → update doc row
|
||||
┌────────────┼────────────────────┐
|
||||
documents.py categories.py plugin.py
|
||||
│ │ (internal only)
|
||||
┌────────┴────────┐
|
||||
upload list/get/patch/suggest
|
||||
│
|
||||
save_upload() pdfplumber extraction
|
||||
│ │
|
||||
Document(status=pending) ai_client.classify_document()
|
||||
│ │
|
||||
BackgroundTask ai-service:8010/chat
|
||||
│ │
|
||||
process_document() JSON result → update doc row
|
||||
|
||||
file_watcher.py (watchdog Observer, daemon thread)
|
||||
│
|
||||
├── _PdfEventHandler.on_created / on_moved
|
||||
│ └── asyncio.run_coroutine_threadsafe(ingest_file, loop)
|
||||
│
|
||||
└── _scan_existing() on startup (catches offline gaps)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -140,6 +179,8 @@ backend (proxy) → doc-service:8001
|
||||
## Future work
|
||||
|
||||
- [x] `POST /documents/{id}/reprocess` — re-run AI extraction
|
||||
- [x] Watch directory feature with file watcher, startup scan, folder-to-category mapping, AI suggestion toggles
|
||||
- [x] Plugin manifest endpoint (`/plugin/manifest`, `/plugin/settings`) for generic settings UI
|
||||
- [ ] Advanced filter: query `extracted_data` JSON fields (vendor, due_date, amount) — requires PostgreSQL `jsonb` column or indexed virtual columns
|
||||
- [ ] Bulk operations endpoint
|
||||
- [ ] Document sharing via groups (blocked on groups/permissions system in backend)
|
||||
@@ -147,3 +188,4 @@ backend (proxy) → doc-service:8001
|
||||
- [ ] Rate limiting on upload endpoint
|
||||
- [ ] Soft delete with restore
|
||||
- [ ] Category rename / delete with cascade handling
|
||||
- [ ] Frontend UI for suggestion badges (suggested_folder / suggested_filename confirm/reject buttons)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add watch directory columns to documents
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-04-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0003"
|
||||
down_revision: Union[str, None] = "0002"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("documents", sa.Column("source", sa.String(16), nullable=False, server_default="upload"))
|
||||
op.add_column("documents", sa.Column("watch_path", sa.String(), nullable=True))
|
||||
op.add_column("documents", sa.Column("suggested_folder", sa.String(128), nullable=True))
|
||||
op.add_column("documents", sa.Column("suggested_filename", sa.String(500), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("documents", "suggested_filename")
|
||||
op.drop_column("documents", "suggested_folder")
|
||||
op.drop_column("documents", "watch_path")
|
||||
op.drop_column("documents", "source")
|
||||
@@ -1,15 +1,45 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.core.config import settings
|
||||
from app.routers import categories, documents
|
||||
from app.routers import plugin as plugin_router
|
||||
|
||||
app = FastAPI(title=settings.PROJECT_NAME)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
loop = asyncio.get_running_loop()
|
||||
watcher = None
|
||||
|
||||
try:
|
||||
from app.services.config_reader import get_storage_config
|
||||
storage_config = await get_storage_config()
|
||||
if storage_config.get("watch_enabled"):
|
||||
from app.services.file_watcher import FileWatcherService
|
||||
watcher = FileWatcherService(loop)
|
||||
await watcher.start(storage_config["watch_path"], storage_config)
|
||||
except Exception as exc:
|
||||
logger.warning("[doc-service] File watcher could not start: %s", exc)
|
||||
|
||||
yield
|
||||
|
||||
if watcher is not None:
|
||||
await watcher.stop()
|
||||
|
||||
|
||||
app = FastAPI(title=settings.PROJECT_NAME, lifespan=lifespan)
|
||||
|
||||
# No CORS — this service is only reachable from the main backend on backend-net.
|
||||
# All browser traffic goes through the main backend proxy.
|
||||
|
||||
app.include_router(documents.router, prefix="/documents", tags=["documents"])
|
||||
app.include_router(categories.router, prefix="/categories", tags=["categories"])
|
||||
app.include_router(plugin_router.router, prefix="/plugin", tags=["plugin"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -27,6 +27,12 @@ class Document(Base):
|
||||
)
|
||||
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Watch-directory ingestion fields (migration 0003)
|
||||
source: Mapped[str] = mapped_column(String(16), nullable=False, default="upload")
|
||||
watch_path: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
suggested_folder: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
suggested_filename: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
category_assignments: Mapped[list["CategoryAssignment"]] = relationship(
|
||||
"CategoryAssignment", back_populates="document", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
@@ -5,6 +5,8 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from sqlalchemy import or_
|
||||
|
||||
from app.database import AsyncSessionLocal, get_db
|
||||
from app.deps import get_user_id
|
||||
from app.models.category import DocumentCategory
|
||||
@@ -15,6 +17,9 @@ from app.services.ai_client import classify_document
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Sentinel user_id for watch-ingested categories — must match documents.py
|
||||
_WATCH_USER_ID = "watch"
|
||||
|
||||
_SIMILARITY_THRESHOLD = 0.4
|
||||
|
||||
|
||||
@@ -81,9 +86,10 @@ async def list_categories(
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[DocumentCategory]:
|
||||
# Include watch-ingested categories so they appear in the sidebar/filter
|
||||
result = await db.execute(
|
||||
select(DocumentCategory)
|
||||
.where(DocumentCategory.user_id == user_id)
|
||||
.where(or_(DocumentCategory.user_id == user_id, DocumentCategory.user_id == _WATCH_USER_ID))
|
||||
.order_by(DocumentCategory.name)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -26,13 +26,21 @@ router = APIRouter()
|
||||
|
||||
_DEFAULT_MAX_BYTES = 20 * 1024 * 1024
|
||||
|
||||
# Sentinel user_id used for watch-directory-ingested documents.
|
||||
# These documents are visible to all authenticated users.
|
||||
_WATCH_USER_ID = "watch"
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _get_user_doc(doc_id: str, user_id: str, db: AsyncSession) -> Document:
|
||||
"""Fetch a document owned by user_id OR a watch-ingested document (visible to all)."""
|
||||
result = await db.execute(
|
||||
select(Document)
|
||||
.where(Document.id == doc_id, Document.user_id == user_id)
|
||||
.where(
|
||||
Document.id == doc_id,
|
||||
or_(Document.user_id == user_id, Document.user_id == _WATCH_USER_ID),
|
||||
)
|
||||
.options(
|
||||
selectinload(Document.category_assignments)
|
||||
.selectinload(CategoryAssignment.category)
|
||||
@@ -61,6 +69,10 @@ def _doc_with_categories(doc: Document) -> DocumentOut:
|
||||
created_at=doc.created_at,
|
||||
processed_at=doc.processed_at,
|
||||
categories=cats,
|
||||
source=doc.source,
|
||||
watch_path=doc.watch_path,
|
||||
suggested_folder=doc.suggested_folder,
|
||||
suggested_filename=doc.suggested_filename,
|
||||
)
|
||||
|
||||
|
||||
@@ -183,7 +195,8 @@ async def list_documents(
|
||||
sort_expr = sort_col.desc() if order == "desc" else sort_col.asc()
|
||||
|
||||
# Build filter conditions once and reuse for both count + items queries.
|
||||
conditions = [Document.user_id == user_id]
|
||||
# Watch-ingested documents (user_id = "watch") are visible to all users.
|
||||
conditions = [or_(Document.user_id == user_id, Document.user_id == _WATCH_USER_ID)]
|
||||
if status:
|
||||
conditions.append(Document.status == status)
|
||||
if document_type:
|
||||
@@ -247,7 +260,10 @@ async def get_document_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Document:
|
||||
result = await db.execute(
|
||||
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
|
||||
select(Document).where(
|
||||
Document.id == doc_id,
|
||||
or_(Document.user_id == user_id, Document.user_id == _WATCH_USER_ID),
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if doc is None:
|
||||
@@ -347,7 +363,10 @@ async def download_file(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> StreamingResponse:
|
||||
result = await db.execute(
|
||||
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
|
||||
select(Document).where(
|
||||
Document.id == doc_id,
|
||||
or_(Document.user_id == user_id, Document.user_id == _WATCH_USER_ID),
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if doc is None:
|
||||
@@ -374,9 +393,12 @@ async def assign_category(
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
# Verify both belong to this user
|
||||
# Verify the document is accessible (own or watch-ingested)
|
||||
doc_result = await db.execute(
|
||||
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
|
||||
select(Document).where(
|
||||
Document.id == doc_id,
|
||||
or_(Document.user_id == user_id, Document.user_id == _WATCH_USER_ID),
|
||||
)
|
||||
)
|
||||
if doc_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
@@ -418,3 +440,81 @@ async def remove_category(
|
||||
if assignment:
|
||||
await db.delete(assignment)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── AI suggestion confirmation ────────────────────────────────────────────────
|
||||
# These endpoints allow users to confirm or reject AI suggestions on
|
||||
# watch-ingested documents. No disk mutations — suggestions only update the DB.
|
||||
|
||||
@router.post("/{doc_id}/suggestions/folder/confirm", status_code=204)
|
||||
async def confirm_folder_suggestion(
|
||||
doc_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
doc = await _get_user_doc(doc_id, user_id, db)
|
||||
if not doc.suggested_folder:
|
||||
raise HTTPException(status_code=400, detail="No folder suggestion pending")
|
||||
|
||||
# Find or create the suggested category under the watch sentinel user
|
||||
cat_result = await db.execute(
|
||||
select(DocumentCategory).where(
|
||||
DocumentCategory.user_id == _WATCH_USER_ID,
|
||||
DocumentCategory.name == doc.suggested_folder,
|
||||
)
|
||||
)
|
||||
cat = cat_result.scalar_one_or_none()
|
||||
if cat is None:
|
||||
cat = DocumentCategory(user_id=_WATCH_USER_ID, name=doc.suggested_folder[:128])
|
||||
db.add(cat)
|
||||
await db.commit()
|
||||
await db.refresh(cat)
|
||||
|
||||
# Assign if not already assigned
|
||||
exists = await db.execute(
|
||||
select(CategoryAssignment).where(
|
||||
CategoryAssignment.document_id == doc_id,
|
||||
CategoryAssignment.category_id == cat.id,
|
||||
)
|
||||
)
|
||||
if exists.scalar_one_or_none() is None:
|
||||
db.add(CategoryAssignment(document_id=doc_id, category_id=cat.id))
|
||||
|
||||
doc.suggested_folder = None
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/{doc_id}/suggestions/folder/reject", status_code=204)
|
||||
async def reject_folder_suggestion(
|
||||
doc_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
doc = await _get_user_doc(doc_id, user_id, db)
|
||||
doc.suggested_folder = None
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/{doc_id}/suggestions/filename/confirm", status_code=204)
|
||||
async def confirm_filename_suggestion(
|
||||
doc_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
doc = await _get_user_doc(doc_id, user_id, db)
|
||||
if not doc.suggested_filename:
|
||||
raise HTTPException(status_code=400, detail="No filename suggestion pending")
|
||||
doc.title = doc.suggested_filename
|
||||
doc.suggested_filename = None
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/{doc_id}/suggestions/filename/reject", status_code=204)
|
||||
async def reject_filename_suggestion(
|
||||
doc_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
doc = await _get_user_doc(doc_id, user_id, db)
|
||||
doc.suggested_filename = None
|
||||
await db.commit()
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Plugin manifest and settings endpoints for doc-service.
|
||||
|
||||
These are internal-only — they are called by the main backend's generic plugin
|
||||
proxy, never directly by the browser. No authentication is applied here because
|
||||
the backend enforces access control before forwarding the request.
|
||||
|
||||
Endpoints:
|
||||
GET /plugin/manifest → static manifest with JSON Schema for settings
|
||||
GET /plugin/settings → current storage config values
|
||||
PATCH /plugin/settings → update storage config (partial update)
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services.config_reader import get_storage_config, save_storage_config
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_MANIFEST: dict = {
|
||||
"id": "doc-service",
|
||||
"name": "Document Service",
|
||||
"icon": "file-text",
|
||||
"version": "1.0",
|
||||
"access": {
|
||||
"allow_superuser": True,
|
||||
"required_groups": ["doc-service-admin"],
|
||||
},
|
||||
"settings_schema": {
|
||||
"type": "object",
|
||||
"title": "Storage & Watch",
|
||||
"properties": {
|
||||
"watch_enabled": {
|
||||
"type": "boolean",
|
||||
"title": "Enable file watching",
|
||||
"description": (
|
||||
"Automatically ingest PDF files added to the mounted watch directory. "
|
||||
"Requires a service restart to take effect after toggling."
|
||||
),
|
||||
},
|
||||
"watch_path": {
|
||||
"type": "string",
|
||||
"title": "Watch path",
|
||||
"readOnly": True,
|
||||
"description": "Configured via Docker volume mount — edit docker-compose to change.",
|
||||
},
|
||||
"ai_folder_suggestion": {
|
||||
"type": "boolean",
|
||||
"title": "AI folder suggestion",
|
||||
"description": (
|
||||
"AI suggests a category for each ingested document. "
|
||||
"You must confirm the suggestion before it is applied."
|
||||
),
|
||||
},
|
||||
"ai_folder_default": {
|
||||
"type": "string",
|
||||
"title": "Default import category",
|
||||
"description": "Category assigned automatically when AI folder suggestion is disabled.",
|
||||
},
|
||||
"ai_rename_suggestion": {
|
||||
"type": "boolean",
|
||||
"title": "AI rename suggestion",
|
||||
"description": (
|
||||
"AI suggests a document title for each ingested file. "
|
||||
"You must confirm before it is applied."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class StorageSettingsUpdate(BaseModel):
|
||||
watch_enabled: bool | None = None
|
||||
ai_folder_suggestion: bool | None = None
|
||||
ai_folder_default: str | None = None
|
||||
ai_rename_suggestion: bool | None = None
|
||||
# watch_path is intentionally excluded — it cannot be changed via API
|
||||
|
||||
|
||||
@router.get("/manifest")
|
||||
async def get_manifest() -> dict:
|
||||
return _MANIFEST
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_settings() -> dict:
|
||||
return await get_storage_config()
|
||||
|
||||
|
||||
@router.patch("/settings")
|
||||
async def update_settings(body: StorageSettingsUpdate) -> dict:
|
||||
update = body.model_dump(exclude_none=True)
|
||||
if "ai_folder_default" in update:
|
||||
update["ai_folder_default"] = update["ai_folder_default"][:128].strip() or "imports"
|
||||
await save_storage_config(update)
|
||||
return await get_storage_config()
|
||||
@@ -23,6 +23,10 @@ class DocumentOut(BaseModel):
|
||||
created_at: datetime
|
||||
processed_at: datetime | None
|
||||
categories: list[CategoryOut] = []
|
||||
source: str = "upload"
|
||||
watch_path: str | None = None
|
||||
suggested_folder: str | None = None
|
||||
suggested_filename: str | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@@ -14,6 +14,14 @@ from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_DEFAULT_STORAGE_CONFIG: dict = {
|
||||
"watch_enabled": False,
|
||||
"watch_path": "/data/watch",
|
||||
"ai_folder_suggestion": False,
|
||||
"ai_folder_default": "imports",
|
||||
"ai_rename_suggestion": False,
|
||||
}
|
||||
|
||||
_DEFAULT_SYSTEM_PROMPT = (
|
||||
"You are a financial document analysis assistant. "
|
||||
"Given the text extracted from a PDF document, return ONLY a JSON object "
|
||||
@@ -43,6 +51,7 @@ _DEFAULT_USER_TEMPLATE = (
|
||||
|
||||
_DEFAULT_CONFIG: dict = {
|
||||
"documents": {"max_pdf_bytes": 20 * 1024 * 1024},
|
||||
"storage": _DEFAULT_STORAGE_CONFIG,
|
||||
"system_prompts": {
|
||||
"system": _DEFAULT_SYSTEM_PROMPT,
|
||||
"user_template": _DEFAULT_USER_TEMPLATE,
|
||||
@@ -64,6 +73,25 @@ def _read_config_sync() -> dict:
|
||||
return _apply_env_overrides(base)
|
||||
|
||||
|
||||
def _read_config_sync_raw() -> dict:
|
||||
"""Read without env overrides — used when we need to write back to disk."""
|
||||
path = Path(settings.CONFIG_PATH)
|
||||
if not path.exists():
|
||||
return deepcopy(_DEFAULT_CONFIG)
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _write_config_sync(config: dict) -> None:
|
||||
"""Atomically write config JSON to disk."""
|
||||
path = Path(settings.CONFIG_PATH)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _apply_env_overrides(config: dict) -> dict:
|
||||
cfg = deepcopy(config)
|
||||
docs = cfg.setdefault("documents", {})
|
||||
@@ -84,3 +112,22 @@ async def load_doc_config() -> dict:
|
||||
_cache = data
|
||||
_cache_at = now
|
||||
return data
|
||||
|
||||
|
||||
async def get_storage_config() -> dict:
|
||||
"""Return storage config block, filling in defaults for any missing keys."""
|
||||
config = await load_doc_config()
|
||||
result = deepcopy(_DEFAULT_STORAGE_CONFIG)
|
||||
result.update(config.get("storage", {}))
|
||||
return result
|
||||
|
||||
|
||||
async def save_storage_config(data: dict) -> None:
|
||||
"""Merge data into the storage config block and persist to disk."""
|
||||
global _cache, _cache_at
|
||||
raw = await asyncio.to_thread(_read_config_sync_raw)
|
||||
raw.setdefault("storage", {}).update(data)
|
||||
await asyncio.to_thread(_write_config_sync, raw)
|
||||
# Invalidate cache so next read picks up the new values
|
||||
_cache = None
|
||||
_cache_at = 0.0
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
File-system watcher for the watch directory.
|
||||
|
||||
Uses the watchdog library to monitor a configured directory for new PDF files.
|
||||
When a PDF is detected, it is automatically ingested into the document service
|
||||
(copied to /data/documents, a DB record is created, and the AI pipeline runs).
|
||||
|
||||
Key design decisions:
|
||||
- No-remove policy: on_deleted and on_moved events are intentionally ignored.
|
||||
The watcher never deletes, moves, or modifies files on the watched volume.
|
||||
- Watch documents use user_id="watch" as a sentinel so they are visible to
|
||||
all authenticated users in the document list.
|
||||
- Subfolder names map to categories: a file at invoices/bill.pdf is assigned
|
||||
to a "invoices" category (auto-created if needed).
|
||||
- Suggestions: if ai_folder_suggestion or ai_rename_suggestion are enabled,
|
||||
the relevant fields are set on the document after AI processing so users
|
||||
can confirm/reject from the UI.
|
||||
- Thread → async bridge: watchdog runs in a daemon thread; asyncio coroutines
|
||||
are dispatched from that thread via run_coroutine_threadsafe.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.observers.polling import PollingObserver
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.category import DocumentCategory
|
||||
from app.models.category_assignment import CategoryAssignment
|
||||
from app.models.document import Document
|
||||
from app.services.storage import save_upload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Must match _WATCH_USER_ID in app/routers/documents.py
|
||||
WATCH_USER_ID = "watch"
|
||||
|
||||
|
||||
# ── Ingestion logic ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def ingest_file(path_str: str, watch_root: Path, config: dict) -> None:
|
||||
"""
|
||||
Ingest a single PDF file from the watch directory.
|
||||
|
||||
Idempotent: skips files that already have a non-failed document record.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
path = Path(path_str)
|
||||
if not path.exists() or not path.is_file():
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Idempotency check — skip if already tracked (and not failed)
|
||||
existing_result = await db.execute(
|
||||
select(Document).where(Document.watch_path == path_str)
|
||||
)
|
||||
existing = existing_result.scalar_one_or_none()
|
||||
if existing is not None and existing.status != "failed":
|
||||
return
|
||||
|
||||
# Determine category from the first subfolder component
|
||||
try:
|
||||
rel = path.relative_to(watch_root)
|
||||
folder_name = rel.parts[0] if len(rel.parts) > 1 else None
|
||||
except ValueError:
|
||||
folder_name = None
|
||||
|
||||
# Read file bytes
|
||||
try:
|
||||
file_data = path.read_bytes()
|
||||
except OSError as exc:
|
||||
logger.warning("[watcher] Cannot read %s: %s", path_str, exc)
|
||||
return
|
||||
|
||||
# Save a copy to /data/documents/watch/{doc_id}.pdf
|
||||
doc_id = existing.id if existing is not None else str(uuid.uuid4())
|
||||
dest = await save_upload(file_data, WATCH_USER_ID, doc_id)
|
||||
|
||||
if existing is not None:
|
||||
# Re-ingest a previously failed document
|
||||
existing.file_path = str(dest)
|
||||
existing.file_size = len(file_data)
|
||||
existing.status = "pending"
|
||||
existing.error_message = None
|
||||
await db.commit()
|
||||
else:
|
||||
doc = Document(
|
||||
id=doc_id,
|
||||
user_id=WATCH_USER_ID,
|
||||
source="watch",
|
||||
watch_path=path_str,
|
||||
filename=path.name,
|
||||
file_path=str(dest),
|
||||
file_size=len(file_data),
|
||||
status="pending",
|
||||
)
|
||||
db.add(doc)
|
||||
await db.commit()
|
||||
|
||||
# Auto-assign category from subfolder name
|
||||
if folder_name:
|
||||
cat_result = await db.execute(
|
||||
select(DocumentCategory).where(
|
||||
DocumentCategory.user_id == WATCH_USER_ID,
|
||||
DocumentCategory.name == folder_name,
|
||||
)
|
||||
)
|
||||
cat = cat_result.scalar_one_or_none()
|
||||
if cat is None:
|
||||
cat = DocumentCategory(user_id=WATCH_USER_ID, name=folder_name[:128])
|
||||
db.add(cat)
|
||||
await db.commit()
|
||||
await db.refresh(cat)
|
||||
|
||||
exists_assign = await db.execute(
|
||||
select(CategoryAssignment).where(
|
||||
CategoryAssignment.document_id == doc_id,
|
||||
CategoryAssignment.category_id == cat.id,
|
||||
)
|
||||
)
|
||||
if exists_assign.scalar_one_or_none() is None:
|
||||
db.add(CategoryAssignment(document_id=doc_id, category_id=cat.id))
|
||||
await db.commit()
|
||||
|
||||
# Run AI pipeline (opens its own session internally)
|
||||
from app.routers.documents import process_document
|
||||
await process_document(doc_id)
|
||||
|
||||
# Set AI suggestions if enabled
|
||||
if config.get("ai_folder_suggestion") or config.get("ai_rename_suggestion"):
|
||||
await _apply_suggestions(doc_id, config)
|
||||
|
||||
|
||||
async def _apply_suggestions(doc_id: str, config: dict) -> None:
|
||||
"""Populate suggested_folder / suggested_filename after AI processing."""
|
||||
from sqlalchemy import select
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(Document).where(Document.id == doc_id))
|
||||
doc = result.scalar_one_or_none()
|
||||
if doc is None or doc.status != "done" or not doc.extracted_data:
|
||||
return
|
||||
|
||||
try:
|
||||
extracted = json.loads(doc.extracted_data)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
changed = False
|
||||
if config.get("ai_folder_suggestion"):
|
||||
suggestions = extracted.get("suggested_categories", [])
|
||||
if suggestions:
|
||||
doc.suggested_folder = str(suggestions[0])[:128]
|
||||
changed = True
|
||||
|
||||
if config.get("ai_rename_suggestion"):
|
||||
title = extracted.get("title")
|
||||
if title:
|
||||
doc.suggested_filename = str(title)[:500]
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Watchdog event handler ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _PdfEventHandler(FileSystemEventHandler):
|
||||
def __init__(
|
||||
self,
|
||||
watch_root: Path,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
config: dict,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._watch_root = watch_root
|
||||
self._loop = loop
|
||||
self._config = config
|
||||
|
||||
def _dispatch_ingest(self, path_str: str) -> None:
|
||||
if path_str.lower().endswith(".pdf"):
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
ingest_file(path_str, self._watch_root, self._config),
|
||||
self._loop,
|
||||
)
|
||||
|
||||
def on_created(self, event): # type: ignore[override]
|
||||
if not event.is_directory:
|
||||
self._dispatch_ingest(event.src_path)
|
||||
|
||||
def on_moved(self, event): # type: ignore[override]
|
||||
# Handles atomic rename/move (e.g. Nextcloud or Syncthing completing a sync)
|
||||
if not event.is_directory:
|
||||
self._dispatch_ingest(event.dest_path)
|
||||
|
||||
# on_deleted / on_modified: intentionally not overridden — no-remove policy
|
||||
|
||||
|
||||
# ── Service ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FileWatcherService:
|
||||
"""Manages the watchdog Observer lifecycle within the FastAPI lifespan."""
|
||||
|
||||
def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
self._loop = loop
|
||||
self._observer: Observer | None = None
|
||||
self._watch_root: Path | None = None
|
||||
self._config: dict = {}
|
||||
|
||||
async def start(self, watch_path: str, config: dict) -> None:
|
||||
self._watch_root = Path(watch_path)
|
||||
self._config = config
|
||||
|
||||
if not self._watch_root.exists():
|
||||
logger.warning(
|
||||
"[watcher] Watch path %s does not exist — file watching disabled",
|
||||
watch_path,
|
||||
)
|
||||
return
|
||||
|
||||
handler = _PdfEventHandler(self._watch_root, self._loop, config)
|
||||
self._observer = PollingObserver()
|
||||
self._observer.schedule(handler, watch_path, recursive=True)
|
||||
self._observer.start()
|
||||
logger.info("[watcher] started, watching %s", watch_path)
|
||||
|
||||
# Run startup scan as a background task so startup is not blocked
|
||||
asyncio.create_task(self._scan_existing())
|
||||
|
||||
async def _scan_existing(self) -> None:
|
||||
"""Ingest any PDFs already present in the watch directory."""
|
||||
if self._watch_root is None:
|
||||
return
|
||||
logger.info("[watcher] scanning existing files in %s", self._watch_root)
|
||||
count = 0
|
||||
for pdf_path in sorted(self._watch_root.rglob("*.pdf")):
|
||||
try:
|
||||
await ingest_file(str(pdf_path), self._watch_root, self._config)
|
||||
count += 1
|
||||
except Exception as exc:
|
||||
logger.warning("[watcher] scan error for %s: %s", pdf_path, exc)
|
||||
logger.info("[watcher] startup scan complete — processed %d file(s)", count)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._observer is not None:
|
||||
self._observer.stop()
|
||||
await asyncio.to_thread(self._observer.join)
|
||||
self._observer = None
|
||||
logger.info("[watcher] stopped")
|
||||
@@ -17,6 +17,7 @@ dependencies = [
|
||||
"pdfplumber>=0.11",
|
||||
"aiofiles>=23.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"watchdog>=4.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
+24
-9
@@ -16,13 +16,14 @@ All API calls go through `src/api/client.ts` (single Axios instance, JWT injecte
|
||||
| `/` | `DashboardPage` | Required |
|
||||
| `/apps` | `AppsPage` | Required |
|
||||
| `/apps/documents` | `DocumentsPage` | Required |
|
||||
| `/apps/documents/settings/admin` | `DocumentAdminSettingsPage` | Admin only |
|
||||
| `/apps/ai/settings/admin` | `AIAdminSettingsPage` | Admin only |
|
||||
| `/apps/documents/settings` | `DocServiceSettingsPage` | ServiceAdminRoute (is_admin OR doc-service-admin) |
|
||||
| `/apps/ai/settings` | `AIAdminSettingsPage` | ServiceAdminRoute (is_admin OR ai-service-admin) |
|
||||
| `/admin` | `AdminPage` (redirects to `/admin/users`) | Admin only |
|
||||
| `/admin/users` | `AdminUsersPage` | Admin only |
|
||||
| `/admin/groups` | `AdminGroupsPage` | Admin only |
|
||||
| `/profile` | `ProfilePage` | Required |
|
||||
| `/settings` | `SettingsPage` (placeholder) | Required |
|
||||
| `/settings/plugins/:id` | `PluginSettingsPage` | Required (per-plugin access control) |
|
||||
|
||||
`PrivateRoute` redirects to `/login` when no token. `AdminRoute` redirects to `/` when not admin.
|
||||
|
||||
@@ -50,7 +51,7 @@ Cards are rendered dynamically from `GET /api/services` (polled every 30 s via T
|
||||
- **healthy=true + app_path set** — clickable card with "Available" badge
|
||||
- **healthy=true + no app_path** — non-clickable card (e.g. AI Service — no user UI)
|
||||
- **healthy=false** — non-clickable, dimmed card with "Unavailable" badge and explanation text
|
||||
- Admin settings link shown for admins regardless of health status
|
||||
- Single **Settings** button per card — visible to global admins OR members of the service's admin group (checked via `GET /api/plugins` which backend filters by access). Links to `svc.settings_path`.
|
||||
|
||||
### Sidebar navigation
|
||||
|
||||
@@ -89,17 +90,20 @@ Cards are rendered dynamically from `GET /api/services` (polled every 30 s via T
|
||||
- **Categories** — assigned chips with remove; dropdown to assign existing; AI-suggested chips with Accept / Create & Assign / Dismiss
|
||||
- **Status polling** — auto-refetches every 3s while status is pending/processing; invalidates document list on done/failed
|
||||
|
||||
### AI Admin Settings (`/apps/ai/settings/admin`)
|
||||
### AI Service Settings (`/apps/ai/settings`)
|
||||
|
||||
Accessible to global admins and `ai-service-admin` group members (`ServiceAdminRoute`).
|
||||
- Provider selector (lmstudio / ollama / anthropic)
|
||||
- Per-provider fields (base URL, model, API key)
|
||||
- Test Connection button (`POST /api/settings/ai/test`)
|
||||
- Save button
|
||||
|
||||
### Document Admin Settings (`/apps/documents/settings/admin`)
|
||||
### Document Service Settings (`/apps/documents/settings`)
|
||||
|
||||
- Upload Limits section only (max PDF size in MB)
|
||||
- Save button
|
||||
Accessible to global admins and `doc-service-admin` group members (`ServiceAdminRoute`).
|
||||
Combined settings on one page, accessed via the single "Settings" button on the app card:
|
||||
- **Upload Limits** — max PDF size in MB (`GET/PATCH /api/settings/documents/limits`)
|
||||
- **Watch Directory** — file watcher config rendered via `PluginSchemaForm` from manifest (`GET/PATCH /api/plugins/doc-service/settings`)
|
||||
|
||||
### Admin — Users page (`/admin/users`)
|
||||
|
||||
@@ -140,6 +144,10 @@ Key functions:
|
||||
| `removeCategory(docId, catId)` | Remove |
|
||||
| `updateDocumentTags(id, tags)` | `PATCH /documents/{id}/tags` |
|
||||
| `updateDocumentTitle(id, title)` | `PATCH /documents/{id}/title` |
|
||||
| `confirmFolderSuggestion(docId)` | `POST /documents/{id}/suggestions/folder/confirm` |
|
||||
| `rejectFolderSuggestion(docId)` | `POST /documents/{id}/suggestions/folder/reject` |
|
||||
| `confirmFilenameSuggestion(docId)` | `POST /documents/{id}/suggestions/filename/confirm` |
|
||||
| `rejectFilenameSuggestion(docId)` | `POST /documents/{id}/suggestions/filename/reject` |
|
||||
| `getAISettings()` | `GET /settings/ai` (masked) |
|
||||
| `updateAISettings(data)` | `PATCH /settings/ai` |
|
||||
| `testAIConnection()` | `POST /settings/ai/test` |
|
||||
@@ -152,6 +160,10 @@ Key functions:
|
||||
| `adminAddGroupMember(gId, uId)` | `POST /admin/groups/{gId}/members/{uId}` |
|
||||
| `adminRemoveGroupMember(gId, uId)` | `DELETE /admin/groups/{gId}/members/{uId}` |
|
||||
| `updateDocumentLimits(data)` | `PATCH /settings/documents/limits` |
|
||||
| `getPlugins()` | `GET /plugins` — list accessible plugins |
|
||||
| `getPluginManifest(id)` | `GET /plugins/{id}/manifest` |
|
||||
| `getPluginSettings(id)` | `GET /plugins/{id}/settings` |
|
||||
| `updatePluginSettings(id, data)` | `PATCH /plugins/{id}/settings` |
|
||||
|
||||
---
|
||||
|
||||
@@ -168,8 +180,10 @@ Key functions:
|
||||
| Component | Path | Description |
|
||||
|-----------|------|-------------|
|
||||
| `AppShell` | `src/components/AppShell.tsx` | Layout wrapper: Sidebar + scrollable main content |
|
||||
| `Sidebar` | `src/components/Sidebar.tsx` | Collapsible left nav (icons-only ↔ icons+labels) |
|
||||
| `Sidebar` | `src/components/Sidebar.tsx` | Collapsible left nav; includes dynamic "Extensions" section |
|
||||
| `ThemeToggle` | `src/components/ThemeToggle.tsx` | Sun/moon ghost icon button; persists to localStorage |
|
||||
| `PluginSchemaForm` | `src/components/PluginSchemaForm.tsx` | JSON Schema → React form (boolean/string/number/readOnly fields) |
|
||||
| `PluginSettingsPage` | `src/pages/PluginSettingsPage.tsx` | Generic plugin settings page (manifest-driven) |
|
||||
| `Button` | `src/components/ui/button.tsx` | shadcn/ui Button (default, ghost, outline, destructive) |
|
||||
| `Input` | `src/components/ui/input.tsx` | shadcn/ui Input |
|
||||
|
||||
@@ -188,10 +202,11 @@ Key functions:
|
||||
- [x] UI component library: shadcn/ui + Tailwind CSS — installed and wired up
|
||||
- [x] AppShell + Sidebar replacing inline Nav component
|
||||
- [x] Light/dark theme context with OS preference detection
|
||||
- [x] Generic plugin infrastructure: Extensions sidebar section, PluginSchemaForm, PluginSettingsPage
|
||||
- [ ] Suggestion badges in DocumentsPage for `suggested_folder` / `suggested_filename` (confirm/reject buttons)
|
||||
- [ ] Toast notification system (upload success, save feedback, errors)
|
||||
- [ ] Loading skeletons
|
||||
- [ ] `POST /queue/jobs` integration — show AI processing queue status / progress per document
|
||||
- [ ] Re-process document button (`POST /documents/{id}/reprocess` — needs backend endpoint first)
|
||||
- [ ] Advanced filter: extracted data fields (vendor, due date, amount) — needs backend support
|
||||
- [x] Groups admin UI — list, create, edit, delete, add/remove members
|
||||
- [ ] App permissions UI per group (blocked on backend group_app_permissions)
|
||||
|
||||
+53
-8
@@ -1,7 +1,7 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "./hooks/useAuth";
|
||||
import { getMe } from "./api/client";
|
||||
import { getMe, getPlugins } from "./api/client";
|
||||
import AppShell from "./components/AppShell";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import DashboardPage from "./pages/DashboardPage";
|
||||
@@ -10,10 +10,12 @@ import AppsPage from "./pages/AppsPage";
|
||||
import AdminPage from "./pages/AdminPage";
|
||||
import AdminUsersPage from "./pages/AdminUsersPage";
|
||||
import AdminGroupsPage from "./pages/AdminGroupsPage";
|
||||
import AdminAppearancePage from "./pages/AdminAppearancePage";
|
||||
import DocumentsPage from "./pages/DocumentsPage";
|
||||
import DocumentAdminSettingsPage from "./pages/DocumentAdminSettingsPage";
|
||||
import DocServiceSettingsPage from "./pages/DocServiceSettingsPage";
|
||||
import AIAdminSettingsPage from "./pages/AIAdminSettingsPage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import PluginSettingsPage from "./pages/PluginSettingsPage";
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const { token } = useAuth();
|
||||
@@ -29,13 +31,46 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||
const { data: user, isLoading } = useQuery({ queryKey: ["me"], queryFn: getMe });
|
||||
|
||||
if (!token) return <Navigate to="/login" replace />;
|
||||
// Wait for the me query before deciding — prevents a flash redirect
|
||||
if (isLoading) return null;
|
||||
// Redirect to /login (not /) so the route appears not to exist
|
||||
if (!user?.is_admin) return <Navigate to="/login" replace />;
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route guard for service-specific settings pages.
|
||||
*
|
||||
* Grants access if the user is a global admin OR the plugin (service) list
|
||||
* returned by the backend includes the given serviceId — which means the user
|
||||
* is a member of that service's admin group.
|
||||
*/
|
||||
function ServiceAdminRoute({
|
||||
children,
|
||||
serviceId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
serviceId: string;
|
||||
}) {
|
||||
const { token } = useAuth();
|
||||
const { data: user, isLoading: userLoading } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: getMe,
|
||||
});
|
||||
const { data: plugins = [], isLoading: pluginsLoading } = useQuery({
|
||||
queryKey: ["plugins"],
|
||||
queryFn: getPlugins,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (!token) return <Navigate to="/login" replace />;
|
||||
if (userLoading || pluginsLoading) return null;
|
||||
|
||||
const hasAccess =
|
||||
user?.is_admin || plugins.some((p) => p.id === serviceId);
|
||||
|
||||
if (!hasAccess) return <Navigate to="/login" replace />;
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
@@ -45,18 +80,28 @@ export default function App() {
|
||||
<Route path="/apps" element={<PrivateRoute><AppsPage /></PrivateRoute>} />
|
||||
<Route path="/apps/documents" element={<PrivateRoute><DocumentsPage /></PrivateRoute>} />
|
||||
<Route
|
||||
path="/apps/documents/settings/admin"
|
||||
element={<AdminRoute><DocumentAdminSettingsPage /></AdminRoute>}
|
||||
path="/apps/documents/settings"
|
||||
element={
|
||||
<ServiceAdminRoute serviceId="doc-service">
|
||||
<DocServiceSettingsPage />
|
||||
</ServiceAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/apps/ai/settings/admin"
|
||||
element={<AdminRoute><AIAdminSettingsPage /></AdminRoute>}
|
||||
path="/apps/ai/settings"
|
||||
element={
|
||||
<ServiceAdminRoute serviceId="ai-service">
|
||||
<AIAdminSettingsPage />
|
||||
</ServiceAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/profile" element={<PrivateRoute><ProfilePage /></PrivateRoute>} />
|
||||
<Route path="/settings" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />
|
||||
<Route path="/settings/plugins/:id" element={<PrivateRoute><PluginSettingsPage /></PrivateRoute>} />
|
||||
<Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} />
|
||||
<Route path="/admin/users" element={<AdminRoute><AdminUsersPage /></AdminRoute>} />
|
||||
<Route path="/admin/groups" element={<AdminRoute><AdminGroupsPage /></AdminRoute>} />
|
||||
<Route path="/admin/appearance" element={<AdminRoute><AdminAppearancePage /></AdminRoute>} />
|
||||
|
||||
{/* Catch-all */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface UserData {
|
||||
full_name: string | null;
|
||||
is_active: boolean;
|
||||
is_admin: boolean;
|
||||
color_mode: string | null;
|
||||
}
|
||||
|
||||
export const getMe = () => api.get<UserData>("/users/me").then((r) => r.data);
|
||||
@@ -106,6 +107,10 @@ export interface DocumentOut {
|
||||
created_at: string;
|
||||
processed_at: string | null;
|
||||
categories: CategoryOut[];
|
||||
source: string;
|
||||
watch_path: string | null;
|
||||
suggested_folder: string | null;
|
||||
suggested_filename: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentPage {
|
||||
@@ -202,6 +207,56 @@ export const renameCategory = (id: string, name: string) =>
|
||||
export const deleteCategory = (id: string) =>
|
||||
api.delete(`/documents/categories/${id}`);
|
||||
|
||||
// --- Appearance & Themes ---
|
||||
export interface ThemeColors {
|
||||
primary: string;
|
||||
primary_hover: string;
|
||||
accent: string;
|
||||
accent_hover: string;
|
||||
background: string;
|
||||
surface: string;
|
||||
border: string;
|
||||
text_primary: string;
|
||||
text_muted: string;
|
||||
}
|
||||
|
||||
export interface ThemeDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
builtin: boolean;
|
||||
light: ThemeColors;
|
||||
dark: ThemeColors;
|
||||
}
|
||||
|
||||
export interface AppearanceSettings {
|
||||
theme: string;
|
||||
default_mode: string;
|
||||
}
|
||||
|
||||
export const getAppearanceSettings = (): Promise<AppearanceSettings> =>
|
||||
api.get<AppearanceSettings>("/settings/appearance").then((r) => r.data);
|
||||
|
||||
export const updateAppearanceSettings = (data: AppearanceSettings): Promise<AppearanceSettings> =>
|
||||
api.patch<AppearanceSettings>("/settings/appearance", data).then((r) => r.data);
|
||||
|
||||
export const getThemes = (): Promise<ThemeDefinition[]> =>
|
||||
api.get<ThemeDefinition[]>("/settings/themes").then((r) => r.data);
|
||||
|
||||
export const createTheme = (data: Omit<ThemeDefinition, "builtin">): Promise<ThemeDefinition> =>
|
||||
api.post<ThemeDefinition>("/settings/themes", data).then((r) => r.data);
|
||||
|
||||
export const updateTheme = (
|
||||
id: string,
|
||||
data: { label?: string; light?: ThemeColors; dark?: ThemeColors }
|
||||
): Promise<ThemeDefinition> =>
|
||||
api.patch<ThemeDefinition>(`/settings/themes/${id}`, data).then((r) => r.data);
|
||||
|
||||
export const deleteTheme = (id: string): Promise<void> =>
|
||||
api.delete(`/settings/themes/${id}`).then((r) => r.data);
|
||||
|
||||
export const updateColorMode = (color_mode: string): Promise<UserData> =>
|
||||
api.patch<UserData>("/users/me/color-mode", { color_mode }).then((r) => r.data);
|
||||
|
||||
// --- Settings (admin only) ---
|
||||
export interface AIProviderUpdate {
|
||||
provider: string;
|
||||
@@ -320,3 +375,59 @@ export const updateSystemPrompt = (
|
||||
api
|
||||
.patch<SystemPromptsData>(`/settings/system-prompts/${serviceId}`, data)
|
||||
.then((r) => r.data);
|
||||
|
||||
// --- Document suggestions (watch-ingested documents) ---
|
||||
export const confirmFolderSuggestion = (docId: string) =>
|
||||
api.post(`/documents/${docId}/suggestions/folder/confirm`);
|
||||
|
||||
export const rejectFolderSuggestion = (docId: string) =>
|
||||
api.post(`/documents/${docId}/suggestions/folder/reject`);
|
||||
|
||||
export const confirmFilenameSuggestion = (docId: string) =>
|
||||
api.post(`/documents/${docId}/suggestions/filename/confirm`);
|
||||
|
||||
export const rejectFilenameSuggestion = (docId: string) =>
|
||||
api.post(`/documents/${docId}/suggestions/filename/reject`);
|
||||
|
||||
// --- Plugins ---
|
||||
export interface PluginOut {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface PluginSchemaProperty {
|
||||
type: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
version: string;
|
||||
access: {
|
||||
allow_superuser: boolean;
|
||||
required_groups: string[];
|
||||
};
|
||||
settings_schema: {
|
||||
type: string;
|
||||
title?: string;
|
||||
properties: Record<string, PluginSchemaProperty>;
|
||||
};
|
||||
}
|
||||
|
||||
export const getPlugins = () =>
|
||||
api.get<PluginOut[]>("/plugins").then((r) => r.data);
|
||||
|
||||
export const getPluginManifest = (id: string) =>
|
||||
api.get<PluginManifest>(`/plugins/${id}/manifest`).then((r) => r.data);
|
||||
|
||||
export const getPluginSettings = (id: string) =>
|
||||
api.get<Record<string, unknown>>(`/plugins/${id}/settings`).then((r) => r.data);
|
||||
|
||||
export const updatePluginSettings = (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<Record<string, unknown>>(`/plugins/${id}/settings`, data).then((r) => r.data);
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PluginSchemaProperty } from "@/api/client";
|
||||
|
||||
interface PluginSchema {
|
||||
type: string;
|
||||
title?: string;
|
||||
properties: Record<string, PluginSchemaProperty>;
|
||||
}
|
||||
|
||||
interface PluginSchemaFormProps {
|
||||
schema: PluginSchema;
|
||||
values: Record<string, unknown>;
|
||||
onSave: (values: Record<string, unknown>) => void;
|
||||
isPending?: boolean;
|
||||
isError?: boolean;
|
||||
isSuccess?: boolean;
|
||||
/** When true, the built-in save button row is hidden (caller renders its own). */
|
||||
noSaveButton?: boolean;
|
||||
/** Expose current form state to the parent via callback on every change. */
|
||||
onChange?: (values: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
||||
checked ? "bg-primary" : "bg-muted/60 border border-border"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
||||
checked ? "translate-x-6" : "translate-x-1"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PluginSchemaForm({
|
||||
schema,
|
||||
values,
|
||||
onSave,
|
||||
isPending,
|
||||
isError,
|
||||
isSuccess,
|
||||
noSaveButton,
|
||||
onChange,
|
||||
}: PluginSchemaFormProps) {
|
||||
const [form, setForm] = useState<Record<string, unknown>>(values);
|
||||
|
||||
useEffect(() => {
|
||||
setForm(values);
|
||||
}, [values]);
|
||||
|
||||
const setField = (key: string, value: unknown) => {
|
||||
const next = { ...form, [key]: value };
|
||||
setForm(next);
|
||||
onChange?.(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(schema.properties).map(([key, prop]) => (
|
||||
<div key={key} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{prop.title}</p>
|
||||
{prop.description && (
|
||||
<p className="text-xs text-muted mt-0.5">{prop.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{prop.type === "boolean" && !prop.readOnly && (
|
||||
<Toggle
|
||||
checked={Boolean(form[key])}
|
||||
onChange={(v) => setField(key, v)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{prop.type === "string" && prop.readOnly && (
|
||||
<p className="text-sm text-muted font-mono bg-muted/20 px-3 py-1.5 rounded-md border border-border">
|
||||
{String(form[key] ?? "")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{prop.type === "string" && !prop.readOnly && (
|
||||
<Input
|
||||
value={String(form[key] ?? "")}
|
||||
onChange={(e) => setField(key, e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
)}
|
||||
|
||||
{prop.type === "number" && !prop.readOnly && (
|
||||
<Input
|
||||
type="number"
|
||||
value={String(form[key] ?? "")}
|
||||
onChange={(e) => setField(key, Number(e.target.value))}
|
||||
className="h-9"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!noSaveButton && (
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<Button onClick={() => onSave(form)} disabled={isPending} size="sm">
|
||||
{isPending ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
{isError && (
|
||||
<span className="text-sm text-destructive">Failed to save. Please try again.</span>
|
||||
)}
|
||||
{isSuccess && !isPending && (
|
||||
<span className="text-sm text-green-600 dark:text-green-400">Saved successfully.</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Folder,
|
||||
Users,
|
||||
UsersRound,
|
||||
Palette,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ThemeToggle from "@/components/ThemeToggle";
|
||||
@@ -265,6 +266,13 @@ export default function Sidebar() {
|
||||
<UsersRound className="h-4 w-4 shrink-0" />
|
||||
<span className="whitespace-nowrap">Groups</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/admin/appearance"
|
||||
className={({ isActive }) => subItemClass(isActive)}
|
||||
>
|
||||
<Palette className="h-4 w-4 shrink-0" />
|
||||
<span className="whitespace-nowrap">Appearance</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,16 +3,16 @@ import { Button } from "@/components/ui/button";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
{isDark ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
|
||||
@@ -1,32 +1,77 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getAppearanceSettings,
|
||||
getMe,
|
||||
getThemes,
|
||||
updateColorMode,
|
||||
type ThemeColors,
|
||||
} from "@/api/client";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
export type ColorMode = "light" | "dark" | "system";
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
if (theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
function applyThemeTokens(colors: ThemeColors) {
|
||||
const el = document.documentElement;
|
||||
el.style.setProperty("--color-primary", colors.primary);
|
||||
el.style.setProperty("--color-primary-hover", colors.primary_hover);
|
||||
el.style.setProperty("--color-accent", colors.accent);
|
||||
el.style.setProperty("--color-accent-hover", colors.accent_hover);
|
||||
el.style.setProperty("--color-background", colors.background);
|
||||
el.style.setProperty("--color-surface", colors.surface);
|
||||
el.style.setProperty("--color-border", colors.border);
|
||||
el.style.setProperty("--color-text-primary", colors.text_primary);
|
||||
el.style.setProperty("--color-text-muted", colors.text_muted);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem("theme") as Theme | null;
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
const { data: me } = useQuery({ queryKey: ["me"], queryFn: getMe });
|
||||
const { data: appearance } = useQuery({
|
||||
queryKey: ["appearance"],
|
||||
queryFn: getAppearanceSettings,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const { data: themes = [] } = useQuery({
|
||||
queryKey: ["themes"],
|
||||
queryFn: getThemes,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Priority: user.color_mode → admin default_mode → system preference
|
||||
const isDark = useMemo(() => {
|
||||
const mode = me?.color_mode ?? appearance?.default_mode ?? "system";
|
||||
if (mode === "system") {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
return mode === "dark";
|
||||
}, [me?.color_mode, appearance?.default_mode]);
|
||||
|
||||
// Apply palette + dark class whenever they change
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
const activeId = appearance?.theme ?? localStorage.getItem("themePalette") ?? "default";
|
||||
const theme =
|
||||
themes.find((t) => t.id === activeId) ?? themes.find((t) => t.id === "default");
|
||||
if (theme) {
|
||||
applyThemeTokens(isDark ? theme.dark : theme.light);
|
||||
}
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
localStorage.setItem("themePalette", activeId);
|
||||
}, [isDark, appearance?.theme, themes]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const setColorMode = useCallback(
|
||||
async (mode: ColorMode) => {
|
||||
// Reflect optimistically before the round-trip
|
||||
document.documentElement.classList.toggle("dark", mode === "dark");
|
||||
await updateColorMode(mode);
|
||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((t) => (t === "light" ? "dark" : "light"));
|
||||
}, []);
|
||||
setColorMode(isDark ? "light" : "dark");
|
||||
}, [isDark, setColorMode]);
|
||||
|
||||
return { theme, toggleTheme };
|
||||
return { isDark, toggleTheme, setColorMode };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import { useState } from "react";
|
||||
import { Palette, Monitor, Sun, Moon, Pencil, Trash2, Plus, X } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getAppearanceSettings,
|
||||
getThemes,
|
||||
updateAppearanceSettings,
|
||||
createTheme,
|
||||
updateTheme,
|
||||
deleteTheme,
|
||||
type ThemeDefinition,
|
||||
type ThemeColors,
|
||||
} from "@/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const hexToRgb = (hex: string): string => {
|
||||
const clean = hex.replace("#", "");
|
||||
const r = parseInt(clean.slice(0, 2), 16);
|
||||
const g = parseInt(clean.slice(2, 4), 16);
|
||||
const b = parseInt(clean.slice(4, 6), 16);
|
||||
return `${r} ${g} ${b}`;
|
||||
};
|
||||
|
||||
const rgbToHex = (rgb: string): string => {
|
||||
const parts = rgb.split(" ").map(Number);
|
||||
if (parts.length !== 3 || parts.some(isNaN)) return "#000000";
|
||||
return "#" + parts.map((n) => n.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
|
||||
const TOKEN_LABELS: { key: keyof ThemeColors; label: string }[] = [
|
||||
{ key: "background", label: "Background" },
|
||||
{ key: "surface", label: "Surface" },
|
||||
{ key: "primary", label: "Primary" },
|
||||
{ key: "primary_hover", label: "Primary Hover" },
|
||||
{ key: "accent", label: "Accent" },
|
||||
{ key: "accent_hover", label: "Accent Hover" },
|
||||
{ key: "border", label: "Border" },
|
||||
{ key: "text_primary", label: "Text" },
|
||||
{ key: "text_muted", label: "Muted Text" },
|
||||
];
|
||||
|
||||
const EMPTY_COLORS: ThemeColors = {
|
||||
background: "248 250 252",
|
||||
surface: "255 255 255",
|
||||
primary: "37 99 235",
|
||||
primary_hover: "29 78 216",
|
||||
accent: "234 179 8",
|
||||
accent_hover: "202 138 4",
|
||||
border: "226 232 240",
|
||||
text_primary: "15 23 42",
|
||||
text_muted: "100 116 139",
|
||||
};
|
||||
|
||||
type ColorMode = "system" | "light" | "dark";
|
||||
|
||||
const MODE_OPTIONS: { value: ColorMode; label: string; icon: React.ReactNode }[] = [
|
||||
{ value: "system", label: "System", icon: <Monitor className="h-4 w-4" /> },
|
||||
{ value: "light", label: "Light", icon: <Sun className="h-4 w-4" /> },
|
||||
{ value: "dark", label: "Dark", icon: <Moon className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
// ── Theme card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function ThemeCard({
|
||||
theme,
|
||||
selected,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
theme: ThemeDefinition;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const swatchKeys: (keyof ThemeColors)[] = [
|
||||
"background",
|
||||
"surface",
|
||||
"primary",
|
||||
"accent",
|
||||
"border",
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"relative cursor-pointer rounded-lg border-2 p-3 transition-all",
|
||||
selected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 bg-surface"
|
||||
)}
|
||||
>
|
||||
{/* Swatches */}
|
||||
<div className="flex gap-1 mb-2">
|
||||
{swatchKeys.map((key) => {
|
||||
const rgb = theme.light[key];
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="h-5 w-5 rounded-sm border border-black/10"
|
||||
style={{ backgroundColor: `rgb(${rgb.replace(/ /g, ",")})` }}
|
||||
title={key}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-medium text-foreground">{theme.label}</p>
|
||||
{theme.builtin && (
|
||||
<span className="text-xs text-muted">Built-in</span>
|
||||
)}
|
||||
|
||||
{/* Edit / delete for custom themes */}
|
||||
{!theme.builtin && (
|
||||
<div className="absolute top-2 right-2 flex gap-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
||||
className="p-1 rounded text-muted hover:text-foreground"
|
||||
title="Edit theme"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||
className="p-1 rounded text-muted hover:text-red-500"
|
||||
title="Delete theme"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Color editor ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ColorsEditor({
|
||||
label,
|
||||
colors,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
colors: ThemeColors;
|
||||
onChange: (colors: ThemeColors) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted uppercase tracking-wide mb-2">{label}</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{TOKEN_LABELS.map(({ key, label: tokenLabel }) => (
|
||||
<div key={key} className="flex flex-col gap-0.5">
|
||||
<label className="text-xs text-muted">{tokenLabel}</label>
|
||||
<input
|
||||
type="color"
|
||||
value={rgbToHex(colors[key])}
|
||||
onChange={(e) =>
|
||||
onChange({ ...colors, [key]: hexToRgb(e.target.value) })
|
||||
}
|
||||
className="h-8 w-full rounded border border-border cursor-pointer"
|
||||
title={colors[key]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Theme form (create / edit) ─────────────────────────────────────────────────
|
||||
|
||||
function ThemeForm({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
isSaving,
|
||||
error,
|
||||
}: {
|
||||
initial?: ThemeDefinition;
|
||||
onSave: (data: { id: string; label: string; light: ThemeColors; dark: ThemeColors }) => void;
|
||||
onCancel: () => void;
|
||||
isSaving: boolean;
|
||||
error?: string;
|
||||
}) {
|
||||
const [id, setId] = useState(initial?.id ?? "");
|
||||
const [label, setLabel] = useState(initial?.label ?? "");
|
||||
const [light, setLight] = useState<ThemeColors>(initial?.light ?? EMPTY_COLORS);
|
||||
const [dark, setDark] = useState<ThemeColors>(initial?.dark ?? { ...EMPTY_COLORS, background: "15 23 42", surface: "30 41 59", text_primary: "203 213 225", text_muted: "148 163 184" });
|
||||
|
||||
const isEditing = !!initial;
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-4 bg-surface mt-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{isEditing ? "Edit Theme" : "New Theme"}
|
||||
</h3>
|
||||
<button onClick={onCancel} className="text-muted hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
{!isEditing && (
|
||||
<div>
|
||||
<label className="text-xs text-muted block mb-1">Theme ID (slug)</label>
|
||||
<input
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ""))}
|
||||
placeholder="my-theme"
|
||||
maxLength={64}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-xs text-muted block mb-1">Display Name</label>
|
||||
<input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="My Theme"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 mb-4">
|
||||
<ColorsEditor label="Light mode" colors={light} onChange={setLight} />
|
||||
<ColorsEditor label="Dark mode" colors={dark} onChange={setDark} />
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500 mb-3">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onSave({ id, label, light, dark })}
|
||||
disabled={isSaving || (!isEditing && !id) || !label}
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save Theme"}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AdminAppearancePage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: appearance } = useQuery({
|
||||
queryKey: ["appearance"],
|
||||
queryFn: getAppearanceSettings,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const { data: themes = [] } = useQuery({
|
||||
queryKey: ["themes"],
|
||||
queryFn: getThemes,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const [selectedTheme, setSelectedTheme] = useState<string | null>(null);
|
||||
const [selectedMode, setSelectedMode] = useState<ColorMode | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingTheme, setEditingTheme] = useState<ThemeDefinition | null>(null);
|
||||
const [formError, setFormError] = useState("");
|
||||
|
||||
const activeTheme = selectedTheme ?? appearance?.theme ?? "default";
|
||||
const activeMode = selectedMode ?? (appearance?.default_mode as ColorMode) ?? "system";
|
||||
|
||||
const saveAppearance = useMutation({
|
||||
mutationFn: updateAppearanceSettings,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["appearance"] });
|
||||
setSelectedTheme(null);
|
||||
setSelectedMode(null);
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createTheme,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["themes"] });
|
||||
setShowForm(false);
|
||||
setFormError("");
|
||||
},
|
||||
onError: (e: Error) => setFormError(e.message),
|
||||
});
|
||||
|
||||
const editMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Parameters<typeof updateTheme>[1] }) =>
|
||||
updateTheme(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["themes"] });
|
||||
setEditingTheme(null);
|
||||
setFormError("");
|
||||
},
|
||||
onError: (e: Error) => setFormError(e.message),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteTheme,
|
||||
onSuccess: (_, deletedId) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["themes"] });
|
||||
if (activeTheme === deletedId) setSelectedTheme("default");
|
||||
},
|
||||
});
|
||||
|
||||
const isDirty =
|
||||
(selectedTheme !== null && selectedTheme !== appearance?.theme) ||
|
||||
(selectedMode !== null && selectedMode !== appearance?.default_mode);
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Palette className="h-6 w-6 text-muted" />
|
||||
<h1 className="text-2xl font-semibold text-foreground">Appearance</h1>
|
||||
</div>
|
||||
|
||||
{/* ── Theme selector ── */}
|
||||
<div className="bg-surface border border-border rounded-lg p-6 mb-6">
|
||||
<h2 className="text-base font-semibold text-foreground mb-1">Colour Theme</h2>
|
||||
<p className="text-sm text-muted mb-4">
|
||||
Select the colour palette applied site-wide. Custom themes can be created below.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{themes.map((theme) => (
|
||||
<ThemeCard
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
selected={activeTheme === theme.id}
|
||||
onSelect={() => setSelectedTheme(theme.id)}
|
||||
onEdit={() => { setEditingTheme(theme); setShowForm(false); setFormError(""); }}
|
||||
onDelete={() => {
|
||||
if (confirm(`Delete "${theme.label}"?`)) deleteMutation.mutate(theme.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Default mode ── */}
|
||||
<div className="bg-surface border border-border rounded-lg p-6 mb-6">
|
||||
<h2 className="text-base font-semibold text-foreground mb-1">Global Default Mode</h2>
|
||||
<p className="text-sm text-muted mb-4">
|
||||
Users can override this in their personal Settings.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{MODE_OPTIONS.map(({ value, label, icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setSelectedMode(value)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-lg border text-sm font-medium transition-colors",
|
||||
activeMode === value
|
||||
? "bg-primary/10 border-primary text-primary"
|
||||
: "border-border text-muted hover:text-foreground hover:bg-muted/20"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Save button ── */}
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
onClick={() =>
|
||||
saveAppearance.mutate({ theme: activeTheme, default_mode: activeMode })
|
||||
}
|
||||
disabled={saveAppearance.isPending || !isDirty}
|
||||
>
|
||||
{saveAppearance.isPending ? "Saving…" : "Save Appearance"}
|
||||
</Button>
|
||||
{saveAppearance.isError && (
|
||||
<p className="mt-2 text-sm text-red-500">Failed to save.</p>
|
||||
)}
|
||||
{saveAppearance.isSuccess && !isDirty && (
|
||||
<p className="mt-2 text-sm text-green-600">Saved.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Custom themes ── */}
|
||||
<div className="bg-surface border border-border rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h2 className="text-base font-semibold text-foreground">Custom Themes</h2>
|
||||
{!showForm && !editingTheme && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => { setShowForm(true); setEditingTheme(null); setFormError(""); }}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Theme
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted">
|
||||
Create your own colour palettes. Each theme is stored as a file and persists
|
||||
across container restarts.
|
||||
</p>
|
||||
|
||||
{showForm && (
|
||||
<ThemeForm
|
||||
onSave={({ id, label, light, dark }) =>
|
||||
createMutation.mutate({ id, label, light, dark })
|
||||
}
|
||||
onCancel={() => { setShowForm(false); setFormError(""); }}
|
||||
isSaving={createMutation.isPending}
|
||||
error={formError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingTheme && (
|
||||
<ThemeForm
|
||||
initial={editingTheme}
|
||||
onSave={({ label, light, dark }) =>
|
||||
editMutation.mutate({ id: editingTheme.id, data: { label, light, dark } })
|
||||
}
|
||||
onCancel={() => { setEditingTheme(null); setFormError(""); }}
|
||||
isSaving={editMutation.isPending}
|
||||
error={formError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showForm && !editingTheme && themes.filter((t) => !t.builtin).length === 0 && (
|
||||
<p className="mt-3 text-sm text-muted italic">No custom themes yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getMe, getServices } from "../api/client";
|
||||
import { getMe, getPlugins, getServices } from "../api/client";
|
||||
|
||||
const cardBase: React.CSSProperties = {
|
||||
backgroundColor: "rgb(var(--color-surface))",
|
||||
@@ -34,6 +34,12 @@ export default function AppsPage() {
|
||||
refetchInterval: 30_000,
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
const { data: plugins = [] } = useQuery({
|
||||
queryKey: ["plugins"],
|
||||
queryFn: getPlugins,
|
||||
retry: false,
|
||||
});
|
||||
const pluginIds = new Set(plugins.map((p) => p.id));
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 900, margin: "0 auto" }}>
|
||||
@@ -75,8 +81,9 @@ export default function AppsPage() {
|
||||
This service is currently unavailable. Please try again later or contact your administrator.
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: 8, marginTop: "auto" }}>
|
||||
{user?.is_admin && svc.settings_path && (
|
||||
{/* Single Settings button — visible to global admins and service-specific admin group members */}
|
||||
{(user?.is_admin || pluginIds.has(svc.id)) && svc.settings_path && (
|
||||
<div style={{ marginTop: "auto" }}>
|
||||
<Link
|
||||
to={svc.settings_path}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -92,8 +99,8 @@ export default function AppsPage() {
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardWrapper>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getDocumentLimits,
|
||||
updateDocumentLimits,
|
||||
getPluginSettings,
|
||||
updatePluginSettings,
|
||||
getPluginManifest,
|
||||
} from "../api/client";
|
||||
import PluginSchemaForm from "../components/PluginSchemaForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: 120,
|
||||
padding: "7px 10px",
|
||||
fontSize: 14,
|
||||
border: "1px solid rgb(var(--color-border))",
|
||||
borderRadius: 4,
|
||||
boxSizing: "border-box",
|
||||
background: "rgb(var(--color-surface))",
|
||||
color: "rgb(var(--color-text-primary))",
|
||||
};
|
||||
|
||||
function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section style={{ marginBottom: 36 }}>
|
||||
<h2 style={{ fontSize: 18, marginBottom: description ? 8 : 16 }}>{title}</h2>
|
||||
{description && (
|
||||
<p style={{ fontSize: 13, color: "rgb(var(--color-text-muted))", marginBottom: 16 }}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocServiceSettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// ── Upload limits ────────────────────────────────────────────────────────────
|
||||
const { data: limitsData, isLoading: limitsLoading } = useQuery({
|
||||
queryKey: ["docLimits"],
|
||||
queryFn: getDocumentLimits,
|
||||
});
|
||||
const [maxPdfMb, setMaxPdfMb] = useState(20);
|
||||
useEffect(() => {
|
||||
if (!limitsData) return;
|
||||
const s = limitsData as Record<string, unknown>;
|
||||
const docs = s.documents as Record<string, unknown> | undefined;
|
||||
if (typeof docs?.max_pdf_bytes === "number") {
|
||||
setMaxPdfMb(Math.round((docs.max_pdf_bytes as number) / (1024 * 1024)));
|
||||
}
|
||||
}, [limitsData]);
|
||||
|
||||
// ── Watch directory ──────────────────────────────────────────────────────────
|
||||
const { data: manifest, isLoading: manifestLoading } = useQuery({
|
||||
queryKey: ["plugin-manifest", "doc-service"],
|
||||
queryFn: () => getPluginManifest("doc-service"),
|
||||
retry: false,
|
||||
});
|
||||
const { data: watchData, isLoading: watchLoading } = useQuery({
|
||||
queryKey: ["plugin-settings", "doc-service"],
|
||||
queryFn: () => getPluginSettings("doc-service"),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Ref so PluginSchemaForm can push its current values to us on change
|
||||
const watchValuesRef = useRef<Record<string, unknown>>({});
|
||||
useEffect(() => {
|
||||
if (watchData) watchValuesRef.current = watchData as Record<string, unknown>;
|
||||
}, [watchData]);
|
||||
|
||||
// ── Mutations ────────────────────────────────────────────────────────────────
|
||||
const limitsMut = useMutation({
|
||||
mutationFn: (mb: number) => updateDocumentLimits(mb),
|
||||
});
|
||||
const watchMut = useMutation({
|
||||
mutationFn: (values: Record<string, unknown>) => updatePluginSettings("doc-service", values),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["plugin-settings", "doc-service"], data);
|
||||
},
|
||||
});
|
||||
|
||||
const isPending = limitsMut.isPending || watchMut.isPending;
|
||||
const isError = limitsMut.isError || watchMut.isError;
|
||||
const isSuccess = limitsMut.isSuccess && watchMut.isSuccess;
|
||||
|
||||
const handleSave = () => {
|
||||
limitsMut.mutate(maxPdfMb);
|
||||
if (manifest?.settings_schema) {
|
||||
watchMut.mutate(watchValuesRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = limitsLoading || manifestLoading || watchLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return <div style={{ padding: 32 }}>Loading…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 32, maxWidth: 700, margin: "0 auto" }}>
|
||||
<h1 style={{ fontSize: 24, marginBottom: 32 }}>Documents — Settings</h1>
|
||||
|
||||
<Section title="Upload Limits">
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "rgb(var(--color-text-muted))" }}>
|
||||
Max file size (MB)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
value={maxPdfMb}
|
||||
onChange={(e) => setMaxPdfMb(Number(e.target.value))}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{manifest?.settings_schema && watchData ? (
|
||||
<Section
|
||||
title="Watch Directory"
|
||||
description="Automatically ingest PDF files dropped into the watched directory. Subfolders are mapped to document categories."
|
||||
>
|
||||
<PluginSchemaForm
|
||||
schema={manifest.settings_schema}
|
||||
values={watchData as Record<string, unknown>}
|
||||
onSave={() => {}}
|
||||
onChange={(values) => { watchValuesRef.current = values; }}
|
||||
noSaveButton
|
||||
/>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<Button onClick={handleSave} disabled={isPending} size="sm">
|
||||
{isPending ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
{isError && (
|
||||
<span className="text-sm text-destructive">Failed to save. Please try again.</span>
|
||||
)}
|
||||
{isSuccess && !isPending && (
|
||||
<span className="text-sm text-green-600 dark:text-green-400">Saved successfully.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getPluginManifest, getPluginSettings, updatePluginSettings } from "@/api/client";
|
||||
import PluginSchemaForm from "@/components/PluginSchemaForm";
|
||||
|
||||
export default function PluginSettingsPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: manifest, isLoading: manifestLoading, isError: manifestError } = useQuery({
|
||||
queryKey: ["plugin-manifest", id],
|
||||
queryFn: () => getPluginManifest(id!),
|
||||
enabled: !!id,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||
queryKey: ["plugin-settings", id],
|
||||
queryFn: () => getPluginSettings(id!),
|
||||
enabled: !!id && !!manifest,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: Record<string, unknown>) => updatePluginSettings(id!, values),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["plugin-settings", id] });
|
||||
},
|
||||
});
|
||||
|
||||
if (manifestLoading || settingsLoading) {
|
||||
return <p className="text-sm text-muted p-6">Loading…</p>;
|
||||
}
|
||||
|
||||
if (manifestError || !manifest) {
|
||||
return (
|
||||
<p className="text-sm text-destructive p-6">
|
||||
Plugin not found or you do not have access to its settings.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
{manifest.settings_schema.title ?? manifest.name}
|
||||
</h1>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
{manifest.name} · v{manifest.version}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PluginSchemaForm
|
||||
schema={manifest.settings_schema}
|
||||
values={settings ?? {}}
|
||||
onSave={(values) => mutation.mutate(values)}
|
||||
isPending={mutation.isPending}
|
||||
isError={mutation.isError}
|
||||
isSuccess={mutation.isSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,77 @@
|
||||
import { Settings } from "lucide-react";
|
||||
import { Settings, Monitor, Sun, Moon } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getMe, getAppearanceSettings, updateColorMode } from "@/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ColorMode = "system" | "light" | "dark";
|
||||
|
||||
const MODE_OPTIONS: { value: ColorMode; label: string; icon: React.ReactNode }[] = [
|
||||
{ value: "system", label: "System", icon: <Monitor className="h-4 w-4" /> },
|
||||
{ value: "light", label: "Light", icon: <Sun className="h-4 w-4" /> },
|
||||
{ value: "dark", label: "Dark", icon: <Moon className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: me } = useQuery({ queryKey: ["me"], queryFn: getMe });
|
||||
const { data: appearance } = useQuery({
|
||||
queryKey: ["appearance"],
|
||||
queryFn: getAppearanceSettings,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const currentMode: ColorMode = (me?.color_mode as ColorMode) ?? "system";
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: updateColorMode,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["me"] }),
|
||||
});
|
||||
|
||||
const adminDefault = appearance?.default_mode ?? "system";
|
||||
const adminDefaultLabel =
|
||||
adminDefault === "system" ? "system preference" : adminDefault + " mode";
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Settings className="h-6 w-6 text-muted" />
|
||||
<h1 className="text-2xl font-semibold text-foreground">Settings</h1>
|
||||
</div>
|
||||
<p className="text-muted text-sm">
|
||||
User and application settings will be available here in a future update.
|
||||
</p>
|
||||
|
||||
<div className="bg-surface border border-border rounded-lg p-6">
|
||||
<h2 className="text-base font-semibold text-foreground mb-1">Appearance</h2>
|
||||
<p className="text-sm text-muted mb-4">
|
||||
Choose your preferred colour mode. Overrides the site-wide default set by your
|
||||
administrator.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{MODE_OPTIONS.map(({ value, label, icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => mutation.mutate(value)}
|
||||
disabled={mutation.isPending}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-lg border text-sm font-medium transition-colors",
|
||||
currentMode === value
|
||||
? "bg-primary/10 border-primary text-primary"
|
||||
: "border-border text-muted hover:text-foreground hover:bg-muted/20"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-muted">
|
||||
Site-wide default: <span className="font-medium">{adminDefaultLabel}</span>
|
||||
</p>
|
||||
|
||||
{mutation.isError && (
|
||||
<p className="mt-2 text-sm text-red-500">Failed to save preference.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user