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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user