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