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:
@@ -39,18 +39,26 @@ _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
|
||||
result = await db.execute(
|
||||
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
|
||||
headers["x-user-is-admin"] = "true" if is_admin else "false"
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -63,7 +71,7 @@ async def proxy_categories(
|
||||
path: str = "",
|
||||
) -> Response:
|
||||
url = f"/categories/{path}" if path else "/categories"
|
||||
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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -7,7 +7,7 @@ from app.database import get_db
|
||||
from app.deps import get_current_admin
|
||||
from app.models.group import Group, GroupMembership
|
||||
from app.models.user import User
|
||||
from app.schemas.group import GroupCreate, GroupDetailOut, GroupOut, GroupUpdate, GroupMemberOut
|
||||
from app.schemas.group import GroupCreate, GroupDetailOut, GroupMemberAdminUpdate, GroupMemberOut, GroupOut, GroupUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -111,6 +111,7 @@ async def get_group(
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
is_group_admin=membership.is_group_admin,
|
||||
joined_at=membership.joined_at,
|
||||
)
|
||||
for membership, user in rows
|
||||
@@ -197,6 +198,26 @@ async def add_member(
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.patch("/{group_id}/members/{user_id}/admin", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def set_member_admin(
|
||||
group_id: str,
|
||||
user_id: str,
|
||||
body: GroupMemberAdminUpdate,
|
||||
_admin: User = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
result = await db.execute(
|
||||
select(GroupMembership).where(
|
||||
GroupMembership.group_id == group_id, GroupMembership.user_id == user_id
|
||||
)
|
||||
)
|
||||
membership = result.scalar_one_or_none()
|
||||
if not membership:
|
||||
raise HTTPException(status_code=404, detail="User is not a member of this group")
|
||||
membership.is_group_admin = body.is_group_admin
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.delete("/{group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_member(
|
||||
group_id: str,
|
||||
|
||||
@@ -39,14 +39,17 @@ async def get_my_groups(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return all groups the current user belongs to."""
|
||||
"""Return all groups the current user belongs to, including their admin status."""
|
||||
result = await db.execute(
|
||||
select(Group)
|
||||
select(Group, GroupMembership.is_group_admin)
|
||||
.join(GroupMembership, GroupMembership.group_id == Group.id)
|
||||
.where(GroupMembership.user_id == current_user.id)
|
||||
.order_by(Group.name)
|
||||
)
|
||||
return result.scalars().all()
|
||||
return [
|
||||
UserGroupOut(id=g.id, name=g.name, description=g.description, is_group_admin=is_admin)
|
||||
for g, is_admin in result.all()
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/me/color-mode", response_model=UserOut)
|
||||
|
||||
Reference in New Issue
Block a user