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
+10 -13
View File
@@ -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`)
+49 -8
View File
@@ -1,7 +1,7 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "./hooks/useAuth";
import { getMe } from "./api/client";
import { getMe, getPlugins } from "./api/client";
import AppShell from "./components/AppShell";
import LoginPage from "./pages/LoginPage";
import DashboardPage from "./pages/DashboardPage";
@@ -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 <Navigate to="/login" replace />;
// Wait for the me query before deciding — prevents a flash redirect
if (isLoading) return null;
// Redirect to /login (not /) so the route appears not to exist
if (!user?.is_admin) return <Navigate to="/login" replace />;
return <AppShell>{children}</AppShell>;
}
/**
* Route guard for service-specific settings pages.
*
* Grants access if the user is a global admin OR the plugin (service) list
* returned by the backend includes the given serviceId — which means the user
* is a member of that service's admin group.
*/
function ServiceAdminRoute({
children,
serviceId,
}: {
children: React.ReactNode;
serviceId: string;
}) {
const { token } = useAuth();
const { data: user, isLoading: userLoading } = useQuery({
queryKey: ["me"],
queryFn: getMe,
});
const { data: plugins = [], isLoading: pluginsLoading } = useQuery({
queryKey: ["plugins"],
queryFn: getPlugins,
retry: false,
});
if (!token) return <Navigate to="/login" replace />;
if (userLoading || pluginsLoading) return null;
const hasAccess =
user?.is_admin || plugins.some((p) => p.id === serviceId);
if (!hasAccess) return <Navigate to="/login" replace />;
return <AppShell>{children}</AppShell>;
}
export default function App() {
return (
<Routes>
@@ -47,12 +80,20 @@ export default function App() {
<Route path="/apps" element={<PrivateRoute><AppsPage /></PrivateRoute>} />
<Route path="/apps/documents" element={<PrivateRoute><DocumentsPage /></PrivateRoute>} />
<Route
path="/apps/documents/settings/admin"
element={<AdminRoute><DocumentAdminSettingsPage /></AdminRoute>}
path="/apps/documents/settings"
element={
<ServiceAdminRoute serviceId="doc-service">
<DocServiceSettingsPage />
</ServiceAdminRoute>
}
/>
<Route
path="/apps/ai/settings/admin"
element={<AdminRoute><AIAdminSettingsPage /></AdminRoute>}
path="/apps/ai/settings"
element={
<ServiceAdminRoute serviceId="ai-service">
<AIAdminSettingsPage />
</ServiceAdminRoute>
}
/>
<Route path="/profile" element={<PrivateRoute><ProfilePage /></PrivateRoute>} />
<Route path="/settings" element={<PrivateRoute><SettingsPage /></PrivateRoute>} />
+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.
</p>
)}
<div style={{ display: "flex", gap: 8, marginTop: "auto" }}>
{user?.is_admin && svc.settings_path && (
{/* Single Settings button — visible to global admins and service-specific admin group members */}
{(user?.is_admin || pluginIds.has(svc.id)) && svc.settings_path && (
<div style={{ marginTop: "auto" }}>
<Link
to={svc.settings_path}
onClick={(e) => e.stopPropagation()}
@@ -98,25 +99,8 @@ export default function AppsPage() {
>
Settings
</Link>
)}
{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>
</div>
)}
</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>
);
}