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
+3
View File
@@ -115,6 +115,7 @@ Relationship: `profile` (one-to-one, cascade all+delete-orphan)
| `id` | String | PK, UUID | | `id` | String | PK, UUID |
| `group_id` | String | FK→groups.id, indexed, CASCADE | | `group_id` | String | FK→groups.id, indexed, CASCADE |
| `user_id` | String | FK→users.id, indexed, CASCADE | | `user_id` | String | FK→users.id, indexed, CASCADE |
| `is_group_admin` | Boolean | NOT NULL, default=false | grants group-admin rights (manage group categories, delete shared docs) |
| `joined_at` | DateTime(tz) | server_default=now() | | `joined_at` | DateTime(tz) | server_default=now() |
Unique constraint: `(group_id, user_id)` Unique constraint: `(group_id, user_id)`
@@ -128,6 +129,7 @@ Unique constraint: `(group_id, user_id)`
| `a3f9c2d14e87` | `add_groups_and_group_memberships` | | `a3f9c2d14e87` | `add_groups_and_group_memberships` |
| `c7e8f9a0b1d2` | `add_dashboard_app_ids_to_users` | | `c7e8f9a0b1d2` | `add_dashboard_app_ids_to_users` |
| `dd6ad2f2c211` | `add_color_mode_to_users` | | `dd6ad2f2c211` | `add_color_mode_to_users` |
| `e1f2a3b4c5d6` | `add_group_member_is_admin` |
--- ---
@@ -177,6 +179,7 @@ Unique constraint: `(group_id, user_id)`
| DELETE | `/api/admin/groups/{id}` | Delete (cascades memberships) | | DELETE | `/api/admin/groups/{id}` | Delete (cascades memberships) |
| POST | `/api/admin/groups/{id}/members/{user_id}` | Add member | | POST | `/api/admin/groups/{id}/members/{user_id}` | Add member |
| DELETE | `/api/admin/groups/{id}/members/{user_id}` | Remove member | | DELETE | `/api/admin/groups/{id}/members/{user_id}` | Remove member |
| PATCH | `/api/admin/groups/{id}/members/{user_id}/admin` | Set/unset group admin role (body: `{ is_group_admin: bool }`) |
### Settings (`/api/settings`) — admin-only ### Settings (`/api/settings`) — admin-only
@@ -0,0 +1,32 @@
"""add is_group_admin to group_memberships
Revision ID: e1f2a3b4c5d6
Revises: dd6ad2f2c211
Create Date: 2026-04-18
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "e1f2a3b4c5d6"
down_revision: Union[str, None] = "dd6ad2f2c211"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"group_memberships",
sa.Column(
"is_group_admin",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
def downgrade() -> None:
op.drop_column("group_memberships", "is_group_admin")
+4 -1
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
@@ -35,6 +35,9 @@ class GroupMembership(Base):
user_id: Mapped[str] = mapped_column( user_id: Mapped[str] = mapped_column(
String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
) )
is_group_admin: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false"
)
joined_at: Mapped[datetime] = mapped_column( joined_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc), default=lambda: datetime.now(timezone.utc),
+13 -5
View File
@@ -39,18 +39,26 @@ _HOP_BY_HOP = frozenset([
_STRIP_RESPONSE = frozenset([*_HOP_BY_HOP, "content-length", "content-type"]) _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 = { headers = {
k: v k: v
for k, v in request.headers.items() for k, v in request.headers.items()
if k.lower() not in _HOP_BY_HOP if k.lower() not in _HOP_BY_HOP
} }
headers["x-user-id"] = user_id headers["x-user-id"] = user_id
result = await db.execute( headers["x-user-is-admin"] = "true" if is_admin else "false"
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
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-groups"] = ",".join(group_ids)
headers["x-user-admin-groups"] = ",".join(admin_group_ids)
return headers return headers
@@ -63,7 +71,7 @@ async def proxy_categories(
path: str = "", path: str = "",
) -> Response: ) -> Response:
url = f"/categories/{path}" if path else "/categories" 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() body = await request.body()
try: try:
+14 -7
View File
@@ -50,21 +50,28 @@ _HOP_BY_HOP = frozenset([
_STRIP_RESPONSE = frozenset([*_HOP_BY_HOP, "content-length", "content-type"]) _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 = { headers = {
k: v k: v
for k, v in request.headers.items() for k, v in request.headers.items()
if k.lower() not in _HOP_BY_HOP if k.lower() not in _HOP_BY_HOP
} }
headers["x-user-id"] = user_id 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 # Inject group memberships and group-admin status so the doc-service can
# group-shared document access without querying the backend DB. # evaluate ownership, sharing access, and group-admin permissions.
result = await db.execute( mem_result = await db.execute(
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id) 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-groups"] = ",".join(group_ids)
headers["x-user-admin-groups"] = ",".join(admin_group_ids)
return headers return headers
@@ -78,7 +85,7 @@ async def proxy_documents(
path: str = "", path: str = "",
) -> Response: ) -> Response:
url = f"/documents/{path}" if path else "/documents" 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() body = await request.body()
try: try:
+22 -1
View File
@@ -7,7 +7,7 @@ from app.database import get_db
from app.deps import get_current_admin from app.deps import get_current_admin
from app.models.group import Group, GroupMembership from app.models.group import Group, GroupMembership
from app.models.user import User 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() router = APIRouter()
@@ -111,6 +111,7 @@ async def get_group(
email=user.email, email=user.email,
full_name=user.full_name, full_name=user.full_name,
is_active=user.is_active, is_active=user.is_active,
is_group_admin=membership.is_group_admin,
joined_at=membership.joined_at, joined_at=membership.joined_at,
) )
for membership, user in rows for membership, user in rows
@@ -197,6 +198,26 @@ async def add_member(
await db.commit() 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) @router.delete("/{group_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_member( async def remove_member(
group_id: str, group_id: str,
+6 -3
View File
@@ -39,14 +39,17 @@ async def get_my_groups(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), 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( result = await db.execute(
select(Group) select(Group, GroupMembership.is_group_admin)
.join(GroupMembership, GroupMembership.group_id == Group.id) .join(GroupMembership, GroupMembership.group_id == Group.id)
.where(GroupMembership.user_id == current_user.id) .where(GroupMembership.user_id == current_user.id)
.order_by(Group.name) .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) @router.patch("/me/color-mode", response_model=UserOut)
+5
View File
@@ -18,11 +18,16 @@ class GroupMemberOut(BaseModel):
email: str email: str
full_name: str | None full_name: str | None
is_active: bool is_active: bool
is_group_admin: bool = False
joined_at: datetime joined_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class GroupMemberAdminUpdate(BaseModel):
is_group_admin: bool
class GroupOut(BaseModel): class GroupOut(BaseModel):
id: str id: str
name: str name: str
+1
View File
@@ -122,6 +122,7 @@ class UserGroupOut(BaseModel):
id: str id: str
name: str name: str
description: str | None description: str | None
is_group_admin: bool = False
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -0,0 +1,34 @@
# 2026-04-18 — Category scopes, group admin role, and permission model
**Timestamp:** 2026-04-18T00:00:00Z
## Summary
Introduces three category scopes (personal / group / system), a PascalCase-with-dashes naming convention, a group-admin role on group memberships, and a full permission model for who can create, rename, and delete categories and documents.
## Files Added
- `backend/alembic/versions/e1f2a3b4c5d6_add_group_member_is_admin.py` — adds `is_group_admin BOOLEAN` to `group_memberships`
- `features/doc-service/alembic/versions/0005_add_share_can_delete.py` — adds `can_delete BOOLEAN` to `document_shares` (backfill from feat/document-delete-permissions)
- `features/doc-service/alembic/versions/0006_add_category_scope.py` — adds `scope VARCHAR(16)` and `group_id VARCHAR` to `document_categories`; data-migrates watch categories to scope='system'
## Files Modified
- `backend/app/models/group.py` — added `is_group_admin` to `GroupMembership`
- `backend/app/schemas/group.py` — added `is_group_admin` to `GroupMemberOut`; new `GroupMemberAdminUpdate`
- `backend/app/schemas/user.py` — added `is_group_admin` to `UserGroupOut`
- `backend/app/routers/users.py``get_my_groups` now joins `GroupMembership` to include `is_group_admin`
- `backend/app/routers/groups.py``get_group` includes `is_group_admin`; new `PATCH /{id}/members/{user_id}/admin` endpoint
- `backend/app/routers/categories_proxy.py` — injects `x-user-is-admin` and `x-user-admin-groups` headers
- `backend/app/routers/documents_proxy.py` — injects `x-user-admin-groups` header (was already injecting `x-user-is-admin`)
- `features/doc-service/app/models/category.py` — added `scope`, `group_id` columns
- `features/doc-service/app/schemas/category.py``CategoryOut` includes `scope`/`group_id`; `CategoryCreate` accepts `group_id`
- `features/doc-service/app/deps.py` — added `get_user_is_admin`, `get_user_admin_groups`
- `features/doc-service/app/routers/categories.py` — full rewrite: name validation regex, scope-based list/create, `_check_can_manage_cat` permission helper, scope-aware rename/delete
- `features/doc-service/app/routers/documents.py``delete_document` enforces is_admin/can_delete/group-admin hierarchy; `remove_category` requires doc ownership; `assign_category` accepts group/system categories
- `frontend/src/api/client.ts``CategoryOut` gains `scope`/`group_id`; `createCategory` accepts optional `groupId`; `UserGroupOut`/`GroupMemberOut` gain `is_group_admin`; new `adminSetGroupMemberAdmin()`; `ApiError` exported
- `frontend/src/components/ManageCategoriesDialog.tsx` — categories grouped by scope; lock icons for unmanageable categories; rename/delete gated by scope permissions; inline rename error display
- `frontend/src/components/SourcePanel.tsx` — categories shown in sections (Mine / Group name / System); scope picker on new category form; client-side name validation
- `frontend/src/pages/AdminGroupsPage.tsx` — group admin checkbox column in members table
- `backend/CLAUDE.md` — updated `group_memberships` model, migration chain, endpoints
- `features/doc-service/CLAUDE.md` — updated `document_categories` model, `document_shares` model, migration chain, deps note
+12 -7
View File
@@ -23,7 +23,7 @@ features/doc-service/
├── app/ ├── app/
│ ├── main.py ← FastAPI, lifespan (file watcher start/stop) │ ├── main.py ← FastAPI, lifespan (file watcher start/stop)
│ ├── database.py ← Same PostgreSQL instance as backend │ ├── database.py ← Same PostgreSQL instance as backend
│ ├── deps.py ← get_user_id (x-user-id), get_user_groups (x-user-groups) │ ├── deps.py ← get_user_id, get_user_groups, get_user_is_admin, get_user_admin_groups (injected headers)
│ ├── models/ │ ├── models/
│ │ ├── document.py ← Document model │ │ ├── document.py ← Document model
│ │ ├── category.py ← DocumentCategory model │ │ ├── category.py ← DocumentCategory model
@@ -78,12 +78,14 @@ features/doc-service/
### `document_categories` ### `document_categories`
| Column | Type | Constraints | | Column | Type | Constraints | Notes |
|--------|------|-------------| |--------|------|-------------|-------|
| `id` | String | PK, UUID | | `id` | String | PK, UUID | |
| `user_id` | String | indexed | | `user_id` | String | indexed | owner; "watch" for system categories |
| `name` | String(128) | NOT NULL | | `name` | String(128) | NOT NULL | PascalCase-with-dashes convention enforced on create/rename |
| `created_at` | DateTime(tz) | server_default=now() | | `scope` | String(16) | NOT NULL, default="personal" | "personal" / "group" / "system" |
| `group_id` | String | nullable, indexed | set when scope="group" |
| `created_at` | DateTime(tz) | server_default=now() | |
### `document_category_assignments` (composite PK) ### `document_category_assignments` (composite PK)
@@ -100,6 +102,7 @@ features/doc-service/
| `document_id` | String | indexed, NOT NULL | not FK — trusts proxy | | `document_id` | String | indexed, NOT NULL | not FK — trusts proxy |
| `group_id` | String | indexed, NOT NULL | group from backend | | `group_id` | String | indexed, NOT NULL | group from backend |
| `shared_by_user_id` | String | NOT NULL | owner who shared | | `shared_by_user_id` | String | NOT NULL | owner who shared |
| `can_delete` | Boolean | NOT NULL, default=false | allows group members to delete the doc |
| `created_at` | DateTime(tz) | server_default=now() | | | `created_at` | DateTime(tz) | server_default=now() | |
Unique constraint: `(document_id, group_id)` Unique constraint: `(document_id, group_id)`
@@ -112,6 +115,8 @@ Unique constraint: `(document_id, group_id)`
| `0002` | `add_document_title` | | `0002` | `add_document_title` |
| `0003` | `add_watch_columns` | | `0003` | `add_watch_columns` |
| `0004` | `add_document_shares` | | `0004` | `add_document_shares` |
| `0005` | `add_share_can_delete` |
| `0006` | `add_category_scope` |
--- ---
@@ -0,0 +1,32 @@
"""add can_delete to document_shares
Revision ID: 0005
Revises: 0004
Create Date: 2026-04-18
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0005"
down_revision: Union[str, None] = "0004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"document_shares",
sa.Column(
"can_delete",
sa.Boolean(),
nullable=False,
server_default="false",
),
)
def downgrade() -> None:
op.drop_column("document_shares", "can_delete")
@@ -0,0 +1,42 @@
"""add scope and group_id to document_categories
Revision ID: 0006
Revises: 0005
Create Date: 2026-04-18
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0006"
down_revision: Union[str, None] = "0005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"document_categories",
sa.Column(
"scope",
sa.String(16),
nullable=False,
server_default="personal",
),
)
op.add_column(
"document_categories",
sa.Column("group_id", sa.String(), nullable=True),
)
op.create_index("ix_document_categories_group_id", "document_categories", ["group_id"])
# Migrate existing watch-owned categories to system scope
op.execute("UPDATE document_categories SET scope = 'system' WHERE user_id = 'watch'")
def downgrade() -> None:
op.drop_index("ix_document_categories_group_id", table_name="document_categories")
op.drop_column("document_categories", "group_id")
op.drop_column("document_categories", "scope")
+19
View File
@@ -21,3 +21,22 @@ async def get_user_groups(x_user_groups: str = Header(default="")) -> list[str]:
if not x_user_groups: if not x_user_groups:
return [] return []
return [g.strip() for g in x_user_groups.split(",") if g.strip()] return [g.strip() for g in x_user_groups.split(",") if g.strip()]
async def get_user_is_admin(x_user_is_admin: str = Header(default="false")) -> bool:
"""
Extract the superuser flag injected by the main backend proxy.
Returns True only when the header value is exactly "true".
"""
return x_user_is_admin.lower() == "true"
async def get_user_admin_groups(x_user_admin_groups: str = Header(default="")) -> list[str]:
"""
Extract the group IDs for which the current user is a group admin.
Injected by the main backend proxy from GroupMembership.is_group_admin.
Returns an empty list if absent or empty.
"""
if not x_user_admin_groups:
return []
return [g.strip() for g in x_user_admin_groups.split(",") if g.strip()]
@@ -13,6 +13,8 @@ class DocumentCategory(Base):
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
user_id: Mapped[str] = mapped_column(String, nullable=False, index=True) user_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(128), nullable=False) name: Mapped[str] = mapped_column(String(128), nullable=False)
scope: Mapped[str] = mapped_column(String(16), nullable=False, default="personal", server_default="personal")
group_id: Mapped[str | None] = mapped_column(String, nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )
+82 -14
View File
@@ -1,5 +1,6 @@
import difflib import difflib
import json import json
import re
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import select
@@ -8,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import or_ from sqlalchemy import or_
from app.database import AsyncSessionLocal, get_db from app.database import AsyncSessionLocal, get_db
from app.deps import get_user_id from app.deps import get_user_admin_groups, get_user_groups, get_user_id, get_user_is_admin
from app.models.category import DocumentCategory from app.models.category import DocumentCategory
from app.models.category_assignment import CategoryAssignment from app.models.category_assignment import CategoryAssignment
from app.models.document import Document from app.models.document import Document
@@ -22,14 +23,31 @@ _WATCH_USER_ID = "watch"
_SIMILARITY_THRESHOLD = 0.4 _SIMILARITY_THRESHOLD = 0.4
# PascalCase-with-dashes: each word starts with a capital, words joined by '-'
# Valid: Invoices, Vendor-Invoices, Q1-Reports
# Invalid: invoices, Invoice Reports, Invoice-reports
_NAME_RE = re.compile(r'^[A-Z][a-zA-Z0-9]*(-[A-Z][a-zA-Z0-9]*)*$')
def _validate_name(name: str) -> None:
if not _NAME_RE.match(name):
raise HTTPException(
status_code=422,
detail=(
"Category name must start with a capital letter. "
"Multiple words are joined with dashes, each starting with a capital "
"(e.g. Invoices, Vendor-Invoices, Q1-Reports)."
),
)
def _name_similarity(a: str, b: str) -> float: def _name_similarity(a: str, b: str) -> float:
"""Return similarity score (01) between two category names.""" """Return similarity score (01) between two category names."""
a_low = a.lower() a_low = a.lower()
b_low = b.lower() b_low = b.lower()
# Word overlap is a strong signal # Word overlap is a strong signal
a_words = set(a_low.split()) a_words = set(a_low.split("-"))
b_words = set(b_low.split()) b_words = set(b_low.split("-"))
if a_words & b_words: if a_words & b_words:
return 0.9 return 0.9
# Fallback: character sequence ratio # Fallback: character sequence ratio
@@ -81,15 +99,44 @@ async def _reanalyze_documents_for_new_category(
pass pass
async def _check_can_manage_cat(
cat: DocumentCategory,
user_id: str,
is_admin: bool,
user_admin_groups: list[str],
) -> None:
"""Raise 403/404 if the current user may not rename or delete this category."""
if is_admin:
return # superuser can manage anything
if cat.scope == "system":
raise HTTPException(status_code=403, detail="Only admins can modify system categories")
if cat.scope == "personal" and cat.user_id != user_id:
raise HTTPException(status_code=404, detail="Category not found")
if cat.scope == "group" and cat.group_id not in user_admin_groups:
raise HTTPException(status_code=403, detail="Only group admins can modify group categories")
@router.get("", response_model=list[CategoryOut]) @router.get("", response_model=list[CategoryOut])
async def list_categories( async def list_categories(
user_id: str = Depends(get_user_id), user_id: str = Depends(get_user_id),
user_groups: list[str] = Depends(get_user_groups),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> list[DocumentCategory]: ) -> list[DocumentCategory]:
# Include watch-ingested categories so they appear in the sidebar/filter """
Return all categories visible to the current user:
- personal (owned by the user)
- group (any group the user belongs to)
- system (watch-ingested, scope='system')
"""
result = await db.execute( result = await db.execute(
select(DocumentCategory) select(DocumentCategory)
.where(or_(DocumentCategory.user_id == user_id, DocumentCategory.user_id == _WATCH_USER_ID)) .where(
or_(
DocumentCategory.user_id == user_id, # personal
DocumentCategory.group_id.in_(user_groups), # group
DocumentCategory.scope == "system", # system
)
)
.order_by(DocumentCategory.name) .order_by(DocumentCategory.name)
) )
return result.scalars().all() return result.scalars().all()
@@ -100,18 +147,32 @@ async def create_category(
body: CategoryCreate, body: CategoryCreate,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
user_id: str = Depends(get_user_id), user_id: str = Depends(get_user_id),
user_groups: list[str] = Depends(get_user_groups),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> DocumentCategory: ) -> DocumentCategory:
name = body.name.strip() name = body.name.strip()
if not name: if not name:
raise HTTPException(status_code=422, detail="Category name cannot be empty") raise HTTPException(status_code=422, detail="Category name cannot be empty")
_validate_name(name)
if body.group_id:
# User must be a member of the target group
if body.group_id not in user_groups:
raise HTTPException(status_code=403, detail="You are not a member of that group")
cat = DocumentCategory(
user_id=user_id,
name=name[:128],
scope="group",
group_id=body.group_id,
)
else:
cat = DocumentCategory(user_id=user_id, name=name[:128], scope="personal")
cat = DocumentCategory(user_id=user_id, name=name[:128])
db.add(cat) db.add(cat)
await db.commit() await db.commit()
await db.refresh(cat) await db.refresh(cat)
# Find existing categories with similar names # Find existing personal categories with similar names for background AI reanalysis
result = await db.execute( result = await db.execute(
select(DocumentCategory) select(DocumentCategory)
.where(DocumentCategory.user_id == user_id) .where(DocumentCategory.user_id == user_id)
@@ -140,12 +201,18 @@ async def rename_category(
cat_id: str, cat_id: str,
body: CategoryUpdate, body: CategoryUpdate,
user_id: str = Depends(get_user_id), user_id: str = Depends(get_user_id),
is_admin: bool = Depends(get_user_is_admin),
user_admin_groups: list[str] = Depends(get_user_admin_groups),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> DocumentCategory: ) -> DocumentCategory:
cat = await _get_user_cat(cat_id, user_id, db) cat = await _fetch_visible_cat(cat_id, user_id, db)
await _check_can_manage_cat(cat, user_id, is_admin, user_admin_groups)
name = body.name.strip() name = body.name.strip()
if not name: if not name:
raise HTTPException(status_code=422, detail="Category name cannot be empty") raise HTTPException(status_code=422, detail="Category name cannot be empty")
_validate_name(name)
cat.name = name[:128] cat.name = name[:128]
await db.commit() await db.commit()
await db.refresh(cat) await db.refresh(cat)
@@ -156,19 +223,20 @@ async def rename_category(
async def delete_category( async def delete_category(
cat_id: str, cat_id: str,
user_id: str = Depends(get_user_id), user_id: str = Depends(get_user_id),
is_admin: bool = Depends(get_user_is_admin),
user_admin_groups: list[str] = Depends(get_user_admin_groups),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> None: ) -> None:
cat = await _get_user_cat(cat_id, user_id, db) cat = await _fetch_visible_cat(cat_id, user_id, db)
await _check_can_manage_cat(cat, user_id, is_admin, user_admin_groups)
await db.delete(cat) await db.delete(cat)
await db.commit() await db.commit()
async def _get_user_cat(cat_id: str, user_id: str, db: AsyncSession) -> DocumentCategory: async def _fetch_visible_cat(cat_id: str, user_id: str, db: AsyncSession) -> DocumentCategory:
"""Fetch a category that the user can see (personal/group/system). 404 if absent."""
result = await db.execute( result = await db.execute(
select(DocumentCategory).where( select(DocumentCategory).where(DocumentCategory.id == cat_id)
DocumentCategory.id == cat_id,
DocumentCategory.user_id == user_id,
)
) )
cat = result.scalar_one_or_none() cat = result.scalar_one_or_none()
if cat is None: if cat is None:
+62 -3
View File
@@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.database import AsyncSessionLocal, get_db from app.database import AsyncSessionLocal, get_db
from app.deps import get_user_groups, get_user_id from app.deps import get_user_admin_groups, get_user_groups, get_user_id, get_user_is_admin
from app.models.category import DocumentCategory from app.models.category import DocumentCategory
from app.models.category_assignment import CategoryAssignment from app.models.category_assignment import CategoryAssignment
from app.models.document import Document from app.models.document import Document
@@ -479,14 +479,59 @@ async def reprocess_document(
async def delete_document( async def delete_document(
doc_id: str, doc_id: str,
user_id: str = Depends(get_user_id), user_id: str = Depends(get_user_id),
is_admin: bool = Depends(get_user_is_admin),
user_groups: list[str] = Depends(get_user_groups),
user_admin_groups: list[str] = Depends(get_user_admin_groups),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> None: ) -> None:
# Fetch the document (owner, watch, or shared with user's groups)
result = await db.execute( result = await db.execute(
select(Document).where(Document.id == doc_id, Document.user_id == user_id) select(Document).where(
Document.id == doc_id,
or_(
Document.user_id == user_id,
Document.user_id == _WATCH_USER_ID,
Document.id.in_(
select(DocumentShare.document_id).where(
DocumentShare.group_id.in_(user_groups)
)
) if user_groups else False,
),
)
) )
doc = result.scalar_one_or_none() doc = result.scalar_one_or_none()
if doc is None: if doc is None:
raise HTTPException(status_code=404, detail="Document not found") raise HTTPException(status_code=404, detail="Document not found")
is_owner = doc.user_id == user_id
if not is_owner and not is_admin:
# Check: shared with a group where the user has can_delete=True
can_delete_via_share = False
if user_groups:
share_result = await db.execute(
select(DocumentShare).where(
DocumentShare.document_id == doc_id,
DocumentShare.group_id.in_(user_groups),
DocumentShare.can_delete.is_(True),
)
)
can_delete_via_share = share_result.scalar_one_or_none() is not None
# Check: user is a group admin for a group the doc is shared with
can_delete_as_group_admin = False
if user_admin_groups:
admin_share_result = await db.execute(
select(DocumentShare).where(
DocumentShare.document_id == doc_id,
DocumentShare.group_id.in_(user_admin_groups),
)
)
can_delete_as_group_admin = admin_share_result.scalar_one_or_none() is not None
if not can_delete_via_share and not can_delete_as_group_admin:
raise HTTPException(status_code=403, detail="Not allowed to delete this document")
delete_file(doc.file_path) delete_file(doc.file_path)
await db.delete(doc) await db.delete(doc)
await db.commit() await db.commit()
@@ -537,6 +582,7 @@ async def assign_category(
doc_id: str, doc_id: str,
cat_id: str, cat_id: str,
user_id: str = Depends(get_user_id), user_id: str = Depends(get_user_id),
user_groups: list[str] = Depends(get_user_groups),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> None: ) -> None:
doc_result = await db.execute( doc_result = await db.execute(
@@ -548,9 +594,15 @@ async def assign_category(
if doc_result.scalar_one_or_none() is None: if doc_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Document not found") raise HTTPException(status_code=404, detail="Document not found")
# Accept personal, group (user is a member), or system categories
cat_result = await db.execute( cat_result = await db.execute(
select(DocumentCategory).where( select(DocumentCategory).where(
DocumentCategory.id == cat_id, DocumentCategory.user_id == user_id DocumentCategory.id == cat_id,
or_(
DocumentCategory.user_id == user_id,
DocumentCategory.group_id.in_(user_groups) if user_groups else False,
DocumentCategory.scope == "system",
),
) )
) )
if cat_result.scalar_one_or_none() is None: if cat_result.scalar_one_or_none() is None:
@@ -574,6 +626,13 @@ async def remove_category(
user_id: str = Depends(get_user_id), user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> None: ) -> None:
# Only the document owner may remove a category assignment
doc_result = await db.execute(
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
)
if doc_result.scalar_one_or_none() is None:
raise HTTPException(status_code=403, detail="Only the document owner can remove category assignments")
result = await db.execute( result = await db.execute(
select(CategoryAssignment).where( select(CategoryAssignment).where(
CategoryAssignment.document_id == doc_id, CategoryAssignment.document_id == doc_id,
@@ -7,6 +7,8 @@ class CategoryOut(BaseModel):
id: str id: str
user_id: str user_id: str
name: str name: str
scope: str = "personal"
group_id: str | None = None
created_at: datetime created_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@@ -14,6 +16,7 @@ class CategoryOut(BaseModel):
class CategoryCreate(BaseModel): class CategoryCreate(BaseModel):
name: str name: str
group_id: str | None = None
class CategoryUpdate(BaseModel): class CategoryUpdate(BaseModel):
+10 -3
View File
@@ -4,7 +4,7 @@ const BASE = "/api";
// Core fetch wrapper // Core fetch wrapper
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
class ApiError extends Error { export class ApiError extends Error {
status: number; status: number;
detail: string; detail: string;
@@ -199,6 +199,8 @@ export type DocumentStatus = "pending" | "processing" | "done" | "failed";
export interface CategoryOut { export interface CategoryOut {
id: string; id: string;
name: string; name: string;
scope: "personal" | "group" | "system";
group_id: string | null;
} }
export interface DocumentOut { export interface DocumentOut {
@@ -332,8 +334,8 @@ export const removeCategory = (docId: string, catId: string) =>
export const listCategories = () => api.get<CategoryOut[]>("/documents/categories"); export const listCategories = () => api.get<CategoryOut[]>("/documents/categories");
export const createCategory = (name: string) => export const createCategory = (name: string, groupId?: string) =>
api.post<CategoryOut>("/documents/categories", { name }); api.post<CategoryOut>("/documents/categories", { name, group_id: groupId ?? null });
export const renameCategory = (id: string, name: string) => export const renameCategory = (id: string, name: string) =>
api.patch<CategoryOut>(`/documents/categories/${id}`, { name }); api.patch<CategoryOut>(`/documents/categories/${id}`, { name });
@@ -431,6 +433,7 @@ export interface UserGroupOut {
id: string; id: string;
name: string; name: string;
description: string | null; description: string | null;
is_group_admin: boolean;
} }
export const getMyGroups = () => api.get<UserGroupOut[]>("/users/me/groups"); export const getMyGroups = () => api.get<UserGroupOut[]>("/users/me/groups");
@@ -453,6 +456,7 @@ export interface GroupMemberOut {
full_name: string | null; full_name: string | null;
is_active: boolean; is_active: boolean;
joined_at: string; joined_at: string;
is_group_admin: boolean;
} }
export interface GroupDetailOut extends GroupOut { export interface GroupDetailOut extends GroupOut {
@@ -489,6 +493,9 @@ export const adminAddGroupMember = (groupId: string, userId: string) =>
export const adminRemoveGroupMember = (groupId: string, userId: string) => export const adminRemoveGroupMember = (groupId: string, userId: string) =>
api.delete(`/admin/groups/${groupId}/members/${userId}`); api.delete(`/admin/groups/${groupId}/members/${userId}`);
export const adminSetGroupMemberAdmin = (groupId: string, userId: string, isAdmin: boolean) =>
api.patch(`/admin/groups/${groupId}/members/${userId}/admin`, { is_group_admin: isAdmin });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Services // Services
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1,7 +1,14 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { X, Pencil, Trash2, Check } from "lucide-react"; import { X, Pencil, Trash2, Check, Lock } from "lucide-react";
import { listCategories, renameCategory, deleteCategory } from "@/api/client"; import {
listCategories,
renameCategory,
deleteCategory,
getMyGroups,
type CategoryOut,
ApiError,
} from "@/api/client";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -11,13 +18,23 @@ interface Props {
export default function ManageCategoriesDialog({ onClose }: Props) { export default function ManageCategoriesDialog({ onClose }: Props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: categories = [] } = useQuery({ const { data: categories = [] } = useQuery({
queryKey: ["categories"], queryKey: ["categories"],
queryFn: listCategories, queryFn: listCategories,
}); });
const { data: myGroups = [] } = useQuery({
queryKey: ["my-groups"],
queryFn: getMyGroups,
});
// Set of group IDs for which the current user is a group admin
const adminGroupIds = new Set(myGroups.filter((g) => g.is_group_admin).map((g) => g.id));
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState(""); const [editValue, setEditValue] = useState("");
const [editError, setEditError] = useState<string | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const editInputRef = useRef<HTMLInputElement>(null); const editInputRef = useRef<HTMLInputElement>(null);
@@ -30,6 +47,10 @@ export default function ManageCategoriesDialog({ onClose }: Props) {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] }); queryClient.invalidateQueries({ queryKey: ["categories"] });
setEditingId(null); setEditingId(null);
setEditError(null);
},
onError: (err) => {
setEditError(err instanceof ApiError ? err.message : "Rename failed");
}, },
}); });
@@ -38,24 +59,135 @@ export default function ManageCategoriesDialog({ onClose }: Props) {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["categories"] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["categories"] }),
}); });
function canManage(cat: CategoryOut): boolean {
if (cat.scope === "personal") return true;
if (cat.scope === "group") return cat.group_id != null && adminGroupIds.has(cat.group_id);
return false; // system — managed only by superuser via other means
}
const filtered = search const filtered = search
? categories.filter((c) => c.name.toLowerCase().includes(search.toLowerCase())) ? categories.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
: categories; : categories;
// Group by scope
const personal = filtered.filter((c) => c.scope === "personal");
const system = filtered.filter((c) => c.scope === "system");
// Group-scoped categories grouped by group_id
const groupCats = filtered.filter((c) => c.scope === "group");
const groupMap = new Map<string, { name: string; cats: CategoryOut[] }>();
for (const cat of groupCats) {
if (!cat.group_id) continue;
if (!groupMap.has(cat.group_id)) {
const grp = myGroups.find((g) => g.id === cat.group_id);
groupMap.set(cat.group_id, { name: grp?.name ?? cat.group_id, cats: [] });
}
groupMap.get(cat.group_id)!.cats.push(cat);
}
function startEdit(id: string, name: string) { function startEdit(id: string, name: string) {
setEditingId(id); setEditingId(id);
setEditValue(name); setEditValue(name);
setEditError(null);
} }
function submitEdit(id: string) { function submitEdit(id: string) {
const name = editValue.trim(); const name = editValue.trim();
if (name && name !== categories.find((c) => c.id === id)?.name) { if (!name) return;
renameMut.mutate({ id, name }); if (name === categories.find((c) => c.id === id)?.name) {
} else {
setEditingId(null); setEditingId(null);
return;
} }
renameMut.mutate({ id, name });
} }
function renderCat(cat: CategoryOut) {
const manageable = canManage(cat);
return (
<div
key={cat.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-muted/10 group"
>
{editingId === cat.id ? (
<div className="flex flex-col flex-1 gap-1">
<form
className="flex items-center gap-2"
onSubmit={(e) => { e.preventDefault(); submitEdit(cat.id); }}
>
<Input
ref={editInputRef}
value={editValue}
onChange={(e) => { setEditValue(e.target.value); setEditError(null); }}
className="h-7 text-sm flex-1"
disabled={renameMut.isPending}
onKeyDown={(e) => { if (e.key === "Escape") { setEditingId(null); setEditError(null); } }}
/>
<button
type="submit"
disabled={!editValue.trim() || renameMut.isPending}
className="text-primary hover:text-primary/80 disabled:opacity-50"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => { setEditingId(null); setEditError(null); }}
className="text-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</form>
{editError && (
<p className="text-xs text-red-500 pl-0.5">{editError}</p>
)}
</div>
) : (
<>
{!manageable && (
<Lock className="h-3.5 w-3.5 text-muted flex-shrink-0" />
)}
<span className="flex-1 text-sm truncate">{cat.name}</span>
{manageable && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => startEdit(cat.id, cat.name)}
className="text-muted hover:text-foreground transition-colors p-0.5"
title="Rename"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => {
if (confirm(`Delete category "${cat.name}"? Documents in it will be uncategorised.`)) {
deleteMut.mutate(cat.id);
}
}}
disabled={deleteMut.isPending}
className="text-muted hover:text-red-500 transition-colors p-0.5 disabled:opacity-50"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</>
)}
</div>
);
}
function renderSection(title: string, cats: CategoryOut[]) {
if (cats.length === 0) return null;
return (
<div className="mb-3">
<p className="text-xs font-semibold text-muted uppercase tracking-wider px-2 mb-1">{title}</p>
{cats.map(renderCat)}
</div>
);
}
const hasAny = filtered.length > 0;
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/70" className="fixed inset-0 z-50 flex items-center justify-center bg-background/70"
@@ -83,73 +215,17 @@ export default function ManageCategoriesDialog({ onClose }: Props) {
)} )}
{/* List */} {/* List */}
<div className="flex-1 overflow-y-auto px-5 py-3 space-y-1 min-h-0"> <div className="flex-1 overflow-y-auto px-5 py-3 min-h-0">
{filtered.length === 0 && ( {!hasAny && (
<p className="text-sm text-muted py-4 text-center"> <p className="text-sm text-muted py-4 text-center">
{search ? "No categories match" : "No categories yet"} {search ? "No categories match" : "No categories yet"}
</p> </p>
)} )}
{filtered.map((cat) => ( {renderSection("My Categories", personal)}
<div {Array.from(groupMap.entries()).map(([, { name, cats }]) =>
key={cat.id} renderSection(`Group: ${name}`, cats)
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-muted/10 group" )}
> {renderSection("System", system)}
{editingId === cat.id ? (
<form
className="flex items-center gap-2 flex-1"
onSubmit={(e) => { e.preventDefault(); submitEdit(cat.id); }}
>
<Input
ref={editInputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
className="h-7 text-sm flex-1"
disabled={renameMut.isPending}
onKeyDown={(e) => { if (e.key === "Escape") setEditingId(null); }}
/>
<button
type="submit"
disabled={!editValue.trim() || renameMut.isPending}
className="text-primary hover:text-primary/80 disabled:opacity-50"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setEditingId(null)}
className="text-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</form>
) : (
<>
<span className="flex-1 text-sm truncate">{cat.name}</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => startEdit(cat.id, cat.name)}
className="text-muted hover:text-foreground transition-colors p-0.5"
title="Rename"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => {
if (confirm(`Delete category "${cat.name}"? Documents in it will be uncategorised.`)) {
deleteMut.mutate(cat.id);
}
}}
disabled={deleteMut.isPending}
className="text-muted hover:text-red-500 transition-colors p-0.5 disabled:opacity-50"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</>
)}
</div>
))}
</div> </div>
{/* Footer */} {/* Footer */}
+130 -43
View File
@@ -2,11 +2,20 @@ import { useState, useRef, useEffect } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Files, User, Users, Folder, Plus, Settings2, Check, X } from "lucide-react"; 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 { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import ManageCategoriesDialog from "@/components/ManageCategoriesDialog"; 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() { export default function SourcePanel() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -16,6 +25,8 @@ export default function SourcePanel() {
const [catSearch, setCatSearch] = useState(""); const [catSearch, setCatSearch] = useState("");
const [addingCat, setAddingCat] = useState(false); const [addingCat, setAddingCat] = useState(false);
const [newCatName, setNewCatName] = useState(""); const [newCatName, setNewCatName] = useState("");
const [newCatGroupId, setNewCatGroupId] = useState<string>("");
const [nameError, setNameError] = useState<string | null>(null);
const [manageOpen, setManageOpen] = useState(false); const [manageOpen, setManageOpen] = useState(false);
const addInputRef = useRef<HTMLInputElement>(null); const addInputRef = useRef<HTMLInputElement>(null);
@@ -24,12 +35,23 @@ export default function SourcePanel() {
queryFn: listCategories, queryFn: listCategories,
}); });
const { data: myGroups = [] } = useQuery({
queryKey: ["my-groups"],
queryFn: getMyGroups,
});
const createMut = useMutation({ const createMut = useMutation({
mutationFn: createCategory, mutationFn: ({ name, groupId }: { name: string; groupId?: string }) =>
createCategory(name, groupId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] }); queryClient.invalidateQueries({ queryKey: ["categories"] });
setNewCatName(""); setNewCatName("");
setNewCatGroupId("");
setAddingCat(false); 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.filter((c) => c.name.toLowerCase().includes(catSearch.toLowerCase()))
: categories; : 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) { function setView(view: string) {
setSearchParams((prev) => { setSearchParams((prev) => {
const next = new URLSearchParams(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) => const viewItemClass = (active: boolean) =>
cn( cn(
"flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors", "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" : "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 ( return (
<> <>
<aside className="w-56 flex flex-col border-r border-border bg-surface shrink-0 h-screen"> <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"> <div className="flex-1 overflow-y-auto min-h-0">
{filteredCats.map((cat) => ( {renderCatSection("Mine", personalCats)}
<button {Array.from(groupCatMap.entries()).map(([, { name, cats }]) =>
key={cat.id} renderCatSection(name, cats)
className={catItemClass(currentCategoryId === cat.id)} )}
onClick={() => selectCategory(cat)} {renderCatSection("System", systemCats)}
> {!hasSections && catSearch && (
<Folder className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{cat.name}</span>
</button>
))}
{filteredCats.length === 0 && catSearch && (
<p className="text-xs text-muted px-2 py-1">No categories match</p> <p className="text-xs text-muted px-2 py-1">No categories match</p>
)} )}
{categories.length === 0 && !catSearch && ( {categories.length === 0 && !catSearch && (
@@ -154,35 +224,52 @@ export default function SourcePanel() {
{/* Add new category */} {/* Add new category */}
<div className="pt-2 border-t border-border mt-2"> <div className="pt-2 border-t border-border mt-2">
{addingCat ? ( {addingCat ? (
<form <form onSubmit={handleAddSubmit} className="flex flex-col gap-1">
onSubmit={(e) => { <div className="flex items-center gap-1">
e.preventDefault(); <Input
if (newCatName.trim()) createMut.mutate(newCatName.trim()); ref={addInputRef}
}} value={newCatName}
className="flex items-center gap-1" onChange={(e) => { setNewCatName(e.target.value); setNameError(null); }}
> placeholder="Vendor-Invoices"
<Input className="h-7 text-xs flex-1"
ref={addInputRef} disabled={createMut.isPending}
value={newCatName} />
onChange={(e) => setNewCatName(e.target.value)} <button
placeholder="Category name" type="submit"
className="h-7 text-xs flex-1" disabled={!newCatName.trim() || createMut.isPending}
disabled={createMut.isPending} className="text-primary hover:text-primary/80 disabled:opacity-50"
/> >
<button <Check className="h-4 w-4" />
type="submit" </button>
disabled={!newCatName.trim() || createMut.isPending} <button
className="text-primary hover:text-primary/80 disabled:opacity-50" type="button"
> onClick={() => {
<Check className="h-4 w-4" /> setAddingCat(false);
</button> setNewCatName("");
<button setNewCatGroupId("");
type="button" setNameError(null);
onClick={() => { setAddingCat(false); setNewCatName(""); }} }}
className="text-muted hover:text-foreground" className="text-muted hover:text-foreground"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </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> </form>
) : ( ) : (
<button <button
+19
View File
@@ -8,6 +8,7 @@ import {
adminUpdateGroup, adminUpdateGroup,
adminAddGroupMember, adminAddGroupMember,
adminRemoveGroupMember, adminRemoveGroupMember,
adminSetGroupMemberAdmin,
adminGetUsers, adminGetUsers,
type GroupOut, type GroupOut,
type GroupCreate, type GroupCreate,
@@ -249,6 +250,14 @@ function GroupMembersPanel({ groupId }: { groupId: string }) {
}, },
}); });
const adminMutation = useMutation({
mutationFn: ({ uId, isAdmin }: { uId: string; isAdmin: boolean }) =>
adminSetGroupMemberAdmin(groupId, uId, isAdmin),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-group", groupId] });
},
});
if (isLoading) return <p style={{ padding: "8px 16px" }}>Loading members</p>; if (isLoading) return <p style={{ padding: "8px 16px" }}>Loading members</p>;
if (!group) return null; if (!group) return null;
@@ -268,6 +277,7 @@ function GroupMembersPanel({ groupId }: { groupId: string }) {
<th style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>Email</th> <th style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>Email</th>
<th style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>Name</th> <th style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>Name</th>
<th style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>Status</th> <th style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>Status</th>
<th style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>Group Admin</th>
<th style={{ padding: "4px 0", fontSize: 13 }}></th> <th style={{ padding: "4px 0", fontSize: 13 }}></th>
</tr> </tr>
</thead> </thead>
@@ -279,6 +289,15 @@ function GroupMembersPanel({ groupId }: { groupId: string }) {
<td style={{ padding: "4px 12px 4px 0", fontSize: 13 }}> <td style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>
{m.is_active ? "Active" : "Inactive"} {m.is_active ? "Active" : "Inactive"}
</td> </td>
<td style={{ padding: "4px 12px 4px 0", fontSize: 13 }}>
<input
type="checkbox"
checked={m.is_group_admin}
disabled={adminMutation.isPending}
onChange={() => adminMutation.mutate({ uId: m.id, isAdmin: !m.is_group_admin })}
title={m.is_group_admin ? "Remove group admin" : "Make group admin"}
/>
</td>
<td style={{ padding: "4px 0" }}> <td style={{ padding: "4px 0" }}>
<button <button
style={{ fontSize: 12, color: "red" }} style={{ fontSize: 12, color: "red" }}