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 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-18 02:49:57 +02:00
parent 003fbee20f
commit c45236651b
15 changed files with 370 additions and 63 deletions
+1
View File
@@ -27,3 +27,4 @@ docker-compose.feat-*.yml
# Don't sync .un files # Don't sync .un files
*.un~ *.un~
dev-watch/**/*.pdf
+7 -4
View File
@@ -84,7 +84,7 @@ docker compose up --build -d
│ ├── app/ │ ├── app/
│ │ ├── main.py ← App factory, router registration, lifespan (health loop) │ │ ├── main.py ← App factory, router registration, lifespan (health loop)
│ │ ├── database.py ← AsyncEngine, AsyncSessionLocal, Base │ │ ├── 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/ │ │ ├── core/
│ │ │ ├── config.py ← All settings via pydantic-settings (reads .env) │ │ │ ├── config.py ← All settings via pydantic-settings (reads .env)
│ │ │ ├── security.py ← JWT sign/verify (RS256), bcrypt hash/verify │ │ │ ├── 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/* │ │ │ ├── categories_proxy.py ← Transparent proxy → doc-service /categories/*
│ │ │ └── documents_proxy.py ← Transparent proxy → doc-service /documents/* │ │ │ └── documents_proxy.py ← Transparent proxy → doc-service /documents/*
│ │ └── services/ │ │ └── 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/ │ ├── alembic/
│ │ ├── env.py ← Async migration runner │ │ ├── env.py ← Async migration runner
│ │ └── versions/ ← Migration chain (see Migrations section) │ │ └── 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/chat.py ← POST /chat (sync, NORMAL priority queue)
│ │ │ ├── routers/health.py ← GET /health │ │ │ ├── routers/health.py ← GET /health
│ │ │ ├── routers/queue.py ← GET /queue/status, /pause, /resume, /cancel/{id} │ │ │ ├── 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/base.py ← AIProvider abstract class
│ │ │ ├── providers/anthropic_provider.py │ │ │ ├── providers/anthropic_provider.py
│ │ │ ├── providers/openai_compat.py ← Ollama / LM Studio │ │ │ ├── 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) │ │ ├── PluginSchemaForm.tsx ← JSON Schema → React form (boolean/string/number/readOnly)
│ │ └── ui/ ← shadcn/ui components (Button, Input, …) │ │ └── ui/ ← shadcn/ui components (Button, Input, …)
│ ├── pages/ ← One file per route (see Routes section) │ ├── 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 │ │ └── PluginSettingsPage.tsx ← Generic plugin settings page driven by manifest
│ ├── lib/utils.ts ← cn() = clsx + tailwind-merge │ ├── lib/utils.ts ← cn() = clsx + tailwind-merge
│ └── styles/theme.css ← CSS custom properties, Tailwind setup │ └── 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 | | `/` | `DashboardPage` | PrivateRoute |
| `/apps` | `AppsPage` | PrivateRoute | | `/apps` | `AppsPage` | PrivateRoute |
| `/apps/documents` | `DocumentsPage` | PrivateRoute | | `/apps/documents` | `DocumentsPage` | PrivateRoute |
| `/apps/documents/settings/admin` | `DocumentAdminSettingsPage` | AdminRoute | | `/apps/documents/settings` | `DocServiceSettingsPage` | ServiceAdminRoute (is_admin OR doc-service-admin member) |
| `/apps/ai/settings/admin` | `AIAdminSettingsPage` | AdminRoute | | `/apps/ai/settings` | `AIAdminSettingsPage` | ServiceAdminRoute (is_admin OR ai-service-admin member) |
| `/profile` | `ProfilePage` | PrivateRoute | | `/profile` | `ProfilePage` | PrivateRoute |
| `/settings` | `SettingsPage` | PrivateRoute | | `/settings` | `SettingsPage` | PrivateRoute |
| `/settings/plugins/:id` | `PluginSettingsPage` | PrivateRoute (auth enforced per-plugin by backend) | | `/settings/plugins/:id` | `PluginSettingsPage` | PrivateRoute (auth enforced per-plugin by backend) |
+12 -6
View File
@@ -66,14 +66,18 @@ A background task (`service_health.py`) polls each service's `/health` endpoint
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| `GET` | `/api/settings/ai` | AI service config (masked — API keys redacted) | | `GET` | `/api/settings/ai` | AI service config (masked)superuser OR `ai-service-admin` member |
| `PATCH` | `/api/settings/ai` | Update AI provider / credentials | | `PATCH` | `/api/settings/ai` | Update AI provider / credentials — same access |
| `POST` | `/api/settings/ai/test` | Test AI connection (proxies a minimal /chat call) | | `POST` | `/api/settings/ai/test` | Test AI connection — same access |
| `GET` | `/api/settings/documents/limits` | Doc service upload limits | | `GET` | `/api/settings/documents/limits` | Doc service upload limits — superuser OR `doc-service-admin` member |
| `PATCH` | `/api/settings/documents/limits` | Update max PDF size | | `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. 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 ### Feature proxies
All `/api/documents/*` and `/api/documents/categories/*` requests are transparently proxied to `doc-service:8001` via `httpx.AsyncClient`. The proxy: 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. 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 ### Database models
+25
View File
@@ -45,6 +45,31 @@ async def get_current_admin(
return current_user 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( async def check_plugin_access(
plugin_id: str, plugin_id: str,
current_user: User, current_user: User,
+5
View File
@@ -6,8 +6,10 @@ from fastapi.middleware.cors import CORSMiddleware
from app.core.app_config import seed_builtin_themes from app.core.app_config import seed_builtin_themes
from app.core.config import settings 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 admin, auth, categories_proxy, documents_proxy, groups, plugins, profile, services, users
from app.routers import settings as settings_router 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 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, doc_service_url=settings.DOC_SERVICE_URL,
ai_service_url=settings.AI_SERVICE_URL, ai_service_url=settings.AI_SERVICE_URL,
) )
# Create <service-id>-admin groups for every registered service (idempotent)
async with AsyncSessionLocal() as db:
await ensure_service_admin_groups(db)
# Run an initial check immediately so the first API response is accurate # Run an initial check immediately so the first API response is accurate
await check_all() await check_all()
task = asyncio.create_task(health_check_loop()) task = asyncio.create_task(health_check_loop())
+8 -8
View File
@@ -31,7 +31,7 @@ from app.core.app_config import (
validate_theme_tokens, validate_theme_tokens,
) )
from app.core.config import settings 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 from app.models.user import User
router = APIRouter() router = APIRouter()
@@ -96,7 +96,7 @@ class ThemeUpdate(BaseModel):
@router.get("/ai") @router.get("/ai")
async def get_ai_settings( async def get_ai_settings(
_: User = Depends(get_current_admin), _: User = Depends(get_service_admin("ai-service")),
) -> dict: ) -> dict:
return load_ai_service_config_masked() return load_ai_service_config_masked()
@@ -104,7 +104,7 @@ async def get_ai_settings(
@router.patch("/ai") @router.patch("/ai")
async def update_ai_settings( async def update_ai_settings(
body: AIProviderUpdate, body: AIProviderUpdate,
_: User = Depends(get_current_admin), _: User = Depends(get_service_admin("ai-service")),
) -> dict: ) -> dict:
valid_providers = ("anthropic", "ollama", "lmstudio") valid_providers = ("anthropic", "ollama", "lmstudio")
if body.provider not in valid_providers: if body.provider not in valid_providers:
@@ -145,7 +145,7 @@ async def update_ai_settings(
@router.post("/ai/test") @router.post("/ai/test")
async def test_ai_connection( async def test_ai_connection(
_: User = Depends(get_current_admin), _: User = Depends(get_service_admin("ai-service")),
) -> dict: ) -> dict:
"""Proxy a minimal chat request to ai-service to verify the connection.""" """Proxy a minimal chat request to ai-service to verify the connection."""
try: try:
@@ -171,7 +171,7 @@ async def test_ai_connection(
@router.get("/documents/limits") @router.get("/documents/limits")
async def get_documents_limits( async def get_documents_limits(
_: User = Depends(get_current_admin), _: User = Depends(get_service_admin("doc-service")),
) -> dict: ) -> dict:
return load_doc_service_config_masked() return load_doc_service_config_masked()
@@ -179,7 +179,7 @@ async def get_documents_limits(
@router.patch("/documents/limits") @router.patch("/documents/limits")
async def update_documents_limits( async def update_documents_limits(
body: LimitsUpdate, body: LimitsUpdate,
_: User = Depends(get_current_admin), _: User = Depends(get_service_admin("doc-service")),
) -> dict: ) -> dict:
if body.max_pdf_mb < 1 or body.max_pdf_mb > 200: 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") 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") @router.get("/system-prompts")
async def get_system_prompts( async def get_system_prompts(
_: User = Depends(get_current_admin), _: User = Depends(get_service_admin("ai-service")),
) -> dict: ) -> dict:
"""Return all editable system prompts, keyed by service id.""" """Return all editable system prompts, keyed by service id."""
return await asyncio.to_thread(load_all_system_prompts) return await asyncio.to_thread(load_all_system_prompts)
@@ -205,7 +205,7 @@ async def get_system_prompts(
async def update_system_prompt( async def update_system_prompt(
service_id: str, service_id: str,
body: SystemPromptUpdate, body: SystemPromptUpdate,
_: User = Depends(get_current_admin), _: User = Depends(get_service_admin("ai-service")),
) -> dict: ) -> dict:
"""Update the system prompts for a single service.""" """Update the system prompts for a single service."""
if service_id not in SYSTEM_PROMPT_SERVICES: if service_id not in SYSTEM_PROMPT_SERVICES:
+37
View File
@@ -0,0 +1,37 @@
"""
Ensure that every registered service has a corresponding admin group.
Called once at startup after register_services(). Idempotent — safe to run
on every restart, creates nothing if groups already exist.
Naming convention: "{service_id}-admin" (e.g. "doc-service-admin")
"""
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.group import Group
from app.services.service_health import get_registry
logger = logging.getLogger(__name__)
async def ensure_service_admin_groups(db: AsyncSession) -> None:
"""Create a <service-id>-admin group for each registered service if absent."""
for svc in get_registry():
group_name = f"{svc.id}-admin"
result = await db.execute(select(Group).where(Group.name == group_name))
if result.scalar_one_or_none() is not None:
continue
import uuid
group = Group(
id=str(uuid.uuid4()),
name=group_name,
description=f"Administrators for the {svc.name} service.",
)
db.add(group)
logger.info("[bootstrap] Created admin group %r for service %r", group_name, svc.id)
await db.commit()
+2 -2
View File
@@ -52,7 +52,7 @@ def register_services(doc_service_url: str, ai_service_url: str) -> None:
internal_url=doc_service_url, internal_url=doc_service_url,
health_path="/health", health_path="/health",
app_path="/apps/documents", app_path="/apps/documents",
settings_path="/apps/documents/settings/admin", settings_path="/apps/documents/settings",
), ),
ServiceDefinition( ServiceDefinition(
id="ai-service", id="ai-service",
@@ -61,7 +61,7 @@ def register_services(doc_service_url: str, ai_service_url: str) -> None:
internal_url=ai_service_url, internal_url=ai_service_url,
health_path="/health", health_path="/health",
app_path="", app_path="",
settings_path="/apps/ai/settings/admin", settings_path="/apps/ai/settings",
), ),
] ]
@@ -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.
+2 -1
View File
@@ -4,7 +4,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from app.core.config import settings 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.routers import queue as queue_router
from app.services.config_reader import load_ai_config from app.services.config_reader import load_ai_config
from app.services.queue import queue_service 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(chat.router, tags=["chat"])
app.include_router(health.router, tags=["health"]) app.include_router(health.router, tags=["health"])
app.include_router(queue_router.router) app.include_router(queue_router.router)
app.include_router(plugin.router, tags=["plugin"])
+31
View File
@@ -0,0 +1,31 @@
"""
Plugin manifest endpoint for the AI service.
Exposes GET /plugin/manifest so the backend health-poller can discover the
service's access rules and register it in the plugin system.
No settings schema is exposed here — the AI service settings are complex
(provider selection, conditional fields) and are rendered by a bespoke page
rather than the generic PluginSchemaForm.
"""
from fastapi import APIRouter
router = APIRouter()
_MANIFEST = {
"id": "ai-service",
"name": "AI Service",
"icon": "cpu",
"version": "1.0",
"access": {
"allow_superuser": True,
"required_groups": ["ai-service-admin"],
},
# No settings_schema — the frontend uses a custom settings page
"settings_schema": None,
}
@router.get("/plugin/manifest")
async def get_manifest() -> dict:
return _MANIFEST
+10 -13
View File
@@ -16,8 +16,8 @@ All API calls go through `src/api/client.ts` (single Axios instance, JWT injecte
| `/` | `DashboardPage` | Required | | `/` | `DashboardPage` | Required |
| `/apps` | `AppsPage` | Required | | `/apps` | `AppsPage` | Required |
| `/apps/documents` | `DocumentsPage` | Required | | `/apps/documents` | `DocumentsPage` | Required |
| `/apps/documents/settings/admin` | `DocumentAdminSettingsPage` | Admin only | | `/apps/documents/settings` | `DocServiceSettingsPage` | ServiceAdminRoute (is_admin OR doc-service-admin) |
| `/apps/ai/settings/admin` | `AIAdminSettingsPage` | Admin only | | `/apps/ai/settings` | `AIAdminSettingsPage` | ServiceAdminRoute (is_admin OR ai-service-admin) |
| `/admin` | `AdminPage` (redirects to `/admin/users`) | Admin only | | `/admin` | `AdminPage` (redirects to `/admin/users`) | Admin only |
| `/admin/users` | `AdminUsersPage` | Admin only | | `/admin/users` | `AdminUsersPage` | Admin only |
| `/admin/groups` | `AdminGroupsPage` | 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 + app_path set** — clickable card with "Available" badge
- **healthy=true + no app_path** — non-clickable card (e.g. AI Service — no user UI) - **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 - **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 ### 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 - Sections auto-open when navigating to their route
- In collapsed (icons-only) mode, clicking the Apps icon navigates to `/apps` - 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`) ### Documents page (`/apps/documents`)
**Upload:** PDF file input, 202 response, error display. **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 - **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 - **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) - Provider selector (lmstudio / ollama / anthropic)
- Per-provider fields (base URL, model, API key) - Per-provider fields (base URL, model, API key)
- Test Connection button (`POST /api/settings/ai/test`) - Test Connection button (`POST /api/settings/ai/test`)
- Save button - Save button
### Document Admin Settings (`/apps/documents/settings/admin`) ### Document Service Settings (`/apps/documents/settings`)
- Upload Limits section only (max PDF size in MB) Accessible to global admins and `doc-service-admin` group members (`ServiceAdminRoute`).
- Save button 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`) ### Admin — Users page (`/admin/users`)
+49 -8
View File
@@ -1,7 +1,7 @@
import { Routes, Route, Navigate } from "react-router-dom"; import { Routes, Route, Navigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuth } from "./hooks/useAuth"; import { useAuth } from "./hooks/useAuth";
import { getMe } from "./api/client"; import { getMe, getPlugins } from "./api/client";
import AppShell from "./components/AppShell"; import AppShell from "./components/AppShell";
import LoginPage from "./pages/LoginPage"; import LoginPage from "./pages/LoginPage";
import DashboardPage from "./pages/DashboardPage"; import DashboardPage from "./pages/DashboardPage";
@@ -12,7 +12,7 @@ import AdminUsersPage from "./pages/AdminUsersPage";
import AdminGroupsPage from "./pages/AdminGroupsPage"; import AdminGroupsPage from "./pages/AdminGroupsPage";
import AdminAppearancePage from "./pages/AdminAppearancePage"; import AdminAppearancePage from "./pages/AdminAppearancePage";
import DocumentsPage from "./pages/DocumentsPage"; import DocumentsPage from "./pages/DocumentsPage";
import DocumentAdminSettingsPage from "./pages/DocumentAdminSettingsPage"; import DocServiceSettingsPage from "./pages/DocServiceSettingsPage";
import AIAdminSettingsPage from "./pages/AIAdminSettingsPage"; import AIAdminSettingsPage from "./pages/AIAdminSettingsPage";
import SettingsPage from "./pages/SettingsPage"; import SettingsPage from "./pages/SettingsPage";
import PluginSettingsPage from "./pages/PluginSettingsPage"; import PluginSettingsPage from "./pages/PluginSettingsPage";
@@ -31,13 +31,46 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
const { data: user, isLoading } = useQuery({ queryKey: ["me"], queryFn: getMe }); const { data: user, isLoading } = useQuery({ queryKey: ["me"], queryFn: getMe });
if (!token) return <Navigate to="/login" replace />; if (!token) return <Navigate to="/login" replace />;
// Wait for the me query before deciding — prevents a flash redirect
if (isLoading) return null; if (isLoading) return null;
// Redirect to /login (not /) so the route appears not to exist
if (!user?.is_admin) return <Navigate to="/login" replace />; if (!user?.is_admin) return <Navigate to="/login" replace />;
return <AppShell>{children}</AppShell>; return <AppShell>{children}</AppShell>;
} }
/**
* Route guard for service-specific settings pages.
*
* Grants access if the user is a global admin OR the plugin (service) list
* returned by the backend includes the given serviceId — which means the user
* is a member of that service's admin group.
*/
function ServiceAdminRoute({
children,
serviceId,
}: {
children: React.ReactNode;
serviceId: string;
}) {
const { token } = useAuth();
const { data: user, isLoading: userLoading } = useQuery({
queryKey: ["me"],
queryFn: getMe,
});
const { data: plugins = [], isLoading: pluginsLoading } = useQuery({
queryKey: ["plugins"],
queryFn: getPlugins,
retry: false,
});
if (!token) return <Navigate to="/login" replace />;
if (userLoading || pluginsLoading) return null;
const hasAccess =
user?.is_admin || plugins.some((p) => p.id === serviceId);
if (!hasAccess) return <Navigate to="/login" replace />;
return <AppShell>{children}</AppShell>;
}
export default function App() { export default function App() {
return ( return (
<Routes> <Routes>
@@ -47,12 +80,20 @@ export default function App() {
<Route path="/apps" element={<PrivateRoute><AppsPage /></PrivateRoute>} /> <Route path="/apps" element={<PrivateRoute><AppsPage /></PrivateRoute>} />
<Route path="/apps/documents" element={<PrivateRoute><DocumentsPage /></PrivateRoute>} /> <Route path="/apps/documents" element={<PrivateRoute><DocumentsPage /></PrivateRoute>} />
<Route <Route
path="/apps/documents/settings/admin" path="/apps/documents/settings"
element={<AdminRoute><DocumentAdminSettingsPage /></AdminRoute>} element={
<ServiceAdminRoute serviceId="doc-service">
<DocServiceSettingsPage />
</ServiceAdminRoute>
}
/> />
<Route <Route
path="/apps/ai/settings/admin" path="/apps/ai/settings"
element={<AdminRoute><AIAdminSettingsPage /></AdminRoute>} element={
<ServiceAdminRoute serviceId="ai-service">
<AIAdminSettingsPage />
</ServiceAdminRoute>
}
/> />
<Route path="/profile" element={<PrivateRoute><ProfilePage /></PrivateRoute>} /> <Route path="/profile" element={<PrivateRoute><ProfilePage /></PrivateRoute>} />
<Route path="/settings" element={<PrivateRoute><SettingsPage /></PrivateRoute>} /> <Route path="/settings" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />
+5 -21
View File
@@ -81,8 +81,9 @@ export default function AppsPage() {
This service is currently unavailable. Please try again later or contact your administrator. This service is currently unavailable. Please try again later or contact your administrator.
</p> </p>
)} )}
<div style={{ display: "flex", gap: 8, marginTop: "auto" }}> {/* Single Settings button — visible to global admins and service-specific admin group members */}
{user?.is_admin && svc.settings_path && ( {(user?.is_admin || pluginIds.has(svc.id)) && svc.settings_path && (
<div style={{ marginTop: "auto" }}>
<Link <Link
to={svc.settings_path} to={svc.settings_path}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -98,25 +99,8 @@ export default function AppsPage() {
> >
Settings Settings
</Link> </Link>
)} </div>
{pluginIds.has(svc.id) && ( )}
<Link
to={`/settings/plugins/${svc.id}`}
onClick={(e) => e.stopPropagation()}
style={{
padding: "6px 14px",
border: "1px solid #ccc",
borderRadius: 4,
textDecoration: "none",
fontSize: 14,
color: "#333",
}}
title="Extension settings"
>
Extension
</Link>
)}
</div>
</CardWrapper> </CardWrapper>
); );
})} })}
@@ -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 (
<section style={{ marginBottom: 36 }}>
<h2 style={{ fontSize: 18, marginBottom: 16 }}>{title}</h2>
{children}
</section>
);
}
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<string, unknown>;
const docs = s.documents as Record<string, unknown> | undefined;
if (typeof docs?.max_pdf_bytes === "number") {
setMaxPdfMb(Math.round((docs.max_pdf_bytes as number) / (1024 * 1024)));
}
}, [rawSettings]);
const limitsMut = useMutation({
mutationFn: (mb: number) => updateDocumentLimits(mb),
});
if (isLoading) return <div>Loading</div>;
return (
<Section title="Upload Limits">
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "rgb(var(--color-text-muted))" }}>
Max file size (MB)
</label>
<input
type="number"
min={1}
max={200}
value={maxPdfMb}
onChange={(e) => setMaxPdfMb(Number(e.target.value))}
style={inputStyle}
/>
</div>
<button
onClick={() => limitsMut.mutate(maxPdfMb)}
disabled={limitsMut.isPending}
style={{
padding: "8px 16px",
cursor: "pointer",
background: "#222",
color: "#fff",
borderRadius: 4,
border: "none",
marginTop: 8,
}}
>
{limitsMut.isPending ? "Saving…" : "Save"}
</button>
{limitsMut.isSuccess && (
<p style={{ marginTop: 8, fontSize: 13, color: "#2a9d8f" }}>Limits saved.</p>
)}
{limitsMut.isError && (
<p style={{ marginTop: 8, fontSize: 13, color: "#e76f51" }}>Failed to save.</p>
)}
</Section>
);
}
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<string, unknown>) => updatePluginSettings("doc-service", values),
onSuccess: (data) => {
queryClient.setQueryData(["plugin-settings", "doc-service"], data);
},
});
if (manifestLoading || settingsLoading) return <div>Loading</div>;
if (!manifest?.settings_schema || !settingsValues) {
return (
<div style={{ fontSize: 13, color: "rgb(var(--color-text-muted))" }}>
Watch directory settings unavailable. Ensure the doc-service is running.
</div>
);
}
return (
<Section title="Watch Directory">
<p style={{ fontSize: 13, color: "rgb(var(--color-text-muted))", marginBottom: 20 }}>
Automatically ingest PDF files dropped into the watched directory. Subfolders are mapped to document categories.
</p>
<PluginSchemaForm
schema={manifest.settings_schema}
values={settingsValues as Record<string, unknown>}
onSave={(values) => updateMut.mutate(values)}
isPending={updateMut.isPending}
isError={updateMut.isError}
isSuccess={updateMut.isSuccess}
/>
</Section>
);
}
export default function DocServiceSettingsPage() {
return (
<div style={{ padding: 32, maxWidth: 700, margin: "0 auto" }}>
<h1 style={{ fontSize: 24, marginBottom: 32 }}>Documents Settings</h1>
<UploadLimitsSection />
<WatchDirectorySection />
</div>
);
}