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 (
+
+ );
+}
+
+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
+
+
+
+ );
+}