From 608b0b7fe889c2608289465547c3debc5972d0fa Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 18 Apr 2026 01:46:17 +0200 Subject: [PATCH 1/7] Add theming system: custom palettes, per-user colour mode, admin appearance page - 4 built-in themes (Default, Pastel, High Contrast, Ocean Blue) seeded as JSON files in /config/themes/ on startup; custom themes can be created, edited, and deleted via the new admin Appearance page - All theme tokens applied via JS inline CSS properties (no hardcoded CSS blocks) - New `color_mode` column on users table (migration dd6ad2f2c211); users can override the admin-set global default in Settings - Backend: GET/PATCH /settings/appearance, full CRUD on /settings/themes - Frontend: AdminAppearancePage with theme grid + colour pickers, SettingsPage replaces placeholder with mode selector, useTheme rewritten to fetch from API Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 17 +- .../dd6ad2f2c211_add_color_mode_to_users.py | 25 + backend/app/core/app_config.py | 210 +++++++++ backend/app/main.py | 2 + backend/app/models/user.py | 2 + backend/app/routers/settings.py | 144 +++++- backend/app/routers/users.py | 16 +- backend/app/schemas/user.py | 12 + frontend/src/App.tsx | 2 + frontend/src/api/client.ts | 51 ++ frontend/src/components/Sidebar.tsx | 8 + frontend/src/components/ThemeToggle.tsx | 6 +- frontend/src/hooks/useTheme.ts | 85 +++- frontend/src/pages/AdminAppearancePage.tsx | 445 ++++++++++++++++++ frontend/src/pages/SettingsPage.tsx | 72 ++- 15 files changed, 1063 insertions(+), 34 deletions(-) create mode 100644 backend/alembic/versions/dd6ad2f2c211_add_color_mode_to_users.py create mode 100644 frontend/src/pages/AdminAppearancePage.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 93d0e9b..f84e66d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ docker compose up --build -d │ │ │ ├── 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,11 +100,11 @@ 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) │ │ │ ├── categories_proxy.py ← Transparent proxy → doc-service /categories/* │ │ │ └── documents_proxy.py ← Transparent proxy → doc-service /documents/* @@ -227,6 +227,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) @@ -309,6 +310,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**: @@ -335,6 +337,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 +378,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 @@ -436,6 +445,7 @@ Unique constraint: `(group_id, user_id)` | `/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. @@ -669,6 +679,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` | diff --git a/backend/alembic/versions/dd6ad2f2c211_add_color_mode_to_users.py b/backend/alembic/versions/dd6ad2f2c211_add_color_mode_to_users.py new file mode 100644 index 0000000..6f118ae --- /dev/null +++ b/backend/alembic/versions/dd6ad2f2c211_add_color_mode_to_users.py @@ -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') diff --git a/backend/app/core/app_config.py b/backend/app/core/app_config.py index 4802bae..bd7b462 100644 --- a/backend/app/core/app_config.py +++ b/backend/app/core/app_config.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index 176a1cd..a26426b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,6 +4,7 @@ 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.routers import settings as settings_router @@ -12,6 +13,7 @@ from app.services.service_health import check_all, health_check_loop, register_s @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, diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 469b61e..72a149d 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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" diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index c89e191..027b540 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -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,24 @@ 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, 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 from app.models.user import User router = APIRouter() @@ -53,6 +60,36 @@ 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 ──────────────────────────────────────────────────────────────── @@ -176,3 +213,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)) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 29679d0..6176c12 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 3c77bc6..a880e48 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -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) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8b90b32..5678127 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ 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 AIAdminSettingsPage from "./pages/AIAdminSettingsPage"; @@ -57,6 +58,7 @@ export default function App() { } /> } /> } /> + } /> {/* Catch-all */} } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 63045a8..78ed3b6 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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("/users/me").then((r) => r.data); @@ -202,6 +203,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 => + api.get("/settings/appearance").then((r) => r.data); + +export const updateAppearanceSettings = (data: AppearanceSettings): Promise => + api.patch("/settings/appearance", data).then((r) => r.data); + +export const getThemes = (): Promise => + api.get("/settings/themes").then((r) => r.data); + +export const createTheme = (data: Omit): Promise => + api.post("/settings/themes", data).then((r) => r.data); + +export const updateTheme = ( + id: string, + data: { label?: string; light?: ThemeColors; dark?: ThemeColors } +): Promise => + api.patch(`/settings/themes/${id}`, data).then((r) => r.data); + +export const deleteTheme = (id: string): Promise => + api.delete(`/settings/themes/${id}`).then((r) => r.data); + +export const updateColorMode = (color_mode: string): Promise => + api.patch("/users/me/color-mode", { color_mode }).then((r) => r.data); + // --- Settings (admin only) --- export interface AIProviderUpdate { provider: string; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 6f1ddfc..7cdb440 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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() { Groups + subItemClass(isActive)} + > + + Appearance + )} diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx index e5e56dc..96d0de6 100644 --- a/frontend/src/components/ThemeToggle.tsx +++ b/frontend/src/components/ThemeToggle.tsx @@ -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 ( + + + )} + + ); +} + +// ── Color editor ─────────────────────────────────────────────────────────────── + +function ColorsEditor({ + label, + colors, + onChange, +}: { + label: string; + colors: ThemeColors; + onChange: (colors: ThemeColors) => void; +}) { + return ( +
+

{label}

+
+ {TOKEN_LABELS.map(({ key, label: tokenLabel }) => ( +
+ + + onChange({ ...colors, [key]: hexToRgb(e.target.value) }) + } + className="h-8 w-full rounded border border-border cursor-pointer" + title={colors[key]} + /> +
+ ))} +
+
+ ); +} + +// ── 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(initial?.light ?? EMPTY_COLORS); + const [dark, setDark] = useState(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 ( +
+
+

+ {isEditing ? "Edit Theme" : "New Theme"} +

+ +
+ +
+ {!isEditing && ( +
+ + setId(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ""))} + placeholder="my-theme" + maxLength={64} + className="w-full" + /> +
+ )} +
+ + setLabel(e.target.value)} + placeholder="My Theme" + className="w-full" + /> +
+
+ +
+ + +
+ + {error &&

{error}

} + +
+ + +
+
+ ); +} + +// ── 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(null); + const [selectedMode, setSelectedMode] = useState(null); + const [showForm, setShowForm] = useState(false); + const [editingTheme, setEditingTheme] = useState(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[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 ( +
+
+ +

