feat: category scopes, group-admin role, and permission model

- Three category scopes: personal / group / system (watch)
- PascalCase-with-dashes naming convention enforced at backend + frontend
- is_group_admin flag on GroupMembership; PATCH endpoint for admins to toggle it
- Categories router: scope-based list/create/rename/delete with _check_can_manage_cat
- Documents router: delete uses is_admin + can_delete share flag + group-admin check; remove_category requires doc ownership; assign_category accepts group/system categories
- Proxy layers inject x-user-is-admin and x-user-admin-groups headers
- Frontend: ManageCategoriesDialog grouped by scope with lock icons; SourcePanel scope picker + client-side name validation; AdminGroupsPage group-admin checkbox

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-18 22:16:49 +02:00
parent 05d79d3d21
commit fec3953009
22 changed files with 691 additions and 155 deletions
+10 -3
View File
@@ -4,7 +4,7 @@ const BASE = "/api";
// Core fetch wrapper
// ---------------------------------------------------------------------------
class ApiError extends Error {
export class ApiError extends Error {
status: number;
detail: string;
@@ -199,6 +199,8 @@ export type DocumentStatus = "pending" | "processing" | "done" | "failed";
export interface CategoryOut {
id: string;
name: string;
scope: "personal" | "group" | "system";
group_id: string | null;
}
export interface DocumentOut {
@@ -332,8 +334,8 @@ export const removeCategory = (docId: string, catId: string) =>
export const listCategories = () => api.get<CategoryOut[]>("/documents/categories");
export const createCategory = (name: string) =>
api.post<CategoryOut>("/documents/categories", { name });
export const createCategory = (name: string, groupId?: string) =>
api.post<CategoryOut>("/documents/categories", { name, group_id: groupId ?? null });
export const renameCategory = (id: string, name: string) =>
api.patch<CategoryOut>(`/documents/categories/${id}`, { name });
@@ -431,6 +433,7 @@ export interface UserGroupOut {
id: string;
name: string;
description: string | null;
is_group_admin: boolean;
}
export const getMyGroups = () => api.get<UserGroupOut[]>("/users/me/groups");
@@ -453,6 +456,7 @@ export interface GroupMemberOut {
full_name: string | null;
is_active: boolean;
joined_at: string;
is_group_admin: boolean;
}
export interface GroupDetailOut extends GroupOut {
@@ -489,6 +493,9 @@ export const adminAddGroupMember = (groupId: string, userId: string) =>
export const adminRemoveGroupMember = (groupId: string, userId: string) =>
api.delete(`/admin/groups/${groupId}/members/${userId}`);
export const adminSetGroupMemberAdmin = (groupId: string, userId: string, isAdmin: boolean) =>
api.patch(`/admin/groups/${groupId}/members/${userId}/admin`, { is_group_admin: isAdmin });
// ---------------------------------------------------------------------------
// Services
// ---------------------------------------------------------------------------
@@ -1,7 +1,14 @@
import { useState, useRef, useEffect } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { X, Pencil, Trash2, Check } from "lucide-react";
import { listCategories, renameCategory, deleteCategory } from "@/api/client";
import { X, Pencil, Trash2, Check, Lock } from "lucide-react";
import {
listCategories,
renameCategory,
deleteCategory,
getMyGroups,
type CategoryOut,
ApiError,
} from "@/api/client";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -11,13 +18,23 @@ interface Props {
export default function ManageCategoriesDialog({ onClose }: Props) {
const queryClient = useQueryClient();
const { data: categories = [] } = useQuery({
queryKey: ["categories"],
queryFn: listCategories,
});
const { data: myGroups = [] } = useQuery({
queryKey: ["my-groups"],
queryFn: getMyGroups,
});
// Set of group IDs for which the current user is a group admin
const adminGroupIds = new Set(myGroups.filter((g) => g.is_group_admin).map((g) => g.id));
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [editError, setEditError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const editInputRef = useRef<HTMLInputElement>(null);
@@ -30,6 +47,10 @@ export default function ManageCategoriesDialog({ onClose }: Props) {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
setEditingId(null);
setEditError(null);
},
onError: (err) => {
setEditError(err instanceof ApiError ? err.message : "Rename failed");
},
});
@@ -38,24 +59,135 @@ export default function ManageCategoriesDialog({ onClose }: Props) {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["categories"] }),
});
function canManage(cat: CategoryOut): boolean {
if (cat.scope === "personal") return true;
if (cat.scope === "group") return cat.group_id != null && adminGroupIds.has(cat.group_id);
return false; // system — managed only by superuser via other means
}
const filtered = search
? categories.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
: categories;
// Group by scope
const personal = filtered.filter((c) => c.scope === "personal");
const system = filtered.filter((c) => c.scope === "system");
// Group-scoped categories grouped by group_id
const groupCats = filtered.filter((c) => c.scope === "group");
const groupMap = new Map<string, { name: string; cats: CategoryOut[] }>();
for (const cat of groupCats) {
if (!cat.group_id) continue;
if (!groupMap.has(cat.group_id)) {
const grp = myGroups.find((g) => g.id === cat.group_id);
groupMap.set(cat.group_id, { name: grp?.name ?? cat.group_id, cats: [] });
}
groupMap.get(cat.group_id)!.cats.push(cat);
}
function startEdit(id: string, name: string) {
setEditingId(id);
setEditValue(name);
setEditError(null);
}
function submitEdit(id: string) {
const name = editValue.trim();
if (name && name !== categories.find((c) => c.id === id)?.name) {
renameMut.mutate({ id, name });
} else {
if (!name) return;
if (name === categories.find((c) => c.id === id)?.name) {
setEditingId(null);
return;
}
renameMut.mutate({ id, name });
}
function renderCat(cat: CategoryOut) {
const manageable = canManage(cat);
return (
<div
key={cat.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-muted/10 group"
>
{editingId === cat.id ? (
<div className="flex flex-col flex-1 gap-1">
<form
className="flex items-center gap-2"
onSubmit={(e) => { e.preventDefault(); submitEdit(cat.id); }}
>
<Input
ref={editInputRef}
value={editValue}
onChange={(e) => { setEditValue(e.target.value); setEditError(null); }}
className="h-7 text-sm flex-1"
disabled={renameMut.isPending}
onKeyDown={(e) => { if (e.key === "Escape") { setEditingId(null); setEditError(null); } }}
/>
<button
type="submit"
disabled={!editValue.trim() || renameMut.isPending}
className="text-primary hover:text-primary/80 disabled:opacity-50"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => { setEditingId(null); setEditError(null); }}
className="text-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</form>
{editError && (
<p className="text-xs text-red-500 pl-0.5">{editError}</p>
)}
</div>
) : (
<>
{!manageable && (
<Lock className="h-3.5 w-3.5 text-muted flex-shrink-0" />
)}
<span className="flex-1 text-sm truncate">{cat.name}</span>
{manageable && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => startEdit(cat.id, cat.name)}
className="text-muted hover:text-foreground transition-colors p-0.5"
title="Rename"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => {
if (confirm(`Delete category "${cat.name}"? Documents in it will be uncategorised.`)) {
deleteMut.mutate(cat.id);
}
}}
disabled={deleteMut.isPending}
className="text-muted hover:text-red-500 transition-colors p-0.5 disabled:opacity-50"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</>
)}
</div>
);
}
function renderSection(title: string, cats: CategoryOut[]) {
if (cats.length === 0) return null;
return (
<div className="mb-3">
<p className="text-xs font-semibold text-muted uppercase tracking-wider px-2 mb-1">{title}</p>
{cats.map(renderCat)}
</div>
);
}
const hasAny = filtered.length > 0;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/70"
@@ -83,73 +215,17 @@ export default function ManageCategoriesDialog({ onClose }: Props) {
)}
{/* List */}
<div className="flex-1 overflow-y-auto px-5 py-3 space-y-1 min-h-0">
{filtered.length === 0 && (
<div className="flex-1 overflow-y-auto px-5 py-3 min-h-0">
{!hasAny && (
<p className="text-sm text-muted py-4 text-center">
{search ? "No categories match" : "No categories yet"}
</p>
)}
{filtered.map((cat) => (
<div
key={cat.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-muted/10 group"
>
{editingId === cat.id ? (
<form
className="flex items-center gap-2 flex-1"
onSubmit={(e) => { e.preventDefault(); submitEdit(cat.id); }}
>
<Input
ref={editInputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
className="h-7 text-sm flex-1"
disabled={renameMut.isPending}
onKeyDown={(e) => { if (e.key === "Escape") setEditingId(null); }}
/>
<button
type="submit"
disabled={!editValue.trim() || renameMut.isPending}
className="text-primary hover:text-primary/80 disabled:opacity-50"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setEditingId(null)}
className="text-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</form>
) : (
<>
<span className="flex-1 text-sm truncate">{cat.name}</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => startEdit(cat.id, cat.name)}
className="text-muted hover:text-foreground transition-colors p-0.5"
title="Rename"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => {
if (confirm(`Delete category "${cat.name}"? Documents in it will be uncategorised.`)) {
deleteMut.mutate(cat.id);
}
}}
disabled={deleteMut.isPending}
className="text-muted hover:text-red-500 transition-colors p-0.5 disabled:opacity-50"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</>
)}
</div>
))}
{renderSection("My Categories", personal)}
{Array.from(groupMap.entries()).map(([, { name, cats }]) =>
renderSection(`Group: ${name}`, cats)
)}
{renderSection("System", system)}
</div>
{/* Footer */}
+130 -43
View File
@@ -2,11 +2,20 @@ import { useState, useRef, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Files, User, Users, Folder, Plus, Settings2, Check, X } from "lucide-react";
import { listCategories, createCategory, CategoryOut } from "@/api/client";
import {
listCategories,
createCategory,
getMyGroups,
type CategoryOut,
ApiError,
} from "@/api/client";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import ManageCategoriesDialog from "@/components/ManageCategoriesDialog";
// PascalCase-with-dashes naming convention
const NAME_RE = /^[A-Z][a-zA-Z0-9]*(-[A-Z][a-zA-Z0-9]*)*$/;
export default function SourcePanel() {
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
@@ -16,6 +25,8 @@ export default function SourcePanel() {
const [catSearch, setCatSearch] = useState("");
const [addingCat, setAddingCat] = useState(false);
const [newCatName, setNewCatName] = useState("");
const [newCatGroupId, setNewCatGroupId] = useState<string>("");
const [nameError, setNameError] = useState<string | null>(null);
const [manageOpen, setManageOpen] = useState(false);
const addInputRef = useRef<HTMLInputElement>(null);
@@ -24,12 +35,23 @@ export default function SourcePanel() {
queryFn: listCategories,
});
const { data: myGroups = [] } = useQuery({
queryKey: ["my-groups"],
queryFn: getMyGroups,
});
const createMut = useMutation({
mutationFn: createCategory,
mutationFn: ({ name, groupId }: { name: string; groupId?: string }) =>
createCategory(name, groupId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
setNewCatName("");
setNewCatGroupId("");
setAddingCat(false);
setNameError(null);
},
onError: (err) => {
setNameError(err instanceof ApiError ? err.message : "Failed to create category");
},
});
@@ -41,6 +63,19 @@ export default function SourcePanel() {
? categories.filter((c) => c.name.toLowerCase().includes(catSearch.toLowerCase()))
: categories;
// Split by scope for display
const personalCats = filteredCats.filter((c) => c.scope === "personal");
const systemCats = filteredCats.filter((c) => c.scope === "system");
const groupCatMap = new Map<string, { name: string; cats: CategoryOut[] }>();
for (const cat of filteredCats.filter((c) => c.scope === "group")) {
if (!cat.group_id) continue;
if (!groupCatMap.has(cat.group_id)) {
const grp = myGroups.find((g) => g.id === cat.group_id);
groupCatMap.set(cat.group_id, { name: grp?.name ?? cat.group_id, cats: [] });
}
groupCatMap.get(cat.group_id)!.cats.push(cat);
}
function setView(view: string) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
@@ -61,6 +96,20 @@ export default function SourcePanel() {
});
}
function handleAddSubmit(e: React.FormEvent) {
e.preventDefault();
const name = newCatName.trim();
if (!name) return;
if (!NAME_RE.test(name)) {
setNameError(
"Must start with a capital letter. Join multiple words with dashes, each capitalised (e.g. Vendor-Invoices)."
);
return;
}
setNameError(null);
createMut.mutate({ name, groupId: newCatGroupId || undefined });
}
const viewItemClass = (active: boolean) =>
cn(
"flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors",
@@ -77,6 +126,32 @@ export default function SourcePanel() {
: "text-muted hover:bg-muted/20 hover:text-foreground"
);
function renderCatSection(label: string, cats: CategoryOut[]) {
if (cats.length === 0) return null;
return (
<div className="mb-1">
<p className="text-[10px] font-semibold text-muted uppercase tracking-wider px-2 pt-1.5 pb-0.5">
{label}
</p>
{cats.map((cat) => (
<button
key={cat.id}
className={catItemClass(currentCategoryId === cat.id)}
onClick={() => selectCategory(cat)}
>
<Folder className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{cat.name}</span>
</button>
))}
</div>
);
}
const hasSections =
personalCats.length > 0 ||
systemCats.length > 0 ||
groupCatMap.size > 0;
return (
<>
<aside className="w-56 flex flex-col border-r border-border bg-surface shrink-0 h-screen">
@@ -132,18 +207,13 @@ export default function SourcePanel() {
/>
)}
<div className="flex-1 overflow-y-auto space-y-0.5 min-h-0">
{filteredCats.map((cat) => (
<button
key={cat.id}
className={catItemClass(currentCategoryId === cat.id)}
onClick={() => selectCategory(cat)}
>
<Folder className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{cat.name}</span>
</button>
))}
{filteredCats.length === 0 && catSearch && (
<div className="flex-1 overflow-y-auto min-h-0">
{renderCatSection("Mine", personalCats)}
{Array.from(groupCatMap.entries()).map(([, { name, cats }]) =>
renderCatSection(name, cats)
)}
{renderCatSection("System", systemCats)}
{!hasSections && catSearch && (
<p className="text-xs text-muted px-2 py-1">No categories match</p>
)}
{categories.length === 0 && !catSearch && (
@@ -154,35 +224,52 @@ export default function SourcePanel() {
{/* Add new category */}
<div className="pt-2 border-t border-border mt-2">
{addingCat ? (
<form
onSubmit={(e) => {
e.preventDefault();
if (newCatName.trim()) createMut.mutate(newCatName.trim());
}}
className="flex items-center gap-1"
>
<Input
ref={addInputRef}
value={newCatName}
onChange={(e) => setNewCatName(e.target.value)}
placeholder="Category name"
className="h-7 text-xs flex-1"
disabled={createMut.isPending}
/>
<button
type="submit"
disabled={!newCatName.trim() || createMut.isPending}
className="text-primary hover:text-primary/80 disabled:opacity-50"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => { setAddingCat(false); setNewCatName(""); }}
className="text-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
<form onSubmit={handleAddSubmit} className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<Input
ref={addInputRef}
value={newCatName}
onChange={(e) => { setNewCatName(e.target.value); setNameError(null); }}
placeholder="Vendor-Invoices"
className="h-7 text-xs flex-1"
disabled={createMut.isPending}
/>
<button
type="submit"
disabled={!newCatName.trim() || createMut.isPending}
className="text-primary hover:text-primary/80 disabled:opacity-50"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => {
setAddingCat(false);
setNewCatName("");
setNewCatGroupId("");
setNameError(null);
}}
className="text-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{myGroups.length > 0 && (
<select
value={newCatGroupId}
onChange={(e) => setNewCatGroupId(e.target.value)}
className="h-7 text-xs rounded border border-border bg-surface text-foreground px-1"
disabled={createMut.isPending}
>
<option value="">Personal</option>
{myGroups.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
)}
{nameError && (
<p className="text-[10px] text-red-500 leading-tight">{nameError}</p>
)}
</form>
) : (
<button
+19
View File
@@ -8,6 +8,7 @@ import {
adminUpdateGroup,
adminAddGroupMember,
adminRemoveGroupMember,
adminSetGroupMemberAdmin,
adminGetUsers,
type GroupOut,
type GroupCreate,
@@ -249,6 +250,14 @@ function GroupMembersPanel({ groupId }: { groupId: string }) {
},
});
const adminMutation = useMutation({
mutationFn: ({ uId, isAdmin }: { uId: string; isAdmin: boolean }) =>
adminSetGroupMemberAdmin(groupId, uId, isAdmin),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-group", groupId] });
},
});
if (isLoading) return <p style={{ padding: "8px 16px" }}>Loading members</p>;
if (!group) return null;
@@ -268,6 +277,7 @@ function GroupMembersPanel({ groupId }: { groupId: string }) {
<th style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>Email</th>
<th style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>Name</th>
<th style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>Status</th>
<th style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>Group Admin</th>
<th style={{ padding: "4px 0", fontSize: 13 }}></th>
</tr>
</thead>
@@ -279,6 +289,15 @@ function GroupMembersPanel({ groupId }: { groupId: string }) {
<td style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>
{m.is_active ? "Active" : "Inactive"}
</td>
<td style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>
<input
type="checkbox"
checked={m.is_group_admin}
disabled={adminMutation.isPending}
onChange={() => adminMutation.mutate({ uId: m.id, isAdmin: !m.is_group_admin })}
title={m.is_group_admin ? "Remove group admin" : "Make group admin"}
/>
</td>
<td style={{ padding: "4px 0" }}>
<button
style={{ fontSize: 12, color: "red" }}