diff --git a/backend/STATUS.md b/backend/STATUS.md index 2de3705..2d3cf18 100644 --- a/backend/STATUS.md +++ b/backend/STATUS.md @@ -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) diff --git a/backend/alembic/versions/a3f9c2d14e87_add_groups_and_group_memberships.py b/backend/alembic/versions/a3f9c2d14e87_add_groups_and_group_memberships.py new file mode 100644 index 0000000..a87b5dd --- /dev/null +++ b/backend/alembic/versions/a3f9c2d14e87_add_groups_and_group_memberships.py @@ -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') diff --git a/backend/app/main.py b/backend/app/main.py index c6be537..176a1cd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 — diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index de2ccf4..3eff199 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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"] diff --git a/backend/app/models/group.py b/backend/app/models/group.py new file mode 100644 index 0000000..c208b2a --- /dev/null +++ b/backend/app/models/group.py @@ -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") diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py new file mode 100644 index 0000000..a1cd774 --- /dev/null +++ b/backend/app/routers/groups.py @@ -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() diff --git a/backend/app/schemas/group.py b/backend/app/schemas/group.py new file mode 100644 index 0000000..bbb06d5 --- /dev/null +++ b/backend/app/schemas/group.py @@ -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] = [] diff --git a/changelog/2026-04-17_groups-and-admin-nav.md b/changelog/2026-04-17_groups-and-admin-nav.md new file mode 100644 index 0000000..1dc5c71 --- /dev/null +++ b/changelog/2026-04-17_groups-and-admin-nav.md @@ -0,0 +1,27 @@ +# 2026-04-17 — Groups management and Admin navigation split + +**Timestamp:** 2026-04-17T12:00:00Z + +## Summary + +Added a Groups system (backend models, API, migration) and split the Admin sidebar item into an expandable accordion with "Users" and "Groups" sub-navigation points. + +## Files Added / Modified / Deleted + +### Added +- `backend/app/models/group.py` — `Group` and `GroupMembership` SQLAlchemy models +- `backend/app/schemas/group.py` — Pydantic schemas: `GroupCreate`, `GroupUpdate`, `GroupOut`, `GroupDetailOut`, `GroupMemberOut` +- `backend/app/routers/groups.py` — Admin CRUD endpoints for groups + member add/remove +- `backend/alembic/versions/a3f9c2d14e87_add_groups_and_group_memberships.py` — Migration creating `groups` and `group_memberships` tables +- `frontend/src/pages/AdminUsersPage.tsx` — User management page (extracted from AdminPage) +- `frontend/src/pages/AdminGroupsPage.tsx` — Group management page with inline member panel + +### Modified +- `backend/app/models/__init__.py` — Import `Group` and `GroupMembership` +- `backend/app/main.py` — Mount `/api/admin/groups` router +- `frontend/src/api/client.ts` — Added 7 group API functions and TypeScript types +- `frontend/src/pages/AdminPage.tsx` — Now a simple redirect to `/admin/users` +- `frontend/src/App.tsx` — Added routes `/admin/users` and `/admin/groups` +- `frontend/src/components/Sidebar.tsx` — Admin item is now an expandable accordion with Users and Groups sub-items +- `backend/STATUS.md` — Documented groups endpoints, models, updated future work +- `frontend/STATUS.md` — Documented new routes, pages, API client functions diff --git a/frontend/STATUS.md b/frontend/STATUS.md index 1073f32..73a437a 100644 --- a/frontend/STATUS.md +++ b/frontend/STATUS.md @@ -18,7 +18,9 @@ All API calls go through `src/api/client.ts` (single Axios instance, JWT injecte | `/apps/documents` | `DocumentsPage` | Required | | `/apps/documents/settings/admin` | `DocumentAdminSettingsPage` | Admin only | | `/apps/ai/settings/admin` | `AIAdminSettingsPage` | Admin only | -| `/admin` | `AdminPage` | Admin only | +| `/admin` | `AdminPage` (redirects to `/admin/users`) | Admin only | +| `/admin/users` | `AdminUsersPage` | Admin only | +| `/admin/groups` | `AdminGroupsPage` | Admin only | | `/profile` | `ProfilePage` | Required | `PrivateRoute` redirects to `/login` when no token. `AdminRoute` redirects to `/` when not admin. @@ -90,10 +92,20 @@ Cards are rendered dynamically from `GET /api/services` (polled every 30 s via T - Upload Limits section only (max PDF size in MB) - Save button -### Admin page (`/admin`) +### Admin — Users page (`/admin/users`) - User list with role and active status -- Inline role/status editing +- Inline active status toggle +- Create user form (email, name, password, admin flag) +- Delete user + +### Admin — Groups page (`/admin/groups`) + +- Group list with name, description, member count +- Create group (name, optional description) +- Edit group name / description inline panel +- Delete group (with confirmation) +- Expand group row to manage members: view members, remove members, add non-members from dropdown ### Profile page (`/profile`) @@ -123,6 +135,13 @@ Key functions: | `updateAISettings(data)` | `PATCH /settings/ai` | | `testAIConnection()` | `POST /settings/ai/test` | | `getDocumentLimits()` | `GET /settings/documents/limits` | +| `adminListGroups()` | `GET /admin/groups` | +| `adminCreateGroup(data)` | `POST /admin/groups` | +| `adminGetGroup(id)` | `GET /admin/groups/{id}` with members | +| `adminUpdateGroup(id, data)` | `PATCH /admin/groups/{id}` | +| `adminDeleteGroup(id)` | `DELETE /admin/groups/{id}` | +| `adminAddGroupMember(gId, uId)` | `POST /admin/groups/{gId}/members/{uId}` | +| `adminRemoveGroupMember(gId, uId)` | `DELETE /admin/groups/{gId}/members/{uId}` | | `updateDocumentLimits(data)` | `PATCH /settings/documents/limits` | --- @@ -150,7 +169,7 @@ Key functions: - **JWT in `localStorage`** — XSS risk; migrate to `httpOnly` cookie when backend supports it - **No toast / notification system** — errors shown inline; success is silent - **No loading skeletons** — "Loading…" text only -- **No group/sharing UI** — blocked on backend groups system +- **No app permission UI** per group — groups exist but permission grants are not yet implemented - **No app permission UI** — all apps visible to all authenticated users --- @@ -165,7 +184,8 @@ Key functions: - [ ] `POST /queue/jobs` integration — show AI processing queue status / progress per document - [ ] Re-process document button (`POST /documents/{id}/reprocess` — needs backend endpoint first) - [ ] Advanced filter: extracted data fields (vendor, due date, amount) — needs backend support -- [ ] Groups + document sharing UI — blocked on backend -- [ ] App permissions UI in Admin page +- [x] Groups admin UI — list, create, edit, delete, add/remove members +- [ ] App permissions UI per group (blocked on backend group_app_permissions) +- [ ] Document sharing UI (blocked on backend) - [ ] `httpOnly` cookie auth (requires backend change) - [ ] Bulk document operations (select multiple, bulk delete / bulk categorise) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 45a77c4..419a760 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,8 @@ import DashboardPage from "./pages/DashboardPage"; import ProfilePage from "./pages/ProfilePage"; import AppsPage from "./pages/AppsPage"; import AdminPage from "./pages/AdminPage"; +import AdminUsersPage from "./pages/AdminUsersPage"; +import AdminGroupsPage from "./pages/AdminGroupsPage"; import DocumentsPage from "./pages/DocumentsPage"; import DocumentAdminSettingsPage from "./pages/DocumentAdminSettingsPage"; import AIAdminSettingsPage from "./pages/AIAdminSettingsPage"; @@ -51,6 +53,8 @@ export default function App() { /> } /> } /> + } /> + } /> {/* Catch-all */} } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e90254e..58fc133 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -224,6 +224,58 @@ export const updateDocumentLimits = (max_pdf_mb: number) => export const getDocumentLimits = () => api.get>("/settings/documents/limits").then((r) => r.data); +// --- Groups (admin only) --- +export interface GroupOut { + id: string; + name: string; + description: string | null; + created_at: string; + member_count: number; +} + +export interface GroupMemberOut { + id: string; + email: string; + full_name: string | null; + is_active: boolean; + joined_at: string; +} + +export interface GroupDetailOut extends GroupOut { + members: GroupMemberOut[]; +} + +export interface GroupCreate { + name: string; + description?: string | null; +} + +export interface GroupUpdate { + name?: string; + description?: string | null; +} + +export const adminListGroups = () => + api.get("/admin/groups").then((r) => r.data); + +export const adminCreateGroup = (data: GroupCreate) => + api.post("/admin/groups", data).then((r) => r.data); + +export const adminGetGroup = (groupId: string) => + api.get(`/admin/groups/${groupId}`).then((r) => r.data); + +export const adminUpdateGroup = (groupId: string, data: GroupUpdate) => + api.patch(`/admin/groups/${groupId}`, data).then((r) => r.data); + +export const adminDeleteGroup = (groupId: string) => + api.delete(`/admin/groups/${groupId}`); + +export const adminAddGroupMember = (groupId: string, userId: string) => + api.post(`/admin/groups/${groupId}/members/${userId}`); + +export const adminRemoveGroupMember = (groupId: string, userId: string) => + api.delete(`/admin/groups/${groupId}/members/${userId}`); + // --- Services --- export interface ServiceStatus { id: string; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 0763835..6f1ddfc 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -13,6 +13,8 @@ import { UserCircle, FileText, Folder, + Users, + UsersRound, } from "lucide-react"; import { Button } from "@/components/ui/button"; import ThemeToggle from "@/components/ThemeToggle"; @@ -28,9 +30,11 @@ export default function Sidebar() { const isAppsRoute = location.pathname.startsWith("/apps"); const isDocsRoute = location.pathname.startsWith("/apps/documents"); + const isAdminRoute = location.pathname.startsWith("/admin"); const [appsOpen, setAppsOpen] = useState(isAppsRoute); const [docsOpen, setDocsOpen] = useState(isDocsRoute); + const [adminOpen, setAdminOpen] = useState(isAdminRoute); // Auto-open sections when navigating to their routes useEffect(() => { @@ -41,6 +45,10 @@ export default function Sidebar() { if (isDocsRoute) setDocsOpen(true); }, [isDocsRoute]); + useEffect(() => { + if (isAdminRoute) setAdminOpen(true); + }, [isAdminRoute]); + const { data: categories = [] } = useQuery({ queryKey: ["categories"], queryFn: listCategories, @@ -200,17 +208,66 @@ export default function Sidebar() { )} - {/* Admin */} + {/* Admin — expandable */} {user?.is_admin && ( - navItemClass(isActive)} - > - - {sidebarExpanded && ( - Admin +
+ {sidebarExpanded ? ( +
+ + + Admin + + +
+ ) : ( + navItemClass(isActive)} + > + + )} - + + {/* Admin sub-items */} + {sidebarExpanded && adminOpen && ( +
+ subItemClass(isActive)} + > + + Users + + subItemClass(isActive)} + > + + Groups + +
+ )} +
)} diff --git a/frontend/src/pages/AdminGroupsPage.tsx b/frontend/src/pages/AdminGroupsPage.tsx new file mode 100644 index 0000000..f20d867 --- /dev/null +++ b/frontend/src/pages/AdminGroupsPage.tsx @@ -0,0 +1,315 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + adminListGroups, + adminCreateGroup, + adminDeleteGroup, + adminGetGroup, + adminUpdateGroup, + adminAddGroupMember, + adminRemoveGroupMember, + adminGetUsers, + type GroupOut, + type GroupCreate, +} from "../api/client"; + +export default function AdminGroupsPage() { + const queryClient = useQueryClient(); + const { data: groups = [], isLoading } = useQuery({ + queryKey: ["admin-groups"], + queryFn: adminListGroups, + }); + + const [showForm, setShowForm] = useState(false); + const [formError, setFormError] = useState(null); + const [form, setForm] = useState({ name: "", description: "" }); + const [expandedGroupId, setExpandedGroupId] = useState(null); + const [editingGroup, setEditingGroup] = useState(null); + const [editName, setEditName] = useState(""); + const [editDescription, setEditDescription] = useState(""); + const [editError, setEditError] = useState(null); + + const createMutation = useMutation({ + mutationFn: adminCreateGroup, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["admin-groups"] }); + setShowForm(false); + setForm({ name: "", description: "" }); + setFormError(null); + }, + onError: (err: any) => { + const detail = err?.response?.data?.detail; + setFormError(typeof detail === "string" ? detail : "Failed to create group"); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: adminDeleteGroup, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["admin-groups"] }); + setExpandedGroupId(null); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: { name?: string; description?: string | null } }) => + adminUpdateGroup(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["admin-groups"] }); + setEditingGroup(null); + setEditError(null); + }, + onError: (err: any) => { + const detail = err?.response?.data?.detail; + setEditError(typeof detail === "string" ? detail : "Failed to update group"); + }, + }); + + const handleCreate = (e: React.FormEvent) => { + e.preventDefault(); + createMutation.mutate({ name: form.name, description: form.description || null }); + }; + + const handleDelete = (group: GroupOut) => { + if (!window.confirm(`Delete group "${group.name}"? This cannot be undone.`)) return; + deleteMutation.mutate(group.id); + }; + + const startEdit = (group: GroupOut) => { + setEditingGroup(group); + setEditName(group.name); + setEditDescription(group.description ?? ""); + setEditError(null); + }; + + const handleUpdate = (e: React.FormEvent) => { + e.preventDefault(); + if (!editingGroup) return; + updateMutation.mutate({ + id: editingGroup.id, + data: { name: editName, description: editDescription || null }, + }); + }; + + return ( +
+

Group Management

+ + {isLoading ? ( +

Loading…

+ ) : ( + + + + + + + + + + + {groups.map((g) => ( + <> + + + + + + + {expandedGroupId === g.id && ( + + + + )} + + ))} + {groups.length === 0 && ( + + + + )} + +
NameDescriptionMembersActions
{g.name}{g.description ?? "—"}{g.member_count} + + + +
+ +
No groups yet.
+ )} + + {editingGroup && ( +
+

Edit Group

+
+
+ + setEditName(e.target.value)} + required + style={{ width: "100%", padding: "6px 8px", boxSizing: "border-box" }} + /> +
+
+ + setEditDescription(e.target.value)} + style={{ width: "100%", padding: "6px 8px", boxSizing: "border-box" }} + /> +
+ {editError &&

{editError}

} +
+ + +
+
+
+ )} + + {!showForm ? ( + + ) : ( +
+

New Group

+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + required + style={{ width: "100%", padding: "6px 8px", boxSizing: "border-box" }} + /> +
+
+ + setForm((f) => ({ ...f, description: e.target.value }))} + style={{ width: "100%", padding: "6px 8px", boxSizing: "border-box" }} + /> +
+ {formError &&

{formError}

} +
+ + +
+
+ )} +
+ ); +} + +function GroupMembersPanel({ groupId }: { groupId: string }) { + const queryClient = useQueryClient(); + const { data: group, isLoading } = useQuery({ + queryKey: ["admin-group", groupId], + queryFn: () => adminGetGroup(groupId), + }); + const { data: allUsers = [] } = useQuery({ + queryKey: ["admin-users"], + queryFn: adminGetUsers, + }); + + const [selectedUserId, setSelectedUserId] = useState(""); + + const addMutation = useMutation({ + mutationFn: ({ gId, uId }: { gId: string; uId: string }) => adminAddGroupMember(gId, uId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["admin-group", groupId] }); + queryClient.invalidateQueries({ queryKey: ["admin-groups"] }); + setSelectedUserId(""); + }, + }); + + const removeMutation = useMutation({ + mutationFn: ({ gId, uId }: { gId: string; uId: string }) => adminRemoveGroupMember(gId, uId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["admin-group", groupId] }); + queryClient.invalidateQueries({ queryKey: ["admin-groups"] }); + }, + }); + + if (isLoading) return

