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:
2026-04-18 11:05:41 +02:00
45 changed files with 2772 additions and 121 deletions
+4
View File
@@ -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
+152 -12
View File
@@ -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
View File
@@ -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')
+210
View File
@@ -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()
+66
View File
@@ -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
View File
@@ -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(
+2
View File
@@ -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"
+125
View File
@@ -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)
+151 -8
View File
@@ -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))
+14 -2
View File
@@ -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
+12
View File
@@ -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)
+37
View File
@@ -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()
+47 -5
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
# Watch directory for development testing
+1
View File
@@ -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
+2
View File
@@ -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:
+2 -1
View File
@@ -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"])
+31
View File
@@ -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
+1 -1
View File
@@ -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
+70 -28
View File
@@ -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")
+31 -1
View File
@@ -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()
+106 -6
View File
@@ -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")
+1
View File
@@ -17,6 +17,7 @@ dependencies = [
"pdfplumber>=0.11",
"aiofiles>=23.0",
"python-multipart>=0.0.9",
"watchdog>=4.0",
]
[project.optional-dependencies]
+24 -9
View File
@@ -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
View File
@@ -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 />} />
+111
View File
@@ -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>
);
}
+8
View File
@@ -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 -3
View File
@@ -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" />
+65 -20
View File
@@ -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 };
}
+445
View File
@@ -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>
);
}
+12 -5
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+67 -5
View File
@@ -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>
);
}