From c45236651b644bd3bb0c49302ffa916b3c74a541 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 18 Apr 2026 02:49:57 +0200 Subject: [PATCH] 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

+ + +
+ ); +}