Loading members…

; + if (!group) return null; + + const memberIds = new Set(group.members.map((m) => m.id)); + const nonMembers = allUsers.filter((u) => !memberIds.has(u.id)); + + return ( +
+

Members ({group.members.length})

+ + {group.members.length === 0 ? ( +

No members yet.

+ ) : ( + + + + + + + + + + + {group.members.map((m) => ( + + + + + + + ))} + +
EmailNameStatus
{m.email}{m.full_name ?? "—"} + {m.is_active ? "Active" : "Inactive"} + + +
+ )} + + {nonMembers.length > 0 && ( +
+ + +
+ )} +
+ ); +} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index ba96986..b421ee4 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -1,175 +1,5 @@ -import { useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - adminCreateUser, - adminDeleteUser, - adminGetUsers, - adminToggleActive, - getMe, - type AdminUserCreate, - type UserData, -} from "../api/client"; +import { Navigate } from "react-router-dom"; export default function AdminPage() { - const queryClient = useQueryClient(); - const { data: me } = useQuery({ queryKey: ["me"], queryFn: getMe }); - const { data: users = [], isLoading } = useQuery({ - queryKey: ["admin-users"], - queryFn: adminGetUsers, - }); - - const [showForm, setShowForm] = useState(false); - const [formError, setFormError] = useState(null); - const [form, setForm] = useState({ - email: "", - password: "", - full_name: "", - is_admin: false, - }); - - const createMutation = useMutation({ - mutationFn: adminCreateUser, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["admin-users"] }); - setShowForm(false); - setForm({ email: "", password: "", full_name: "", is_admin: false }); - setFormError(null); - }, - onError: (err: any) => { - const detail = err?.response?.data?.detail; - if (Array.isArray(detail)) { - setFormError(detail.map((d: any) => d.msg).join("; ")); - } else { - setFormError(detail ?? "Failed to create user"); - } - }, - }); - - const deleteMutation = useMutation({ - mutationFn: adminDeleteUser, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["admin-users"] }), - }); - - const toggleActiveMutation = useMutation({ - mutationFn: adminToggleActive, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["admin-users"] }), - }); - - const handleDelete = (user: UserData) => { - if (!window.confirm(`Delete user "${user.email}"? This cannot be undone.`)) return; - deleteMutation.mutate(user.id); - }; - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - createMutation.mutate(form); - }; - - return ( - <> -
-

User Management

- - {isLoading ? ( -

Loading…

- ) : ( - - - - - - - - - - - - {users.map((u) => ( - - - - - - - - ))} - -
EmailNameStatusRoleActions
{u.email}{u.full_name ?? "—"} - {u.is_active ? "Active" : "Inactive"} - - {u.is_admin ? "Admin" : "User"} - - {u.id !== me?.id && ( - <> - - - - )} - {u.id === me?.id && ( - you - )} -
- )} - - {!showForm ? ( - - ) : ( -
-

New User

- setForm((f) => ({ ...f, email: v }))} required /> - setForm((f) => ({ ...f, full_name: v }))} /> - setForm((f) => ({ ...f, password: v }))} required /> -
- setForm((f) => ({ ...f, is_admin: e.target.checked }))} - /> - -
- {formError &&

{formError}

} -
- - -
- - )} -
- - ); -} - -function FormField({ - label, value, onChange, type = "text", required = false, -}: { - label: string; value: string; onChange: (v: string) => void; - type?: string; required?: boolean; -}) { - return ( -
- - onChange(e.target.value)} - required={required} - style={{ width: "100%", padding: "6px 8px", boxSizing: "border-box" }} - /> -
- ); + return ; } diff --git a/frontend/src/pages/AdminUsersPage.tsx b/frontend/src/pages/AdminUsersPage.tsx new file mode 100644 index 0000000..fa6cb02 --- /dev/null +++ b/frontend/src/pages/AdminUsersPage.tsx @@ -0,0 +1,173 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + adminCreateUser, + adminDeleteUser, + adminGetUsers, + adminToggleActive, + getMe, + type AdminUserCreate, + type UserData, +} from "../api/client"; + +export default function AdminUsersPage() { + const queryClient = useQueryClient(); + const { data: me } = useQuery({ queryKey: ["me"], queryFn: getMe }); + const { data: users = [], isLoading } = useQuery({ + queryKey: ["admin-users"], + queryFn: adminGetUsers, + }); + + const [showForm, setShowForm] = useState(false); + const [formError, setFormError] = useState(null); + const [form, setForm] = useState({ + email: "", + password: "", + full_name: "", + is_admin: false, + }); + + const createMutation = useMutation({ + mutationFn: adminCreateUser, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["admin-users"] }); + setShowForm(false); + setForm({ email: "", password: "", full_name: "", is_admin: false }); + setFormError(null); + }, + onError: (err: any) => { + const detail = err?.response?.data?.detail; + if (Array.isArray(detail)) { + setFormError(detail.map((d: any) => d.msg).join("; ")); + } else { + setFormError(detail ?? "Failed to create user"); + } + }, + }); + + const deleteMutation = useMutation({ + mutationFn: adminDeleteUser, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["admin-users"] }), + }); + + const toggleActiveMutation = useMutation({ + mutationFn: adminToggleActive, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["admin-users"] }), + }); + + const handleDelete = (user: UserData) => { + if (!window.confirm(`Delete user "${user.email}"? This cannot be undone.`)) return; + deleteMutation.mutate(user.id); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + createMutation.mutate(form); + }; + + return ( +
+

User Management

+ + {isLoading ? ( +

Loading…

+ ) : ( + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + ))} + +
EmailNameStatusRoleActions
{u.email}{u.full_name ?? "—"} + {u.is_active ? "Active" : "Inactive"} + + {u.is_admin ? "Admin" : "User"} + + {u.id !== me?.id && ( + <> + + + + )} + {u.id === me?.id && ( + you + )} +
+ )} + + {!showForm ? ( + + ) : ( +
+

New User

+ setForm((f) => ({ ...f, email: v }))} required /> + setForm((f) => ({ ...f, full_name: v }))} /> + setForm((f) => ({ ...f, password: v }))} required /> +
+ setForm((f) => ({ ...f, is_admin: e.target.checked }))} + /> + +
+ {formError &&

{formError}

} +
+ + +
+ + )} +
+ ); +} + +function FormField({ + label, value, onChange, type = "text", required = false, +}: { + label: string; value: string; onChange: (v: string) => void; + type?: string; required?: boolean; +}) { + return ( +
+ + onChange(e.target.value)} + required={required} + style={{ width: "100%", padding: "6px 8px", boxSizing: "border-box" }} + /> +
+ ); +}