Appearance

+
+ + {/* ── Theme selector ── */} +
+

Colour Theme

+

+ Select the colour palette applied site-wide. Custom themes can be created below. +

+ +
+ {themes.map((theme) => ( + setSelectedTheme(theme.id)} + onEdit={() => { setEditingTheme(theme); setShowForm(false); setFormError(""); }} + onDelete={() => { + if (confirm(`Delete "${theme.label}"?`)) deleteMutation.mutate(theme.id); + }} + /> + ))} +
+
+ + {/* ── Default mode ── */} +
+

Global Default Mode

+

+ Users can override this in their personal Settings. +

+ +
+ {MODE_OPTIONS.map(({ value, label, icon }) => ( + + ))} +
+
+ + {/* ── Save button ── */} +
+ + {saveAppearance.isError && ( +

Failed to save.

+ )} + {saveAppearance.isSuccess && !isDirty && ( +

Saved.

+ )} +
+ + {/* ── Custom themes ── */} +
+
+

Custom Themes

+ {!showForm && !editingTheme && ( + + )} +
+

+ Create your own colour palettes. Each theme is stored as a file and persists + across container restarts. +

+ + {showForm && ( + + createMutation.mutate({ id, label, light, dark }) + } + onCancel={() => { setShowForm(false); setFormError(""); }} + isSaving={createMutation.isPending} + error={formError} + /> + )} + + {editingTheme && ( + + 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 && ( +

No custom themes yet.

+ )} +
+
+ ); +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 2a1fde7..e3f9a24 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -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: }, + { value: "light", label: "Light", icon: }, + { value: "dark", label: "Dark", icon: }, +]; 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 (
-
+

Settings

-

- User and application settings will be available here in a future update. -

+ +
+

Appearance

+

+ Choose your preferred colour mode. Overrides the site-wide default set by your + administrator. +

+ +
+ {MODE_OPTIONS.map(({ value, label, icon }) => ( + + ))} +
+ +

+ Site-wide default: {adminDefaultLabel} +

+ + {mutation.isError && ( +

Failed to save preference.

+ )} +
); } From 2d7207b62fddbb5e3a999d6db7c29385b9e13183 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 18 Apr 2026 01:52:35 +0200 Subject: [PATCH 2/7] Fix missing save_appearance_config import in settings router Co-Authored-By: Claude Sonnet 4.6 --- backend/app/routers/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index 027b540..d6f95a0 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -21,6 +21,7 @@ from app.core.app_config import ( 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, From 00466a9801db92e469bc0eb7e3ff1f00b4ad0f9a Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 18 Apr 2026 02:09:50 +0200 Subject: [PATCH 3/7] Add generic plugin architecture and watch-directory feature Introduces a manifest contract so feature containers self-describe their settings (JSON Schema + access rules). Backend and frontend gain generic plugin proxy and dynamic Extensions UI with zero feature-specific code. Doc-service is the first plugin consumer: exposes /plugin/manifest and /plugin/settings, adds a watchdog-based file watcher that auto-ingests PDFs from a mounted directory, maps subfolders to categories, supports AI-suggested folder/filename (user-confirmed), and enforces a no-remove policy. Access is gated by is_superuser or doc-service-admin group. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 + CLAUDE.md | 142 +++++++++- backend/STATUS.md | 16 ++ backend/app/deps.py | 41 +++ backend/app/main.py | 3 +- backend/app/routers/plugins.py | 125 +++++++++ backend/app/services/service_health.py | 43 ++- .../2026-04-18_plugin-arch-watch-feature.md | 44 +++ dev-watch/.gitkeep | 1 + docker-compose.dev.yml | 1 + docker-compose.yml | 2 + features/doc-service/Dockerfile | 2 +- features/doc-service/STATUS.md | 98 +++++-- .../versions/0003_add_watch_columns.py | 30 ++ features/doc-service/app/main.py | 32 ++- features/doc-service/app/models/document.py | 6 + .../doc-service/app/routers/categories.py | 8 +- features/doc-service/app/routers/documents.py | 112 +++++++- features/doc-service/app/routers/plugin.py | 97 +++++++ features/doc-service/app/schemas/document.py | 4 + .../doc-service/app/services/config_reader.py | 47 ++++ .../doc-service/app/services/file_watcher.py | 256 ++++++++++++++++++ features/doc-service/pyproject.toml | 1 + frontend/STATUS.md | 22 +- frontend/src/App.tsx | 2 + frontend/src/api/client.ts | 60 ++++ frontend/src/components/PluginSchemaForm.tsx | 119 ++++++++ frontend/src/components/Sidebar.tsx | 45 ++- frontend/src/pages/PluginSettingsPage.tsx | 63 +++++ 29 files changed, 1373 insertions(+), 52 deletions(-) create mode 100644 backend/app/routers/plugins.py create mode 100644 changelog/2026-04-18_plugin-arch-watch-feature.md create mode 100644 dev-watch/.gitkeep create mode 100644 features/doc-service/alembic/versions/0003_add_watch_columns.py create mode 100644 features/doc-service/app/routers/plugin.py create mode 100644 features/doc-service/app/services/file_watcher.py create mode 100644 frontend/src/components/PluginSchemaForm.tsx create mode 100644 frontend/src/pages/PluginSettingsPage.tsx diff --git a/.gitignore b/.gitignore index 9fba404..4976167 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,8 @@ 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~ diff --git a/CLAUDE.md b/CLAUDE.md index f84e66d..95aeb13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,12 +78,13 @@ 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_.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, check_plugin_access │ │ ├── core/ │ │ │ ├── config.py ← All settings via pydantic-settings (reads .env) │ │ │ ├── security.py ← JWT sign/verify (RS256), bcrypt hash/verify @@ -106,10 +107,11 @@ docker compose up --build -d │ │ │ ├── groups.py ← Group CRUD + member management (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 │ ├── alembic/ │ │ ├── env.py ← Async migration runner │ │ └── versions/ ← Migration chain (see Migrations section) @@ -133,7 +135,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 +146,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 │ @@ -164,10 +169,12 @@ docker compose up --build -d │ │ └── useTheme.ts ← Theme toggle │ ├── components/ │ │ ├── AppShell.tsx ← Layout: Sidebar + scrollable main - │ │ ├── Sidebar.tsx ← Collapsible nav (icons ↔ icons+labels) + │ │ ├── Sidebar.tsx ← Collapsible nav; "Extensions" section auto-populated from /api/plugins │ │ ├── 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) + │ │ └── 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 @@ -283,6 +290,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`** @@ -318,6 +329,7 @@ Unique constraint: `(group_id, user_id)` |--------|------| | `0001` | `create_doc_tables` | | `0002` | `add_document_title` | +| `0003` | `add_watch_columns` | --- @@ -407,6 +419,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 @@ -417,6 +433,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 | @@ -442,6 +469,7 @@ Unique constraint: `(group_id, user_id)` | `/apps/ai/settings/admin` | `AIAdminSettingsPage` | AdminRoute | | `/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 | @@ -566,6 +594,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**: @@ -708,7 +739,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 @@ -717,6 +748,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 @@ -816,6 +848,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/ # 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-.yml` (gitignored): +```yaml +# docker-compose.feat-.yml — feature test stack, never committed to main +services: + frontend: + ports: + - "$PORT:8080" # e.g. 5174:8080 + container_name: frontend- + backend: + container_name: backend- + doc-service: + container_name: doc-service- + ai-service: + container_name: ai-service- + db: + container_name: db- + +networks: + backend-net: + name: backend-net- + frontend-net: + name: frontend-net- +``` + +**Start the feature stack**: +```bash +docker compose -f docker-compose.yml \ + -f docker-compose.dev.yml \ + -f docker-compose.feat-.yml \ + --project-name 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/`. Commit and push normally: +```bash +git add +git commit -m "feat: " +git push -u origin feat/ +``` + +#### 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 logs backend` +- [ ] Migrations (if any) applied cleanly: `docker compose -p exec backend alembic upgrade head` + +#### 5 — Merge to main +Once all checks pass: +```bash +git checkout main +git merge --no-ff feat/ -m "Merge feat/: " +git push +git branch -d feat/ +git push origin --delete feat/ +``` + +#### 6 — Tear down the feature stack +```bash +docker compose -f docker-compose.yml \ + -f docker-compose.dev.yml \ + -f docker-compose.feat-.yml \ + --project-name down --volumes --remove-orphans +rm docker-compose.feat-.yml +``` + +--- + ### Infrastructure change protocol After **any** change to Dockerfiles, `docker-compose*.yml`, `nginx.conf`, or setup scripts: diff --git a/backend/STATUS.md b/backend/STATUS.md index a089bb4..f315602 100644 --- a/backend/STATUS.md +++ b/backend/STATUS.md @@ -82,6 +82,21 @@ 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 Extensions sidebar — no backend code changes required. + ### Database models | Model | Table | Notes | @@ -137,6 +152,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 diff --git a/backend/app/deps.py b/backend/app/deps.py index 454cab7..08f64cc 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -43,3 +43,44 @@ async def get_current_admin( detail="Not found", ) return current_user + + +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 diff --git a/backend/app/main.py b/backend/app/main.py index a26426b..e715bc9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ 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.routers import admin, auth, categories_proxy, documents_proxy, groups, plugins, profile, services, users from app.routers import settings as settings_router from app.services.service_health import check_all, health_check_loop, register_services @@ -46,6 +46,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( diff --git a/backend/app/routers/plugins.py b/backend/app/routers/plugins.py new file mode 100644 index 0000000..e9ed573 --- /dev/null +++ b/backend/app/routers/plugins.py @@ -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 _REGISTRY, get_cached_manifest, 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 _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) diff --git a/backend/app/services/service_health.py b/backend/app/services/service_health.py index 9d7763e..44d0062 100644 --- a/backend/app/services/service_health.py +++ b/backend/app/services/service_health.py @@ -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( @@ -62,6 +66,7 @@ def register_services(doc_service_url: str, ai_service_url: str) -> None: ] _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,16 @@ 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 diff --git a/changelog/2026-04-18_plugin-arch-watch-feature.md b/changelog/2026-04-18_plugin-arch-watch-feature.md new file mode 100644 index 0000000..e50ae7b --- /dev/null +++ b/changelog/2026-04-18_plugin-arch-watch-feature.md @@ -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 | diff --git a/dev-watch/.gitkeep b/dev-watch/.gitkeep new file mode 100644 index 0000000..d3b8173 --- /dev/null +++ b/dev-watch/.gitkeep @@ -0,0 +1 @@ +# Watch directory for development testing diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ad9b0b4..93f75cb 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index f2c5c2a..e3e3a5f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/features/doc-service/Dockerfile b/features/doc-service/Dockerfile index 6aa6f7a..fdd0e9c 100644 --- a/features/doc-service/Dockerfile +++ b/features/doc-service/Dockerfile @@ -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 diff --git a/features/doc-service/STATUS.md b/features/doc-service/STATUS.md index e6c5dee..3c4322e 100644 --- a/features/doc-service/STATUS.md +++ b/features/doc-service/STATUS.md @@ -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) diff --git a/features/doc-service/alembic/versions/0003_add_watch_columns.py b/features/doc-service/alembic/versions/0003_add_watch_columns.py new file mode 100644 index 0000000..41e3a92 --- /dev/null +++ b/features/doc-service/alembic/versions/0003_add_watch_columns.py @@ -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") diff --git a/features/doc-service/app/main.py b/features/doc-service/app/main.py index 66685bd..5e93220 100644 --- a/features/doc-service/app/main.py +++ b/features/doc-service/app/main.py @@ -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") diff --git a/features/doc-service/app/models/document.py b/features/doc-service/app/models/document.py index 25d88fa..6339c3e 100644 --- a/features/doc-service/app/models/document.py +++ b/features/doc-service/app/models/document.py @@ -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" ) diff --git a/features/doc-service/app/routers/categories.py b/features/doc-service/app/routers/categories.py index 53999d4..1a60508 100644 --- a/features/doc-service/app/routers/categories.py +++ b/features/doc-service/app/routers/categories.py @@ -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() diff --git a/features/doc-service/app/routers/documents.py b/features/doc-service/app/routers/documents.py index 5a1fd35..3989ea4 100644 --- a/features/doc-service/app/routers/documents.py +++ b/features/doc-service/app/routers/documents.py @@ -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() diff --git a/features/doc-service/app/routers/plugin.py b/features/doc-service/app/routers/plugin.py new file mode 100644 index 0000000..ed63184 --- /dev/null +++ b/features/doc-service/app/routers/plugin.py @@ -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() diff --git a/features/doc-service/app/schemas/document.py b/features/doc-service/app/schemas/document.py index 0690b23..861ad64 100644 --- a/features/doc-service/app/schemas/document.py +++ b/features/doc-service/app/schemas/document.py @@ -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} diff --git a/features/doc-service/app/services/config_reader.py b/features/doc-service/app/services/config_reader.py index be6b350..a3db812 100644 --- a/features/doc-service/app/services/config_reader.py +++ b/features/doc-service/app/services/config_reader.py @@ -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 diff --git a/features/doc-service/app/services/file_watcher.py b/features/doc-service/app/services/file_watcher.py new file mode 100644 index 0000000..e0bdde0 --- /dev/null +++ b/features/doc-service/app/services/file_watcher.py @@ -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 import Observer + +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 = Observer() + 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") diff --git a/features/doc-service/pyproject.toml b/features/doc-service/pyproject.toml index 92f3415..57b57b1 100644 --- a/features/doc-service/pyproject.toml +++ b/features/doc-service/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "pdfplumber>=0.11", "aiofiles>=23.0", "python-multipart>=0.0.9", + "watchdog>=4.0", ] [project.optional-dependencies] diff --git a/frontend/STATUS.md b/frontend/STATUS.md index 2c7a312..e6cd707 100644 --- a/frontend/STATUS.md +++ b/frontend/STATUS.md @@ -23,6 +23,7 @@ All API calls go through `src/api/client.ts` (single Axios instance, JWT injecte | `/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. @@ -60,6 +61,12 @@ Cards are rendered dynamically from `GET /api/services` (polled every 30 s via T - Sections auto-open when navigating to their route - In collapsed (icons-only) mode, clicking the Apps icon navigates to `/apps` +**Extensions** section (dynamic): +- Populated from `GET /api/plugins` (polled via TanStack Query, `retry: false`) +- Only shown when the user has access to at least one plugin +- Each entry links to `/settings/plugins/:id` +- No code changes needed to add future plugin-enabled feature containers + ### Documents page (`/apps/documents`) **Upload:** PDF file input, 202 response, error display. @@ -140,6 +147,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 +163,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 +183,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 +205,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) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5678127..d2df540 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ import DocumentsPage from "./pages/DocumentsPage"; import DocumentAdminSettingsPage from "./pages/DocumentAdminSettingsPage"; import AIAdminSettingsPage from "./pages/AIAdminSettingsPage"; import SettingsPage from "./pages/SettingsPage"; +import PluginSettingsPage from "./pages/PluginSettingsPage"; function PrivateRoute({ children }: { children: React.ReactNode }) { const { token } = useAuth(); @@ -55,6 +56,7 @@ export default function App() { /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 78ed3b6..3cd31c5 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -107,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 { @@ -371,3 +375,59 @@ export const updateSystemPrompt = ( api .patch(`/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; + }; +} + +export const getPlugins = () => + api.get("/plugins").then((r) => r.data); + +export const getPluginManifest = (id: string) => + api.get(`/plugins/${id}/manifest`).then((r) => r.data); + +export const getPluginSettings = (id: string) => + api.get>(`/plugins/${id}/settings`).then((r) => r.data); + +export const updatePluginSettings = (id: string, data: Record) => + api.patch>(`/plugins/${id}/settings`, data).then((r) => r.data); diff --git a/frontend/src/components/PluginSchemaForm.tsx b/frontend/src/components/PluginSchemaForm.tsx new file mode 100644 index 0000000..290cad9 --- /dev/null +++ b/frontend/src/components/PluginSchemaForm.tsx @@ -0,0 +1,119 @@ +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; +} + +interface PluginSchemaFormProps { + schema: PluginSchema; + values: Record; + onSave: (values: Record) => void; + isPending?: boolean; + isError?: boolean; + isSuccess?: boolean; +} + +function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ); +} + +export default function PluginSchemaForm({ + schema, + values, + onSave, + isPending, + isError, + isSuccess, +}: PluginSchemaFormProps) { + const [form, setForm] = useState>(values); + + useEffect(() => { + setForm(values); + }, [values]); + + const setField = (key: string, value: unknown) => { + setForm((prev) => ({ ...prev, [key]: value })); + }; + + return ( +
+ {Object.entries(schema.properties).map(([key, prop]) => ( +
+
+
+

{prop.title}

+ {prop.description && ( +

{prop.description}

+ )} +
+ {prop.type === "boolean" && !prop.readOnly && ( + setField(key, v)} + /> + )} +
+ + {prop.type === "string" && prop.readOnly && ( +

+ {String(form[key] ?? "")} +

+ )} + + {prop.type === "string" && !prop.readOnly && ( + setField(key, e.target.value)} + className="h-9" + /> + )} + + {prop.type === "number" && !prop.readOnly && ( + setField(key, Number(e.target.value))} + className="h-9" + /> + )} +
+ ))} + +
+ + {isError && ( + Failed to save. Please try again. + )} + {isSuccess && !isPending && ( + Saved successfully. + )} +
+
+ ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 7cdb440..707cdc4 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -16,11 +16,12 @@ import { Users, UsersRound, Palette, + Puzzle, } from "lucide-react"; import { Button } from "@/components/ui/button"; import ThemeToggle from "@/components/ThemeToggle"; import { useAuth } from "@/hooks/useAuth"; -import { getMe, listCategories } from "@/api/client"; +import { getMe, getPlugins, listCategories } from "@/api/client"; import { cn } from "@/lib/utils"; export default function Sidebar() { @@ -56,6 +57,14 @@ export default function Sidebar() { enabled: appsOpen && docsOpen && !!user, }); + const { data: plugins = [] } = useQuery({ + queryKey: ["plugins"], + queryFn: getPlugins, + enabled: !!user, + // Empty array on 404/error — regular users simply see no plugins + retry: false, + }); + const navItemClass = (isActive: boolean) => cn( "flex items-center rounded-lg transition-colors", @@ -209,6 +218,40 @@ export default function Sidebar() { )} + {/* Extensions — visible only when the user has accessible plugins */} + {plugins.length > 0 && ( +
+ {sidebarExpanded ? ( + <> +
+ + Extensions + +
+
+ {plugins.map((plugin) => ( + subItemClass(isActive)} + > + + {plugin.name} + + ))} +
+ + ) : ( + navItemClass(isActive)} + > + + + )} +
+ )} + {/* Admin — expandable */} {user?.is_admin && (
diff --git a/frontend/src/pages/PluginSettingsPage.tsx b/frontend/src/pages/PluginSettingsPage.tsx new file mode 100644 index 0000000..8084263 --- /dev/null +++ b/frontend/src/pages/PluginSettingsPage.tsx @@ -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) => updatePluginSettings(id!, values), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["plugin-settings", id] }); + }, + }); + + if (manifestLoading || settingsLoading) { + return

Loading…

; + } + + if (manifestError || !manifest) { + return ( +

+ Plugin not found or you do not have access to its settings. +

+ ); + } + + return ( +
+
+

+ {manifest.settings_schema.title ?? manifest.name} +

+

+ {manifest.name} · v{manifest.version} +

+
+ + mutation.mutate(values)} + isPending={mutation.isPending} + isError={mutation.isError} + isSuccess={mutation.isSuccess} + /> +
+ ); +} From 18a638bc3a4ff9933b281b3bf6ce14af15988fc4 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 18 Apr 2026 02:25:16 +0200 Subject: [PATCH 4/7] Fix plugin list bug and switch watcher to PollingObserver - Fix: list_plugins imported _REGISTRY as a direct reference to the empty list that existed at import time; register_services() replaces _REGISTRY with a new list so the imported reference was always []. Added get_registry() helper so callers access the live list via the module namespace. GET /api/plugins now correctly returns accessible plugins for the current user. - Fix: switch watchdog from InotifyObserver to PollingObserver. Inotify events from the macOS host are not forwarded through the Docker bind mount, so new files were only detected via the startup scan. PollingObserver (1s default interval) works reliably on all platforms including macOS+Docker bind mounts. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/routers/plugins.py | 4 ++-- backend/app/services/service_health.py | 5 +++++ features/doc-service/app/services/file_watcher.py | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/backend/app/routers/plugins.py b/backend/app/routers/plugins.py index e9ed573..e547adc 100644 --- a/backend/app/routers/plugins.py +++ b/backend/app/routers/plugins.py @@ -20,7 +20,7 @@ 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 _REGISTRY, get_cached_manifest, get_service_url +from app.services.service_health import get_cached_manifest, get_registry, get_service_url router = APIRouter() @@ -72,7 +72,7 @@ async def list_plugins( ) -> list[dict]: """Return the list of plugins the current user may access.""" accessible = [] - for svc in _REGISTRY: + for svc in get_registry(): manifest = get_cached_manifest(svc.id) if manifest is None: continue diff --git a/backend/app/services/service_health.py b/backend/app/services/service_health.py index 44d0062..0805075 100644 --- a/backend/app/services/service_health.py +++ b/backend/app/services/service_health.py @@ -162,3 +162,8 @@ def get_service_url(service_id: str) -> str | None: 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 diff --git a/features/doc-service/app/services/file_watcher.py b/features/doc-service/app/services/file_watcher.py index e0bdde0..e9de9d7 100644 --- a/features/doc-service/app/services/file_watcher.py +++ b/features/doc-service/app/services/file_watcher.py @@ -25,7 +25,7 @@ import uuid from pathlib import Path from watchdog.events import FileSystemEventHandler -from watchdog.observers import Observer +from watchdog.observers.polling import PollingObserver from app.database import AsyncSessionLocal from app.models.category import DocumentCategory @@ -226,7 +226,7 @@ class FileWatcherService: return handler = _PdfEventHandler(self._watch_root, self._loop, config) - self._observer = Observer() + self._observer = PollingObserver() self._observer.schedule(handler, watch_path, recursive=True) self._observer.start() logger.info("[watcher] started, watching %s", watch_path) From 003fbee20f74d55bd00ab3f108bad9374d3c031e Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 18 Apr 2026 02:31:12 +0200 Subject: [PATCH 5/7] Move plugin settings access from sidebar to app card Remove the "Extensions" section from the sidebar nav. Instead, each app card on the Apps page shows an "Extension" button when the current user has access to that app's plugin (matched by service ID). The button links to /settings/plugins/:id alongside the existing admin Settings button. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 2 +- frontend/STATUS.md | 10 +++---- frontend/src/components/Sidebar.tsx | 45 +---------------------------- frontend/src/pages/AppsPage.tsx | 25 +++++++++++++++- 4 files changed, 31 insertions(+), 51 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 95aeb13..c91b3c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,7 +169,7 @@ docker compose up --build -d │ │ └── useTheme.ts ← Theme toggle │ ├── components/ │ │ ├── AppShell.tsx ← Layout: Sidebar + scrollable main - │ │ ├── Sidebar.tsx ← Collapsible nav; "Extensions" section auto-populated from /api/plugins + │ │ ├── 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, …) diff --git a/frontend/STATUS.md b/frontend/STATUS.md index e6cd707..59e1eda 100644 --- a/frontend/STATUS.md +++ b/frontend/STATUS.md @@ -61,11 +61,11 @@ Cards are rendered dynamically from `GET /api/services` (polled every 30 s via T - Sections auto-open when navigating to their route - In collapsed (icons-only) mode, clicking the Apps icon navigates to `/apps` -**Extensions** section (dynamic): -- Populated from `GET /api/plugins` (polled via TanStack Query, `retry: false`) -- Only shown when the user has access to at least one plugin -- Each entry links to `/settings/plugins/:id` -- No code changes needed to add future plugin-enabled feature containers +**App cards — Extension button:** +- `GET /api/plugins` is queried on the Apps page (already user-filtered by backend) +- If an app's `id` matches a plugin `id`, an "Extension" button is shown on that card +- Button links to `/settings/plugins/:id` alongside the existing admin "Settings" button +- Only users with plugin access see the button (backend filters `GET /api/plugins`) ### Documents page (`/apps/documents`) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 707cdc4..7cdb440 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -16,12 +16,11 @@ import { Users, UsersRound, Palette, - Puzzle, } from "lucide-react"; import { Button } from "@/components/ui/button"; import ThemeToggle from "@/components/ThemeToggle"; import { useAuth } from "@/hooks/useAuth"; -import { getMe, getPlugins, listCategories } from "@/api/client"; +import { getMe, listCategories } from "@/api/client"; import { cn } from "@/lib/utils"; export default function Sidebar() { @@ -57,14 +56,6 @@ export default function Sidebar() { enabled: appsOpen && docsOpen && !!user, }); - const { data: plugins = [] } = useQuery({ - queryKey: ["plugins"], - queryFn: getPlugins, - enabled: !!user, - // Empty array on 404/error — regular users simply see no plugins - retry: false, - }); - const navItemClass = (isActive: boolean) => cn( "flex items-center rounded-lg transition-colors", @@ -218,40 +209,6 @@ export default function Sidebar() { )} - {/* Extensions — visible only when the user has accessible plugins */} - {plugins.length > 0 && ( -
- {sidebarExpanded ? ( - <> -
- - Extensions - -
-
- {plugins.map((plugin) => ( - subItemClass(isActive)} - > - - {plugin.name} - - ))} -
- - ) : ( - navItemClass(isActive)} - > - - - )} -
- )} - {/* Admin — expandable */} {user?.is_admin && (
diff --git a/frontend/src/pages/AppsPage.tsx b/frontend/src/pages/AppsPage.tsx index ef367b7..eb0f458 100644 --- a/frontend/src/pages/AppsPage.tsx +++ b/frontend/src/pages/AppsPage.tsx @@ -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 (
@@ -93,6 +99,23 @@ export default function AppsPage() { Settings )} + {pluginIds.has(svc.id) && ( + e.stopPropagation()} + style={{ + padding: "6px 14px", + border: "1px solid #ccc", + borderRadius: 4, + textDecoration: "none", + fontSize: 14, + color: "#333", + }} + title="Extension settings" + > + Extension + + )}
); From c45236651b644bd3bb0c49302ffa916b3c74a541 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 18 Apr 2026 02:49:57 +0200 Subject: [PATCH 6/7] Add service admin groups, combined settings pages, single Settings button - Auto-create {service-id}-admin groups at startup (group_bootstrap.py) - get_service_admin() dep: grants access to superusers OR service group members - /api/settings/ai and /api/settings/documents/limits now allow service admins - AI service exposes /plugin/manifest (ai-service-admin access group) - DocServiceSettingsPage: combined upload limits + watch directory on one page - ServiceAdminRoute in frontend guards new /apps/documents/settings and /apps/ai/settings - Single Settings button per app card (visible to admins and service group members) Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + CLAUDE.md | 11 +- backend/STATUS.md | 18 ++- backend/app/deps.py | 25 +++ backend/app/main.py | 5 + backend/app/routers/settings.py | 16 +- backend/app/services/group_bootstrap.py | 37 +++++ backend/app/services/service_health.py | 4 +- ..._service-admin-groups-combined-settings.md | 24 +++ features/ai-service/app/main.py | 3 +- features/ai-service/app/routers/plugin.py | 31 ++++ frontend/STATUS.md | 23 ++- frontend/src/App.tsx | 57 ++++++- frontend/src/pages/AppsPage.tsx | 26 +-- frontend/src/pages/DocServiceSettingsPage.tsx | 152 ++++++++++++++++++ 15 files changed, 370 insertions(+), 63 deletions(-) create mode 100644 backend/app/services/group_bootstrap.py create mode 100644 changelog/2026-04-18_service-admin-groups-combined-settings.md create mode 100644 features/ai-service/app/routers/plugin.py create mode 100644 frontend/src/pages/DocServiceSettingsPage.tsx diff --git a/.gitignore b/.gitignore index 4976167..a5b6875 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ docker-compose.feat-*.yml # Don't sync .un files *.un~ +dev-watch/**/*.pdf diff --git a/CLAUDE.md b/CLAUDE.md index c91b3c0..59604dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ docker compose up --build -d │ ├── app/ │ │ ├── main.py ← App factory, router registration, lifespan (health loop) │ │ ├── database.py ← AsyncEngine, AsyncSessionLocal, Base -│ │ ├── deps.py ← get_current_user, get_current_admin, check_plugin_access +│ │ ├── 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 @@ -111,7 +111,8 @@ docker compose up --build -d │ │ │ ├── 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; caches /plugin/manifest per service +│ │ ├── 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) @@ -126,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 @@ -174,6 +176,7 @@ docker compose up --build -d │ │ ├── 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 @@ -465,8 +468,8 @@ Auth: is_superuser OR member of group listed in manifest `required_groups`. Retu | `/` | `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) | diff --git a/backend/STATUS.md b/backend/STATUS.md index f315602..ada52db 100644 --- a/backend/STATUS.md +++ b/backend/STATUS.md @@ -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: @@ -95,7 +99,9 @@ Generic extension/plugin infrastructure — **zero feature-specific code in back 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 Extensions sidebar — no backend code changes required. +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 diff --git a/backend/app/deps.py b/backend/app/deps.py index 08f64cc..5e01fd9 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -45,6 +45,31 @@ async def get_current_admin( 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, diff --git a/backend/app/main.py b/backend/app/main.py index e715bc9..1274ecb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,8 +6,10 @@ from fastapi.middleware.cors import CORSMiddleware from app.core.app_config import seed_builtin_themes from app.core.config import settings +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 @@ -18,6 +20,9 @@ async def lifespan(app: FastAPI): doc_service_url=settings.DOC_SERVICE_URL, ai_service_url=settings.AI_SERVICE_URL, ) + # Create -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()) diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index d6f95a0..b8b6bdc 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -31,7 +31,7 @@ from app.core.app_config import ( validate_theme_tokens, ) from app.core.config import settings -from app.deps import get_current_admin, get_current_user +from app.deps import get_current_admin, get_current_user, get_service_admin from app.models.user import User router = APIRouter() @@ -96,7 +96,7 @@ class ThemeUpdate(BaseModel): @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() @@ -104,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: @@ -145,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: @@ -171,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() @@ -179,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") @@ -195,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) @@ -205,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: diff --git a/backend/app/services/group_bootstrap.py b/backend/app/services/group_bootstrap.py new file mode 100644 index 0000000..6c0641c --- /dev/null +++ b/backend/app/services/group_bootstrap.py @@ -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 -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() diff --git a/backend/app/services/service_health.py b/backend/app/services/service_health.py index 0805075..f2eac01 100644 --- a/backend/app/services/service_health.py +++ b/backend/app/services/service_health.py @@ -52,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", @@ -61,7 +61,7 @@ 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", ), ] diff --git a/changelog/2026-04-18_service-admin-groups-combined-settings.md b/changelog/2026-04-18_service-admin-groups-combined-settings.md new file mode 100644 index 0000000..20ed308 --- /dev/null +++ b/changelog/2026-04-18_service-admin-groups-combined-settings.md @@ -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. diff --git a/features/ai-service/app/main.py b/features/ai-service/app/main.py index 9223e2f..4406d0a 100644 --- a/features/ai-service/app/main.py +++ b/features/ai-service/app/main.py @@ -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"]) diff --git a/features/ai-service/app/routers/plugin.py b/features/ai-service/app/routers/plugin.py new file mode 100644 index 0000000..3b8f75a --- /dev/null +++ b/features/ai-service/app/routers/plugin.py @@ -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 diff --git a/frontend/STATUS.md b/frontend/STATUS.md index 59e1eda..a6a5e3d 100644 --- a/frontend/STATUS.md +++ b/frontend/STATUS.md @@ -16,8 +16,8 @@ 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 | @@ -51,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 @@ -61,12 +61,6 @@ Cards are rendered dynamically from `GET /api/services` (polled every 30 s via T - Sections auto-open when navigating to their route - In collapsed (icons-only) mode, clicking the Apps icon navigates to `/apps` -**App cards — Extension button:** -- `GET /api/plugins` is queried on the Apps page (already user-filtered by backend) -- If an app's `id` matches a plugin `id`, an "Extension" button is shown on that card -- Button links to `/settings/plugins/:id` alongside the existing admin "Settings" button -- Only users with plugin access see the button (backend filters `GET /api/plugins`) - ### Documents page (`/apps/documents`) **Upload:** PDF file input, 202 response, error display. @@ -96,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`) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d2df540..561c405 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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"; @@ -12,7 +12,7 @@ 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"; @@ -31,13 +31,46 @@ function AdminRoute({ children }: { children: React.ReactNode }) { const { data: user, isLoading } = useQuery({ queryKey: ["me"], queryFn: getMe }); if (!token) return ; - // 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 ; return {children}; } +/** + * 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 ; + if (userLoading || pluginsLoading) return null; + + const hasAccess = + user?.is_admin || plugins.some((p) => p.id === serviceId); + + if (!hasAccess) return ; + return {children}; +} + export default function App() { return ( @@ -47,12 +80,20 @@ export default function App() { } /> } /> } + path="/apps/documents/settings" + element={ + + + + } /> } + path="/apps/ai/settings" + element={ + + + + } /> } /> } /> diff --git a/frontend/src/pages/AppsPage.tsx b/frontend/src/pages/AppsPage.tsx index eb0f458..c3122b9 100644 --- a/frontend/src/pages/AppsPage.tsx +++ b/frontend/src/pages/AppsPage.tsx @@ -81,8 +81,9 @@ export default function AppsPage() { This service is currently unavailable. Please try again later or contact your administrator.

)} -
- {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 && ( +
e.stopPropagation()} @@ -98,25 +99,8 @@ export default function AppsPage() { > Settings - )} - {pluginIds.has(svc.id) && ( - e.stopPropagation()} - style={{ - padding: "6px 14px", - border: "1px solid #ccc", - borderRadius: 4, - textDecoration: "none", - fontSize: 14, - color: "#333", - }} - title="Extension settings" - > - Extension - - )} -
+
+ )} ); })} diff --git a/frontend/src/pages/DocServiceSettingsPage.tsx b/frontend/src/pages/DocServiceSettingsPage.tsx new file mode 100644 index 0000000..7183a13 --- /dev/null +++ b/frontend/src/pages/DocServiceSettingsPage.tsx @@ -0,0 +1,152 @@ +import { useEffect, 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"; + +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, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function UploadLimitsSection() { + const { data: rawSettings, isLoading } = useQuery({ + queryKey: ["docLimits"], + queryFn: getDocumentLimits, + }); + + const [maxPdfMb, setMaxPdfMb] = useState(20); + + useEffect(() => { + if (!rawSettings) return; + const s = rawSettings as Record; + const docs = s.documents as Record | undefined; + if (typeof docs?.max_pdf_bytes === "number") { + setMaxPdfMb(Math.round((docs.max_pdf_bytes as number) / (1024 * 1024))); + } + }, [rawSettings]); + + const limitsMut = useMutation({ + mutationFn: (mb: number) => updateDocumentLimits(mb), + }); + + if (isLoading) return
Loading…
; + + return ( +
+
+ + setMaxPdfMb(Number(e.target.value))} + style={inputStyle} + /> +
+ + {limitsMut.isSuccess && ( +

Limits saved.

+ )} + {limitsMut.isError && ( +

Failed to save.

+ )} +
+ ); +} + +function WatchDirectorySection() { + const queryClient = useQueryClient(); + + const { data: manifest, isLoading: manifestLoading } = useQuery({ + queryKey: ["plugin-manifest", "doc-service"], + queryFn: () => getPluginManifest("doc-service"), + retry: false, + }); + + const { data: settingsValues, isLoading: settingsLoading } = useQuery({ + queryKey: ["plugin-settings", "doc-service"], + queryFn: () => getPluginSettings("doc-service"), + retry: false, + }); + + const updateMut = useMutation({ + mutationFn: (values: Record) => updatePluginSettings("doc-service", values), + onSuccess: (data) => { + queryClient.setQueryData(["plugin-settings", "doc-service"], data); + }, + }); + + if (manifestLoading || settingsLoading) return
Loading…
; + + if (!manifest?.settings_schema || !settingsValues) { + return ( +
+ Watch directory settings unavailable. Ensure the doc-service is running. +
+ ); + } + + return ( +
+

+ Automatically ingest PDF files dropped into the watched directory. Subfolders are mapped to document categories. +

+ } + onSave={(values) => updateMut.mutate(values)} + isPending={updateMut.isPending} + isError={updateMut.isError} + isSuccess={updateMut.isSuccess} + /> +
+ ); +} + +export default function DocServiceSettingsPage() { + return ( +
+

Documents — Settings

+ + +
+ ); +} From f16c290b925b81c43a90d89bdaab1e32d5b1e5c6 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 18 Apr 2026 10:49:46 +0200 Subject: [PATCH 7/7] Consolidate doc-service settings to a single Save changes button Lift state to page level, fire both upload-limits and watch-directory mutations from one button. Add noSaveButton and onChange props to PluginSchemaForm to support this pattern. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/PluginSchemaForm.tsx | 34 ++-- frontend/src/pages/DocServiceSettingsPage.tsx | 176 +++++++++--------- 2 files changed, 109 insertions(+), 101 deletions(-) diff --git a/frontend/src/components/PluginSchemaForm.tsx b/frontend/src/components/PluginSchemaForm.tsx index 290cad9..73b40a9 100644 --- a/frontend/src/components/PluginSchemaForm.tsx +++ b/frontend/src/components/PluginSchemaForm.tsx @@ -17,6 +17,10 @@ interface PluginSchemaFormProps { 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) => void; } function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { @@ -48,6 +52,8 @@ export default function PluginSchemaForm({ isPending, isError, isSuccess, + noSaveButton, + onChange, }: PluginSchemaFormProps) { const [form, setForm] = useState>(values); @@ -56,7 +62,9 @@ export default function PluginSchemaForm({ }, [values]); const setField = (key: string, value: unknown) => { - setForm((prev) => ({ ...prev, [key]: value })); + const next = { ...form, [key]: value }; + setForm(next); + onChange?.(next); }; return ( @@ -103,17 +111,19 @@ export default function PluginSchemaForm({
))} -
- - {isError && ( - Failed to save. Please try again. - )} - {isSuccess && !isPending && ( - Saved successfully. - )} -
+ {!noSaveButton && ( +
+ + {isError && ( + Failed to save. Please try again. + )} + {isSuccess && !isPending && ( + Saved successfully. + )} +
+ )}
); } diff --git a/frontend/src/pages/DocServiceSettingsPage.tsx b/frontend/src/pages/DocServiceSettingsPage.tsx index 7183a13..21ae89e 100644 --- a/frontend/src/pages/DocServiceSettingsPage.tsx +++ b/frontend/src/pages/DocServiceSettingsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { getDocumentLimits, @@ -8,6 +8,7 @@ import { getPluginManifest, } from "../api/client"; import PluginSchemaForm from "../components/PluginSchemaForm"; +import { Button } from "@/components/ui/button"; const inputStyle: React.CSSProperties = { width: 120, @@ -20,133 +21,130 @@ const inputStyle: React.CSSProperties = { color: "rgb(var(--color-text-primary))", }; -function Section({ title, children }: { title: string; children: React.ReactNode }) { +function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) { return (
-

{title}

+

{title}

+ {description && ( +

+ {description} +

+ )} {children}
); } -function UploadLimitsSection() { - const { data: rawSettings, isLoading } = useQuery({ +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 (!rawSettings) return; - const s = rawSettings as Record; + if (!limitsData) return; + const s = limitsData as Record; const docs = s.documents as Record | undefined; if (typeof docs?.max_pdf_bytes === "number") { setMaxPdfMb(Math.round((docs.max_pdf_bytes as number) / (1024 * 1024))); } - }, [rawSettings]); - - const limitsMut = useMutation({ - mutationFn: (mb: number) => updateDocumentLimits(mb), - }); - - if (isLoading) return
Loading…
; - - return ( -
-
- - setMaxPdfMb(Number(e.target.value))} - style={inputStyle} - /> -
- - {limitsMut.isSuccess && ( -

Limits saved.

- )} - {limitsMut.isError && ( -

Failed to save.

- )} -
- ); -} - -function WatchDirectorySection() { - const queryClient = useQueryClient(); + }, [limitsData]); + // ── Watch directory ────────────────────────────────────────────────────────── const { data: manifest, isLoading: manifestLoading } = useQuery({ queryKey: ["plugin-manifest", "doc-service"], queryFn: () => getPluginManifest("doc-service"), retry: false, }); - - const { data: settingsValues, isLoading: settingsLoading } = useQuery({ + const { data: watchData, isLoading: watchLoading } = useQuery({ queryKey: ["plugin-settings", "doc-service"], queryFn: () => getPluginSettings("doc-service"), retry: false, }); - const updateMut = useMutation({ + // Ref so PluginSchemaForm can push its current values to us on change + const watchValuesRef = useRef>({}); + useEffect(() => { + if (watchData) watchValuesRef.current = watchData as Record; + }, [watchData]); + + // ── Mutations ──────────────────────────────────────────────────────────────── + const limitsMut = useMutation({ + mutationFn: (mb: number) => updateDocumentLimits(mb), + }); + const watchMut = useMutation({ mutationFn: (values: Record) => updatePluginSettings("doc-service", values), onSuccess: (data) => { queryClient.setQueryData(["plugin-settings", "doc-service"], data); }, }); - if (manifestLoading || settingsLoading) return
Loading…
; + const isPending = limitsMut.isPending || watchMut.isPending; + const isError = limitsMut.isError || watchMut.isError; + const isSuccess = limitsMut.isSuccess && watchMut.isSuccess; - if (!manifest?.settings_schema || !settingsValues) { - return ( -
- Watch directory settings unavailable. Ensure the doc-service is running. -
- ); + const handleSave = () => { + limitsMut.mutate(maxPdfMb); + if (manifest?.settings_schema) { + watchMut.mutate(watchValuesRef.current); + } + }; + + const isLoading = limitsLoading || manifestLoading || watchLoading; + + if (isLoading) { + return
Loading…
; } - return ( -
-

- Automatically ingest PDF files dropped into the watched directory. Subfolders are mapped to document categories. -

- } - onSave={(values) => updateMut.mutate(values)} - isPending={updateMut.isPending} - isError={updateMut.isError} - isSuccess={updateMut.isSuccess} - /> -
- ); -} - -export default function DocServiceSettingsPage() { return (

Documents — Settings

- - + +
+
+ + setMaxPdfMb(Number(e.target.value))} + style={inputStyle} + /> +
+
+ + {manifest?.settings_schema && watchData ? ( +
+ } + onSave={() => {}} + onChange={(values) => { watchValuesRef.current = values; }} + noSaveButton + /> +
+ ) : null} + +
+ + {isError && ( + Failed to save. Please try again. + )} + {isSuccess && !isPending && ( + Saved successfully. + )} +
); }