Add Groups management and split Admin navigation
- New backend: Group + GroupMembership models, schemas, CRUD router at /api/admin/groups (list, create, get detail, update, delete, add/remove members) - New Alembic migration: groups and group_memberships tables - Frontend: Admin sidebar item is now an expandable accordion with Users and Groups sub-items; AdminPage redirects to /admin/users; new AdminUsersPage and AdminGroupsPage with inline member management panel - API client: 7 new group functions + TypeScript types Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+18
-2
@@ -40,6 +40,18 @@ JWT signing uses a 4096-bit RSA key pair (`RS256`). Keys are generated by `scrip
|
||||
| `GET` | `/api/admin/users` | List all users (admin only) |
|
||||
| `PATCH` | `/api/admin/users/{id}` | Update user (role, active flag) |
|
||||
|
||||
### Groups (`/api/admin/groups`)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/api/admin/groups` | List all groups with member count |
|
||||
| `POST` | `/api/admin/groups` | Create a new group |
|
||||
| `GET` | `/api/admin/groups/{id}` | Get group detail with member list |
|
||||
| `PATCH` | `/api/admin/groups/{id}` | Update group name / description |
|
||||
| `DELETE` | `/api/admin/groups/{id}` | Delete group (cascades memberships) |
|
||||
| `POST` | `/api/admin/groups/{id}/members/{user_id}` | Add user to group |
|
||||
| `DELETE` | `/api/admin/groups/{id}/members/{user_id}` | Remove user from group |
|
||||
|
||||
### Services (`/api/services`)
|
||||
|
||||
| Method | Path | Description |
|
||||
@@ -74,6 +86,8 @@ All `/api/documents/*` and `/api/documents/categories/*` requests are transparen
|
||||
|-------|-------|-------|
|
||||
| `User` | `users` | email, hashed_password, role (`user`\|`admin`), is_active |
|
||||
| `Profile` | `profiles` | one-to-one with User; full_name, phone, etc. |
|
||||
| `Group` | `groups` | name (unique), description, created_at |
|
||||
| `GroupMembership` | `group_memberships` | group_id + user_id (unique pair); joined_at |
|
||||
|
||||
Alembic migrations in `backend/alembic/versions/` — version table: `alembic_version`.
|
||||
|
||||
@@ -112,7 +126,7 @@ Browser (port 5173 dev / 80 prod)
|
||||
- **No refresh tokens** — 8h hard expiry; adding refresh requires `httpOnly` cookie + rotation
|
||||
- **No `httpOnly` cookie** — JWT in `localStorage` is XSS-exposed
|
||||
- **App permissions** — no per-user, per-app access control. Currently all authenticated users can use all apps. Planned: `user_app_permissions` table, admin UI to grant/revoke
|
||||
- **Groups / sharing** — no group model yet; blocks document sharing in doc-service
|
||||
- **Groups / sharing** — groups + memberships exist; app permission hooks not yet wired up
|
||||
- **Email verification** — accounts are active immediately after registration
|
||||
- **Password reset** — no flow implemented
|
||||
|
||||
@@ -120,7 +134,9 @@ Browser (port 5173 dev / 80 prod)
|
||||
|
||||
## Future work
|
||||
|
||||
- [ ] Groups + permissions system: `groups`, `group_memberships`, `group_app_permissions` tables; admin CRUD; doc sharing via group membership
|
||||
- [x] Groups system: `groups`, `group_memberships` tables; admin CRUD; add/remove members
|
||||
- [ ] App permissions registry: `group_app_permissions` table; AppsPage filtered by group grants
|
||||
- [ ] Doc sharing via group membership
|
||||
- [ ] App permissions registry: `user_app_permissions (user_id, app_key)`; AppsPage filtered by grants
|
||||
- [ ] `httpOnly` cookie migration for JWT
|
||||
- [ ] Refresh token flow (paired with cookie migration)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""add groups and group_memberships tables
|
||||
|
||||
Revision ID: a3f9c2d14e87
|
||||
Revises: 676084df61d1
|
||||
Create Date: 2026-04-17 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'a3f9c2d14e87'
|
||||
down_revision: Union[str, None] = '676084df61d1'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'groups',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(length=128), nullable=False),
|
||||
sa.Column('description', sa.String(length=512), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index(op.f('ix_groups_name'), 'groups', ['name'], unique=True)
|
||||
|
||||
op.create_table(
|
||||
'group_memberships',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('group_id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=False),
|
||||
sa.Column('joined_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('group_id', 'user_id', name='uq_group_user'),
|
||||
)
|
||||
op.create_index(op.f('ix_group_memberships_group_id'), 'group_memberships', ['group_id'], unique=False)
|
||||
op.create_index(op.f('ix_group_memberships_user_id'), 'group_memberships', ['user_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_group_memberships_user_id'), table_name='group_memberships')
|
||||
op.drop_index(op.f('ix_group_memberships_group_id'), table_name='group_memberships')
|
||||
op.drop_table('group_memberships')
|
||||
op.drop_index(op.f('ix_groups_name'), table_name='groups')
|
||||
op.drop_table('groups')
|
||||
+2
-1
@@ -5,7 +5,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.config import settings
|
||||
from app.routers import admin, auth, categories_proxy, documents_proxy, profile, services, users
|
||||
from app.routers import admin, auth, categories_proxy, documents_proxy, groups, profile, services, users
|
||||
from app.routers import settings as settings_router
|
||||
from app.services.service_health import check_all, health_check_loop, register_services
|
||||
|
||||
@@ -41,6 +41,7 @@ app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
||||
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
||||
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
|
||||
app.include_router(groups.router, prefix="/api/admin/groups", tags=["admin"])
|
||||
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
|
||||
app.include_router(services.router, prefix="/api/services", tags=["services"])
|
||||
# categories_proxy MUST be registered before documents_proxy —
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from app.models.group import Group, GroupMembership
|
||||
from app.models.profile import Profile
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = ["User", "Profile"]
|
||||
__all__ = ["User", "Profile", "Group", "GroupMembership"]
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Group(Base):
|
||||
__tablename__ = "groups"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||
description: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
memberships: Mapped[list["GroupMembership"]] = relationship(
|
||||
"GroupMembership", back_populates="group", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class GroupMembership(Base):
|
||||
__tablename__ = "group_memberships"
|
||||
__table_args__ = (UniqueConstraint("group_id", "user_id", name="uq_group_user"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
group_id: Mapped[str] = mapped_column(
|
||||
String, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
joined_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
group: Mapped["Group"] = relationship("Group", back_populates="memberships")
|
||||
@@ -0,0 +1,216 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
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
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_group_out(group: Group) -> GroupOut:
|
||||
return GroupOut(
|
||||
id=group.id,
|
||||
name=group.name,
|
||||
description=group.description,
|
||||
created_at=group.created_at,
|
||||
member_count=len(group.memberships),
|
||||
)
|
||||
|
||||
|
||||
def _to_group_detail(group: Group) -> GroupDetailOut:
|
||||
members = []
|
||||
for m in group.memberships:
|
||||
# memberships are loaded with joined user via selectinload
|
||||
user = m.__dict__.get("_user") or getattr(m, "user", None)
|
||||
if user is None:
|
||||
continue
|
||||
members.append(GroupMemberOut(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
joined_at=m.joined_at,
|
||||
))
|
||||
return GroupDetailOut(
|
||||
id=group.id,
|
||||
name=group.name,
|
||||
description=group.description,
|
||||
created_at=group.created_at,
|
||||
member_count=len(members),
|
||||
members=members,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[GroupOut])
|
||||
async def list_groups(
|
||||
_admin: User = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[GroupOut]:
|
||||
result = await db.execute(
|
||||
select(Group).options(selectinload(Group.memberships)).order_by(Group.name)
|
||||
)
|
||||
groups = list(result.scalars().all())
|
||||
return [_to_group_out(g) for g in groups]
|
||||
|
||||
|
||||
@router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_group(
|
||||
body: GroupCreate,
|
||||
_admin: User = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> GroupOut:
|
||||
existing = await db.execute(select(Group).where(Group.name == body.name))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="A group with that name already exists")
|
||||
|
||||
group = Group(name=body.name, description=body.description)
|
||||
db.add(group)
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
# refresh doesn't load relationships — load fresh with memberships
|
||||
result = await db.execute(
|
||||
select(Group).options(selectinload(Group.memberships)).where(Group.id == group.id)
|
||||
)
|
||||
group = result.scalar_one()
|
||||
return _to_group_out(group)
|
||||
|
||||
|
||||
@router.get("/{group_id}", response_model=GroupDetailOut)
|
||||
async def get_group(
|
||||
group_id: str,
|
||||
_admin: User = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> GroupDetailOut:
|
||||
result = await db.execute(
|
||||
select(Group)
|
||||
.options(
|
||||
selectinload(Group.memberships).joinedload(GroupMembership.group),
|
||||
)
|
||||
.where(Group.id == group_id)
|
||||
)
|
||||
group = result.scalar_one_or_none()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
|
||||
# Load members separately for simplicity
|
||||
mem_result = await db.execute(
|
||||
select(GroupMembership, User)
|
||||
.join(User, User.id == GroupMembership.user_id)
|
||||
.where(GroupMembership.group_id == group_id)
|
||||
.order_by(User.email)
|
||||
)
|
||||
rows = mem_result.all()
|
||||
members = [
|
||||
GroupMemberOut(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
joined_at=membership.joined_at,
|
||||
)
|
||||
for membership, user in rows
|
||||
]
|
||||
return GroupDetailOut(
|
||||
id=group.id,
|
||||
name=group.name,
|
||||
description=group.description,
|
||||
created_at=group.created_at,
|
||||
member_count=len(members),
|
||||
members=members,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{group_id}", response_model=GroupOut)
|
||||
async def update_group(
|
||||
group_id: str,
|
||||
body: GroupUpdate,
|
||||
_admin: User = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> GroupOut:
|
||||
result = await db.execute(
|
||||
select(Group).options(selectinload(Group.memberships)).where(Group.id == group_id)
|
||||
)
|
||||
group = result.scalar_one_or_none()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
|
||||
if body.name is not None and body.name != group.name:
|
||||
dupe = await db.execute(select(Group).where(Group.name == body.name))
|
||||
if dupe.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="A group with that name already exists")
|
||||
group.name = body.name
|
||||
if body.description is not None:
|
||||
group.description = body.description
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
result2 = await db.execute(
|
||||
select(Group).options(selectinload(Group.memberships)).where(Group.id == group.id)
|
||||
)
|
||||
group = result2.scalar_one()
|
||||
return _to_group_out(group)
|
||||
|
||||
|
||||
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_group(
|
||||
group_id: str,
|
||||
_admin: User = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
result = await db.execute(select(Group).where(Group.id == group_id))
|
||||
group = result.scalar_one_or_none()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
await db.delete(group)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/{group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def add_member(
|
||||
group_id: str,
|
||||
user_id: str,
|
||||
_admin: User = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
group_result = await db.execute(select(Group).where(Group.id == group_id))
|
||||
if not group_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
|
||||
user_result = await db.execute(select(User).where(User.id == user_id))
|
||||
if not user_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
existing = await db.execute(
|
||||
select(GroupMembership).where(
|
||||
GroupMembership.group_id == group_id, GroupMembership.user_id == user_id
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="User is already a member of this group")
|
||||
|
||||
db.add(GroupMembership(group_id=group_id, user_id=user_id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.delete("/{group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_member(
|
||||
group_id: str,
|
||||
user_id: str,
|
||||
_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")
|
||||
await db.delete(membership)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class GroupCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=128)
|
||||
description: str | None = Field(None, max_length=512)
|
||||
|
||||
|
||||
class GroupUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=128)
|
||||
description: str | None = Field(None, max_length=512)
|
||||
|
||||
|
||||
class GroupMemberOut(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
full_name: str | None
|
||||
is_active: bool
|
||||
joined_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class GroupOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str | None
|
||||
created_at: datetime
|
||||
member_count: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class GroupDetailOut(GroupOut):
|
||||
members: list[GroupMemberOut] = []
|
||||
Reference in New Issue
Block a user