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
+14 -7
View File
@@ -50,21 +50,28 @@ _HOP_BY_HOP = frozenset([
_STRIP_RESPONSE = frozenset([*_HOP_BY_HOP, "content-length", "content-type"])
async def _forward_headers(request: Request, user_id: str, db: AsyncSession) -> dict:
async def _forward_headers(
request: Request, user_id: str, is_admin: bool, db: AsyncSession
) -> dict:
headers = {
k: v
for k, v in request.headers.items()
if k.lower() not in _HOP_BY_HOP
}
headers["x-user-id"] = user_id
headers["x-user-is-admin"] = "true" if is_admin else "false"
# Inject the user's group memberships so the doc-service can evaluate
# group-shared document access without querying the backend DB.
result = await db.execute(
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
# Inject group memberships and group-admin status so the doc-service can
# evaluate ownership, sharing access, and group-admin permissions.
mem_result = await db.execute(
select(GroupMembership.group_id, GroupMembership.is_group_admin)
.where(GroupMembership.user_id == user_id)
)
group_ids = [row[0] for row in result.all()]
rows = mem_result.all()
group_ids = [row[0] for row in rows]
admin_group_ids = [row[0] for row in rows if row[1]]
headers["x-user-groups"] = ",".join(group_ids)
headers["x-user-admin-groups"] = ",".join(admin_group_ids)
return headers
@@ -78,7 +85,7 @@ async def proxy_documents(
path: str = "",
) -> Response:
url = f"/documents/{path}" if path else "/documents"
headers = await _forward_headers(request, str(current_user.id), db)
headers = await _forward_headers(request, str(current_user.id), current_user.is_superuser, db)
body = await request.body()
try: