From 683a63172c547822952acdd500889860a83d3c4a Mon Sep 17 00:00:00 2001 From: dehbok Date: Fri, 21 Aug 2026 08:43:44 +0200 Subject: [PATCH 1/6] Added openspec, engineering rules. Added proposal, tasks. Developed feature. Added tests. Added documentation. --- .gitignore | 10 +++ README.md | 4 + backend/app/api/deps.py | 23 +++-- backend/app/api/main.py | 3 +- backend/app/api/routes/items.py | 15 +++- backend/app/api/routes/login.py | 6 +- backend/app/api/routes/private.py | 14 ++- backend/app/api/routes/users.py | 30 ++++--- backend/app/api/routes/utils.py | 5 +- backend/app/core/db.py | 36 +++++++- backend/app/crud.py | 26 ++++-- backend/app/models.py | 60 ++++++++++++- backend/tests/api/routes/test_login.py | 12 ++- backend/tests/api/routes/test_users.py | 16 +++- backend/tests/conftest.py | 7 ++ backend/tests/crud/test_user.py | 19 ++-- backend/tests/utils/user.py | 7 +- frontend/src/client/index.ts | 4 +- frontend/src/client/sdk.gen.ts | 18 +++- frontend/src/client/types.gen.ts | 86 ++++++++++++++++--- frontend/src/components/Admin/AddUser.tsx | 39 ++++++--- frontend/src/components/Admin/EditUser.tsx | 41 ++++++--- .../src/components/Admin/UserActionsMenu.tsx | 7 +- frontend/src/components/Admin/columns.tsx | 6 +- .../src/components/Sidebar/AppSidebar.tsx | 15 +++- frontend/src/hooks/useAuth.ts | 4 +- frontend/src/routeTree.gen.ts | 42 +++++++++ frontend/src/routes/_layout/admin.tsx | 9 +- frontend/src/routes/_layout/settings.tsx | 5 +- frontend/src/utils.ts | 17 ++++ frontend/tests/admin.spec.ts | 66 ++++++++++++-- 31 files changed, 525 insertions(+), 127 deletions(-) diff --git a/.gitignore b/.gitignore index 3c697eb7da..a1eb95a77c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,13 @@ backend/app/frontend/ /playwright-report/ /blob-report/ /playwright/.cache/ + +# IDE +.idea/ + +# Duplicate OpenSpec tool scaffolding +.agent/ +.agents/ +.commandcode/ +.claude/commands/opsx/ +.claude/skills/openspec-*/ diff --git a/README.md b/README.md index a3e20f4dbf..5149be0dd0 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,10 @@ General development docs: [development.md](./development.md). This includes the local FastAPI and Vite workflow, Docker Compose services, `.env` configuration, and more. +## Authorization + +Role-based access control (roles, permissions, and how the frontend learns a user's capabilities): [docs/AUTHORIZATION.md](./docs/AUTHORIZATION.md). + ## Release Notes Check the file [release-notes.md](./release-notes.md). diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 5f28ec692a..c5e55ce733 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -1,4 +1,4 @@ -from collections.abc import Generator +from collections.abc import Callable, Generator from typing import Annotated import jwt @@ -8,7 +8,7 @@ from pydantic import ValidationError from sqlmodel import Session -from app.core import security +from app.core import rbac, security from app.core.config import settings from app.core.db import engine from app.models import TokenPayload, User @@ -33,7 +33,7 @@ def get_current_user(session: SessionDep, token: TokenDep) -> User: token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] ) token_data = TokenPayload(**payload) - except InvalidTokenError, ValidationError: + except (InvalidTokenError, ValidationError): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Could not validate credentials", @@ -49,9 +49,14 @@ def get_current_user(session: SessionDep, token: TokenDep) -> User: CurrentUser = Annotated[User, Depends(get_current_user)] -def get_current_active_superuser(current_user: CurrentUser) -> User: - if not current_user.is_superuser: - raise HTTPException( - status_code=403, detail="The user doesn't have enough privileges" - ) - return current_user +def require_permission(code: str) -> Callable[[CurrentUser], User]: + # Factory, not a dependency itself: `Depends(require_permission("x"))` calls + # this to build the actual per-code dependency below. + def dependency(current_user: CurrentUser) -> User: + if not rbac.has_permission(current_user, code): + raise HTTPException( + status_code=403, detail="The user doesn't have enough privileges" + ) + return current_user + + return dependency diff --git a/backend/app/api/main.py b/backend/app/api/main.py index a42e5003ee..b5eaaadfa5 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils +from app.api.routes import items, login, metrics, private, users, utils from app.core.config import settings api_router = APIRouter() @@ -8,6 +8,7 @@ api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(items.router) +api_router.include_router(metrics.router) if settings.FASTAPI_ENV == "development": diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py index f0eb30e4ce..df34afdf7c 100644 --- a/backend/app/api/routes/items.py +++ b/backend/app/api/routes/items.py @@ -5,6 +5,7 @@ from sqlmodel import col, func, select from app.api.deps import CurrentUser, SessionDep +from app.core import rbac from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message router = APIRouter(prefix="/items", tags=["items"]) @@ -18,7 +19,7 @@ def read_items( Retrieve items. """ - if current_user.is_superuser: + if rbac.has_permission(current_user, rbac.PERMISSION_SYSTEM_ADMIN): count_statement = select(func.count()).select_from(Item) count = session.exec(count_statement).one() statement = ( @@ -53,7 +54,9 @@ def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> item = session.get(Item, id) if not item: raise HTTPException(status_code=404, detail="Item not found") - if not current_user.is_superuser and (item.owner_id != current_user.id): + if not rbac.has_permission(current_user, rbac.PERMISSION_SYSTEM_ADMIN) and ( + item.owner_id != current_user.id + ): raise HTTPException(status_code=403, detail="Not enough permissions") return item @@ -86,7 +89,9 @@ def update_item( item = session.get(Item, id) if not item: raise HTTPException(status_code=404, detail="Item not found") - if not current_user.is_superuser and (item.owner_id != current_user.id): + if not rbac.has_permission(current_user, rbac.PERMISSION_SYSTEM_ADMIN) and ( + item.owner_id != current_user.id + ): raise HTTPException(status_code=403, detail="Not enough permissions") update_dict = item_in.model_dump(exclude_unset=True) item.sqlmodel_update(update_dict) @@ -106,7 +111,9 @@ def delete_item( item = session.get(Item, id) if not item: raise HTTPException(status_code=404, detail="Item not found") - if not current_user.is_superuser and (item.owner_id != current_user.id): + if not rbac.has_permission(current_user, rbac.PERMISSION_SYSTEM_ADMIN) and ( + item.owner_id != current_user.id + ): raise HTTPException(status_code=403, detail="Not enough permissions") session.delete(item) session.commit() diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py index 58441e37e9..27083c781a 100644 --- a/backend/app/api/routes/login.py +++ b/backend/app/api/routes/login.py @@ -6,8 +6,8 @@ from fastapi.security import OAuth2PasswordRequestForm from app import crud -from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser -from app.core import security +from app.api.deps import CurrentUser, SessionDep, require_permission +from app.core import rbac, security from app.core.config import settings from app.models import Message, NewPassword, Token, UserPublic, UserUpdate from app.utils import ( @@ -99,7 +99,7 @@ def reset_password(session: SessionDep, body: NewPassword) -> Message: @router.post( "/password-recovery-html-content/{email}", - dependencies=[Depends(get_current_active_superuser)], + dependencies=[Depends(require_permission(rbac.PERMISSION_SYSTEM_ADMIN))], response_class=HTMLResponse, ) def recover_password_html_content(email: str, session: SessionDep) -> Any: diff --git a/backend/app/api/routes/private.py b/backend/app/api/routes/private.py index 9f33ef1900..c846b5278f 100644 --- a/backend/app/api/routes/private.py +++ b/backend/app/api/routes/private.py @@ -3,10 +3,10 @@ from fastapi import APIRouter from pydantic import BaseModel +from app import crud from app.api.deps import SessionDep -from app.core.security import get_password_hash from app.models import ( - User, + UserCreate, UserPublic, ) @@ -26,13 +26,9 @@ def create_user(user_in: PrivateUserCreate, session: SessionDep) -> Any: Create a new user. """ - user = User( + user_create = UserCreate( email=user_in.email, full_name=user_in.full_name, - hashed_password=get_password_hash(user_in.password), + password=user_in.password, ) - - session.add(user) - session.commit() - - return user + return crud.create_user(session=session, user_create=user_create) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 1748f58484..5559ac48dd 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -8,8 +8,9 @@ from app.api.deps import ( CurrentUser, SessionDep, - get_current_active_superuser, + require_permission, ) +from app.core import rbac from app.core.config import settings from app.core.security import get_password_hash, verify_password from app.models import ( @@ -18,6 +19,7 @@ UpdatePassword, User, UserCreate, + UserMePublic, UserPublic, UserRegister, UsersPublic, @@ -31,7 +33,7 @@ @router.get( "/", - dependencies=[Depends(get_current_active_superuser)], + dependencies=[Depends(require_permission(rbac.PERMISSION_USERS_LIST))], response_model=UsersPublic, ) def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: @@ -52,7 +54,9 @@ def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: @router.post( - "/", dependencies=[Depends(get_current_active_superuser)], response_model=UserPublic + "/", + dependencies=[Depends(require_permission(rbac.PERMISSION_USERS_CREATE))], + response_model=UserPublic, ) def create_user(*, session: SessionDep, user_in: UserCreate) -> Any: """ @@ -121,12 +125,13 @@ def update_password_me( return Message(message="Password updated successfully") -@router.get("/me", response_model=UserPublic) +@router.get("/me", response_model=UserMePublic) def read_user_me(current_user: CurrentUser) -> Any: """ Get current user. """ - return current_user + permissions = rbac.get_permission_codes(current_user) + return UserMePublic.model_validate(current_user, update={"permissions": permissions}) @router.delete("/me", response_model=Message) @@ -134,9 +139,9 @@ def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any: """ Delete own user. """ - if current_user.is_superuser: + if rbac.has_permission(current_user, rbac.PERMISSION_SYSTEM_ADMIN): raise HTTPException( - status_code=403, detail="Super users are not allowed to delete themselves" + status_code=403, detail="Admins are not allowed to delete themselves" ) session.delete(current_user) session.commit() @@ -169,7 +174,7 @@ def read_user_by_id( user = session.get(User, user_id) if user == current_user: return user - if not current_user.is_superuser: + if not rbac.has_permission(current_user, rbac.PERMISSION_USERS_LIST): raise HTTPException( status_code=403, detail="The user doesn't have enough privileges", @@ -181,7 +186,7 @@ def read_user_by_id( @router.patch( "/{user_id}", - dependencies=[Depends(get_current_active_superuser)], + dependencies=[Depends(require_permission(rbac.PERMISSION_USERS_MANAGE))], response_model=UserPublic, ) def update_user( @@ -211,7 +216,10 @@ def update_user( return db_user -@router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)]) +@router.delete( + "/{user_id}", + dependencies=[Depends(require_permission(rbac.PERMISSION_USERS_MANAGE))], +) def delete_user( session: SessionDep, current_user: CurrentUser, user_id: uuid.UUID ) -> Message: @@ -223,7 +231,7 @@ def delete_user( raise HTTPException(status_code=404, detail="User not found") if user == current_user: raise HTTPException( - status_code=403, detail="Super users are not allowed to delete themselves" + status_code=403, detail="Admins are not allowed to delete themselves" ) statement = delete(Item).where(col(Item.owner_id) == user_id) session.exec(statement) diff --git a/backend/app/api/routes/utils.py b/backend/app/api/routes/utils.py index fc093419b3..5faa64e20b 100644 --- a/backend/app/api/routes/utils.py +++ b/backend/app/api/routes/utils.py @@ -1,7 +1,8 @@ from fastapi import APIRouter, Depends from pydantic.networks import EmailStr -from app.api.deps import get_current_active_superuser +from app.api.deps import require_permission +from app.core import rbac from app.models import Message from app.utils import generate_test_email, send_email @@ -10,7 +11,7 @@ @router.post( "/test-email/", - dependencies=[Depends(get_current_active_superuser)], + dependencies=[Depends(require_permission(rbac.PERMISSION_SYSTEM_ADMIN))], status_code=201, ) def test_email(email_to: EmailStr) -> Message: diff --git a/backend/app/core/db.py b/backend/app/core/db.py index f19ac0d3bf..beede20ca1 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -1,8 +1,9 @@ from sqlmodel import Session, create_engine, select from app import crud +from app.core import rbac from app.core.config import settings -from app.models import User, UserCreate +from app.models import Permission, Role, User, UserCreate engine = create_engine(str(settings.DATABASE_URL)) @@ -12,6 +13,34 @@ # for more details: https://github.com/fastapi/full-stack-fastapi-template/issues/28 +def _upsert_permissions(session: Session) -> dict[str, Permission]: + permissions: dict[str, Permission] = {} + for code in rbac.PERMISSION_CODES: + permission = session.exec( + select(Permission).where(Permission.code == code) + ).first() + if not permission: + permission = Permission(code=code) + session.add(permission) + session.flush() + permissions[code] = permission + return permissions + + +def _upsert_roles(session: Session, permissions: dict[str, Permission]) -> None: + for slug in rbac.ROLE_SLUGS: + role = session.exec(select(Role).where(Role.slug == slug)).first() + if not role: + role = Role(slug=slug) + session.add(role) + session.flush() + existing_codes = {permission.code for permission in role.permissions} + for code in rbac.ROLE_PERMISSIONS[slug]: + if code not in existing_codes: + role.permissions.append(permissions[code]) + session.commit() + + def init_db(session: Session) -> None: # Tables should be created with Alembic migrations # But if you don't want to use migrations, create @@ -21,6 +50,9 @@ def init_db(session: Session) -> None: # This works because the models are already imported and registered from app.models # SQLModel.metadata.create_all(engine) + permissions = _upsert_permissions(session) + _upsert_roles(session, permissions) + user = session.exec( select(User).where(User.email == settings.FIRST_SUPERUSER) ).first() @@ -28,6 +60,6 @@ def init_db(session: Session) -> None: user_in = UserCreate( email=settings.FIRST_SUPERUSER, password=settings.FIRST_SUPERUSER_PASSWORD, - is_superuser=True, + role=rbac.ROLE_ADMIN, ) user = crud.create_user(session=session, user_create=user_in) diff --git a/backend/app/crud.py b/backend/app/crud.py index a8ceba6444..8f60fd01a1 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -3,13 +3,25 @@ from sqlmodel import Session, select +from app.core import rbac from app.core.security import get_password_hash, verify_password -from app.models import Item, ItemCreate, User, UserCreate, UserUpdate +from app.models import Item, ItemCreate, Role, User, UserCreate, UserUpdate + + +def get_role_by_slug(*, session: Session, slug: str) -> Role: + role = session.exec(select(Role).where(Role.slug == slug)).first() + if role is None: + raise ValueError(f"Unknown role: {slug}") + return role def create_user(*, session: Session, user_create: UserCreate) -> User: - db_obj = User.model_validate( - user_create, update={"hashed_password": get_password_hash(user_create.password)} + role = get_role_by_slug(session=session, slug=user_create.role or rbac.ROLE_MEMBER) + user_data = user_create.model_dump(exclude={"password", "role"}) + db_obj = User( + **user_data, + hashed_password=get_password_hash(user_create.password), + role_id=role.id, ) session.add(db_obj) session.commit() @@ -21,9 +33,11 @@ def update_user(*, session: Session, db_user: User, user_in: UserUpdate) -> Any: user_data = user_in.model_dump(exclude_unset=True) extra_data = {} if "password" in user_data: - password = user_data["password"] - hashed_password = get_password_hash(password) - extra_data["hashed_password"] = hashed_password + password = user_data.pop("password") + extra_data["hashed_password"] = get_password_hash(password) + if "role" in user_data: + role_slug = user_data.pop("role") + extra_data["role_id"] = get_role_by_slug(session=session, slug=role_slug).id db_user.sqlmodel_update(user_data, update=extra_data) session.add(db_user) session.commit() diff --git a/backend/app/models.py b/backend/app/models.py index dcedf9a2f5..a48cbec049 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,7 +1,8 @@ import uuid from datetime import UTC, datetime +from typing import Any -from pydantic import EmailStr +from pydantic import EmailStr, field_validator from sqlalchemy import DateTime from sqlmodel import Field, Relationship, SQLModel @@ -10,17 +11,52 @@ def get_datetime_utc() -> datetime: return datetime.now(UTC) +# Permission-role link table +class RolePermissionLink(SQLModel, table=True): + __tablename__ = "role_permission" + + role_id: uuid.UUID = Field( + foreign_key="role.id", primary_key=True, ondelete="CASCADE" + ) + permission_id: uuid.UUID = Field( + foreign_key="permission.id", primary_key=True, ondelete="CASCADE" + ) + + +class PermissionBase(SQLModel): + code: str = Field(unique=True, index=True, max_length=64) + + +class Permission(PermissionBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + roles: list[Role] = Relationship( + back_populates="permissions", link_model=RolePermissionLink + ) + + +class RoleBase(SQLModel): + slug: str = Field(unique=True, index=True, max_length=64) + + +class Role(RoleBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + permissions: list[Permission] = Relationship( + back_populates="roles", link_model=RolePermissionLink + ) + users: list[User] = Relationship(back_populates="role") + + # Shared properties class UserBase(SQLModel): email: EmailStr = Field(unique=True, index=True, max_length=255) is_active: bool = True - is_superuser: bool = False full_name: str | None = Field(default=None, max_length=255) # Properties to receive via API on creation class UserCreate(UserBase): password: str = Field(min_length=8, max_length=128) + role: str | None = Field(default=None, max_length=64) class UserRegister(SQLModel): @@ -33,7 +69,7 @@ class UserRegister(SQLModel): class UserUpdate(SQLModel): email: EmailStr | None = Field(default=None, max_length=255) is_active: bool | None = None - is_superuser: bool | None = None + role: str | None = Field(default=None, max_length=64) full_name: str | None = Field(default=None, max_length=255) password: str | None = Field(default=None, min_length=8, max_length=128) @@ -56,6 +92,8 @@ class User(UserBase, table=True): default_factory=get_datetime_utc, sa_type=DateTime(timezone=True), # type: ignore ) + role_id: uuid.UUID = Field(foreign_key="role.id", nullable=False) + role: Role = Relationship(back_populates="users") items: list[Item] = Relationship(back_populates="owner", cascade_delete=True) @@ -63,6 +101,17 @@ class User(UserBase, table=True): class UserPublic(UserBase): id: uuid.UUID created_at: datetime | None = None + role: str + + @field_validator("role", mode="before") + @classmethod + def _role_slug(cls, value: Any) -> Any: + slug = getattr(value, "slug", None) + return slug if slug is not None else value + + +class UserMePublic(UserPublic): + permissions: list[str] class UsersPublic(SQLModel): @@ -112,6 +161,11 @@ class ItemsPublic(SQLModel): count: int +class MetricsPublic(SQLModel): + user_count: int + item_count: int + + # Generic message class Message(SQLModel): message: str diff --git a/backend/tests/api/routes/test_login.py b/backend/tests/api/routes/test_login.py index 96677a25f6..456ddaf745 100644 --- a/backend/tests/api/routes/test_login.py +++ b/backend/tests/api/routes/test_login.py @@ -4,6 +4,7 @@ from pwdlib.hashers.bcrypt import BcryptHasher from sqlmodel import Session +from app import crud from app.core.config import settings from app.core.security import get_password_hash, verify_password from app.crud import create_user @@ -89,7 +90,6 @@ def test_reset_password(client: TestClient, db: Session) -> None: full_name="Test User", password=password, is_active=True, - is_superuser=False, ) user = create_user(session=db, user_create=user_create) token = generate_password_reset_token(email=email) @@ -138,7 +138,10 @@ def test_login_with_bcrypt_password_upgrades_to_argon2( bcrypt_hash = bcrypt_hasher.hash(password) assert bcrypt_hash.startswith("$2") # bcrypt hashes start with $2 - user = User(email=email, hashed_password=bcrypt_hash, is_active=True) + member_role = crud.get_role_by_slug(session=db, slug="member") + user = User( + email=email, hashed_password=bcrypt_hash, is_active=True, role_id=member_role.id + ) db.add(user) db.commit() db.refresh(user) @@ -172,7 +175,10 @@ def test_login_with_argon2_password_keeps_hash(client: TestClient, db: Session) assert argon2_hash.startswith("$argon2") # Create user with argon2 hash - user = User(email=email, hashed_password=argon2_hash, is_active=True) + member_role = crud.get_role_by_slug(session=db, slug="member") + user = User( + email=email, hashed_password=argon2_hash, is_active=True, role_id=member_role.id + ) db.add(user) db.commit() db.refresh(user) diff --git a/backend/tests/api/routes/test_users.py b/backend/tests/api/routes/test_users.py index 9c4cdd5991..7589360b57 100644 --- a/backend/tests/api/routes/test_users.py +++ b/backend/tests/api/routes/test_users.py @@ -19,7 +19,14 @@ def test_get_users_superuser_me( current_user = r.json() assert current_user assert current_user["is_active"] is True - assert current_user["is_superuser"] + assert current_user["role"] == "admin" + assert set(current_user["permissions"]) == { + "users:list", + "users:create", + "users:manage", + "metrics:view", + "system:admin", + } assert current_user["email"] == settings.FIRST_SUPERUSER @@ -30,7 +37,8 @@ def test_get_users_normal_user_me( current_user = r.json() assert current_user assert current_user["is_active"] is True - assert current_user["is_superuser"] is False + assert current_user["role"] == "member" + assert current_user["permissions"] == [] assert current_user["email"] == settings.EMAIL_TEST_USER @@ -457,7 +465,7 @@ def test_delete_user_me_as_superuser( ) assert r.status_code == 403 response = r.json() - assert response["detail"] == "Super users are not allowed to delete themselves" + assert response["detail"] == "Admins are not allowed to delete themselves" def test_delete_user_super_user( @@ -502,7 +510,7 @@ def test_delete_user_current_super_user_error( headers=superuser_token_headers, ) assert r.status_code == 403 - assert r.json()["detail"] == "Super users are not allowed to delete themselves" + assert r.json()["detail"] == "Admins are not allowed to delete themselves" def test_delete_user_without_privileges( diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 7cdabf3c45..65259ca93f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -40,3 +40,10 @@ def normal_user_token_headers(client: TestClient, db: Session) -> dict[str, str] return authentication_token_from_email( client=client, email=settings.EMAIL_TEST_USER, db=db ) + + +@pytest.fixture(scope="module") +def manager_user_token_headers(client: TestClient, db: Session) -> dict[str, str]: + return authentication_token_from_email( + client=client, email="manager-fixture@example.com", db=db, role="manager" + ) diff --git a/backend/tests/crud/test_user.py b/backend/tests/crud/test_user.py index 3db77ef624..6b6bb222d6 100644 --- a/backend/tests/crud/test_user.py +++ b/backend/tests/crud/test_user.py @@ -50,26 +50,26 @@ def test_check_if_user_is_active_inactive(db: Session) -> None: assert user.is_active is False -def test_check_if_user_is_superuser(db: Session) -> None: +def test_create_user_with_role(db: Session) -> None: email = random_email() password = random_lower_string() - user_in = UserCreate(email=email, password=password, is_superuser=True) + user_in = UserCreate(email=email, password=password, role="admin") user = crud.create_user(session=db, user_create=user_in) - assert user.is_superuser is True + assert user.role.slug == "admin" -def test_check_if_user_is_superuser_normal_user(db: Session) -> None: +def test_create_user_default_role(db: Session) -> None: username = random_email() password = random_lower_string() user_in = UserCreate(email=username, password=password) user = crud.create_user(session=db, user_create=user_in) - assert user.is_superuser is False + assert user.role.slug == "member" def test_get_user(db: Session) -> None: password = random_lower_string() username = random_email() - user_in = UserCreate(email=username, password=password, is_superuser=True) + user_in = UserCreate(email=username, password=password, role="admin") user = crud.create_user(session=db, user_create=user_in) user_2 = db.get(User, user.id) assert user_2 @@ -80,10 +80,10 @@ def test_get_user(db: Session) -> None: def test_update_user(db: Session) -> None: password = random_lower_string() email = random_email() - user_in = UserCreate(email=email, password=password, is_superuser=True) + user_in = UserCreate(email=email, password=password, role="admin") user = crud.create_user(session=db, user_create=user_in) new_password = random_lower_string() - user_in_update = UserUpdate(password=new_password, is_superuser=True) + user_in_update = UserUpdate(password=new_password, role="admin") if user.id is not None: crud.update_user(session=db, db_user=user, user_in=user_in_update) user_2 = db.get(User, user.id) @@ -104,7 +104,8 @@ def test_authenticate_user_with_bcrypt_upgrades_to_argon2(db: Session) -> None: assert bcrypt_hash.startswith("$2") # bcrypt hashes start with $2 # Create user with bcrypt hash directly in the database - user = User(email=email, hashed_password=bcrypt_hash) + member_role = crud.get_role_by_slug(session=db, slug="member") + user = User(email=email, hashed_password=bcrypt_hash, role_id=member_role.id) db.add(user) db.commit() db.refresh(user) diff --git a/backend/tests/utils/user.py b/backend/tests/utils/user.py index 5867431ed8..75e4878148 100644 --- a/backend/tests/utils/user.py +++ b/backend/tests/utils/user.py @@ -28,17 +28,18 @@ def create_random_user(db: Session) -> User: def authentication_token_from_email( - *, client: TestClient, email: str, db: Session + *, client: TestClient, email: str, db: Session, role: str | None = None ) -> dict[str, str]: """ Return a valid token for the user with given email. - If the user doesn't exist it is created first. + If the user doesn't exist it is created first, with the given role + (defaults to `member`). """ password = random_lower_string() user = crud.get_user_by_email(session=db, email=email) if not user: - user_in_create = UserCreate(email=email, password=password) + user_in_create = UserCreate(email=email, password=password, role=role) user = crud.create_user(session=db, user_create=user_in_create) else: user_in_update = UserUpdate(password=password) diff --git a/frontend/src/client/index.ts b/frontend/src/client/index.ts index f4fdf30796..ecf48c55ea 100644 --- a/frontend/src/client/index.ts +++ b/frontend/src/client/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { ItemsService, LoginService, type Options, PrivateService, UsersService, UtilsService } from './sdk.gen'; -export type { Body_login_login_access_token, ClientOptions, HTTPValidationError, ItemCreate, ItemPublic, itemsCreateItemData, itemsCreateItemError, itemsCreateItemErrors, itemsCreateItemResponse, itemsCreateItemResponses, itemsDeleteItemData, itemsDeleteItemError, itemsDeleteItemErrors, itemsDeleteItemResponse, itemsDeleteItemResponses, ItemsPublic, itemsReadItemData, itemsReadItemError, itemsReadItemErrors, itemsReadItemResponse, itemsReadItemResponses, itemsReadItemsData, itemsReadItemsError, itemsReadItemsErrors, itemsReadItemsResponse, itemsReadItemsResponses, itemsUpdateItemData, itemsUpdateItemError, itemsUpdateItemErrors, itemsUpdateItemResponse, itemsUpdateItemResponses, ItemUpdate, loginLoginAccessTokenData, loginLoginAccessTokenError, loginLoginAccessTokenErrors, loginLoginAccessTokenResponse, loginLoginAccessTokenResponses, loginRecoverPasswordData, loginRecoverPasswordError, loginRecoverPasswordErrors, loginRecoverPasswordHtmlContentData, loginRecoverPasswordHtmlContentError, loginRecoverPasswordHtmlContentErrors, loginRecoverPasswordHtmlContentResponse, loginRecoverPasswordHtmlContentResponses, loginRecoverPasswordResponse, loginRecoverPasswordResponses, loginResetPasswordData, loginResetPasswordError, loginResetPasswordErrors, loginResetPasswordResponse, loginResetPasswordResponses, loginTestTokenData, loginTestTokenResponse, loginTestTokenResponses, Message, NewPassword, privateCreateUserData, privateCreateUserError, privateCreateUserErrors, privateCreateUserResponse, privateCreateUserResponses, PrivateUserCreate, Token, UpdatePassword, UserCreate, UserPublic, UserRegister, usersCreateUserData, usersCreateUserError, usersCreateUserErrors, usersCreateUserResponse, usersCreateUserResponses, usersDeleteUserData, usersDeleteUserError, usersDeleteUserErrors, usersDeleteUserMeData, usersDeleteUserMeResponse, usersDeleteUserMeResponses, usersDeleteUserResponse, usersDeleteUserResponses, UsersPublic, usersReadUserByIdData, usersReadUserByIdError, usersReadUserByIdErrors, usersReadUserByIdResponse, usersReadUserByIdResponses, usersReadUserMeData, usersReadUserMeResponse, usersReadUserMeResponses, usersReadUsersData, usersReadUsersError, usersReadUsersErrors, usersReadUsersResponse, usersReadUsersResponses, usersRegisterUserData, usersRegisterUserError, usersRegisterUserErrors, usersRegisterUserResponse, usersRegisterUserResponses, usersUpdatePasswordMeData, usersUpdatePasswordMeError, usersUpdatePasswordMeErrors, usersUpdatePasswordMeResponse, usersUpdatePasswordMeResponses, usersUpdateUserData, usersUpdateUserError, usersUpdateUserErrors, usersUpdateUserMeData, usersUpdateUserMeError, usersUpdateUserMeErrors, usersUpdateUserMeResponse, usersUpdateUserMeResponses, usersUpdateUserResponse, usersUpdateUserResponses, UserUpdate, UserUpdateMe, utilsHealthCheckData, utilsHealthCheckResponse, utilsHealthCheckResponses, utilsTestEmailData, utilsTestEmailError, utilsTestEmailErrors, utilsTestEmailResponse, utilsTestEmailResponses, ValidationError } from './types.gen'; +export { ItemsService, LoginService, MetricsService, type Options, PrivateService, UsersService, UtilsService } from './sdk.gen'; +export type { Body_login_login_access_token, ClientOptions, HTTPValidationError, ItemCreate, ItemPublic, itemsCreateItemData, itemsCreateItemError, itemsCreateItemErrors, itemsCreateItemResponse, itemsCreateItemResponses, itemsDeleteItemData, itemsDeleteItemError, itemsDeleteItemErrors, itemsDeleteItemResponse, itemsDeleteItemResponses, ItemsPublic, itemsReadItemData, itemsReadItemError, itemsReadItemErrors, itemsReadItemResponse, itemsReadItemResponses, itemsReadItemsData, itemsReadItemsError, itemsReadItemsErrors, itemsReadItemsResponse, itemsReadItemsResponses, itemsUpdateItemData, itemsUpdateItemError, itemsUpdateItemErrors, itemsUpdateItemResponse, itemsUpdateItemResponses, ItemUpdate, loginLoginAccessTokenData, loginLoginAccessTokenError, loginLoginAccessTokenErrors, loginLoginAccessTokenResponse, loginLoginAccessTokenResponses, loginRecoverPasswordData, loginRecoverPasswordError, loginRecoverPasswordErrors, loginRecoverPasswordHtmlContentData, loginRecoverPasswordHtmlContentError, loginRecoverPasswordHtmlContentErrors, loginRecoverPasswordHtmlContentResponse, loginRecoverPasswordHtmlContentResponses, loginRecoverPasswordResponse, loginRecoverPasswordResponses, loginResetPasswordData, loginResetPasswordError, loginResetPasswordErrors, loginResetPasswordResponse, loginResetPasswordResponses, loginTestTokenData, loginTestTokenResponse, loginTestTokenResponses, Message, MetricsPublic, metricsReadMetricsData, metricsReadMetricsResponse, metricsReadMetricsResponses, NewPassword, privateCreateUserData, privateCreateUserError, privateCreateUserErrors, privateCreateUserResponse, privateCreateUserResponses, PrivateUserCreate, Token, UpdatePassword, UserCreate, UserMePublic, UserPublic, UserRegister, usersCreateUserData, usersCreateUserError, usersCreateUserErrors, usersCreateUserResponse, usersCreateUserResponses, usersDeleteUserData, usersDeleteUserError, usersDeleteUserErrors, usersDeleteUserMeData, usersDeleteUserMeResponse, usersDeleteUserMeResponses, usersDeleteUserResponse, usersDeleteUserResponses, UsersPublic, usersReadUserByIdData, usersReadUserByIdError, usersReadUserByIdErrors, usersReadUserByIdResponse, usersReadUserByIdResponses, usersReadUserMeData, usersReadUserMeResponse, usersReadUserMeResponses, usersReadUsersData, usersReadUsersError, usersReadUsersErrors, usersReadUsersResponse, usersReadUsersResponses, usersRegisterUserData, usersRegisterUserError, usersRegisterUserErrors, usersRegisterUserResponse, usersRegisterUserResponses, usersUpdatePasswordMeData, usersUpdatePasswordMeError, usersUpdatePasswordMeErrors, usersUpdatePasswordMeResponse, usersUpdatePasswordMeResponses, usersUpdateUserData, usersUpdateUserError, usersUpdateUserErrors, usersUpdateUserMeData, usersUpdateUserMeError, usersUpdateUserMeErrors, usersUpdateUserMeResponse, usersUpdateUserMeResponses, usersUpdateUserResponse, usersUpdateUserResponses, UserUpdate, UserUpdateMe, utilsHealthCheckData, utilsHealthCheckResponse, utilsHealthCheckResponses, utilsTestEmailData, utilsTestEmailError, utilsTestEmailErrors, utilsTestEmailResponse, utilsTestEmailResponses, ValidationError } from './types.gen'; diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index b1dd2cf1d5..00835f3ddb 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -2,7 +2,7 @@ import { type Client, type Options as Options2, type TDataShape, urlSearchParamsBodySerializer } from './client'; import { client } from './client.gen'; -import type { itemsCreateItemData, itemsCreateItemErrors, itemsCreateItemResponses, itemsDeleteItemData, itemsDeleteItemErrors, itemsDeleteItemResponses, itemsReadItemData, itemsReadItemErrors, itemsReadItemResponses, itemsReadItemsData, itemsReadItemsErrors, itemsReadItemsResponses, itemsUpdateItemData, itemsUpdateItemErrors, itemsUpdateItemResponses, loginLoginAccessTokenData, loginLoginAccessTokenErrors, loginLoginAccessTokenResponses, loginRecoverPasswordData, loginRecoverPasswordErrors, loginRecoverPasswordHtmlContentData, loginRecoverPasswordHtmlContentErrors, loginRecoverPasswordHtmlContentResponses, loginRecoverPasswordResponses, loginResetPasswordData, loginResetPasswordErrors, loginResetPasswordResponses, loginTestTokenData, loginTestTokenResponses, privateCreateUserData, privateCreateUserErrors, privateCreateUserResponses, usersCreateUserData, usersCreateUserErrors, usersCreateUserResponses, usersDeleteUserData, usersDeleteUserErrors, usersDeleteUserMeData, usersDeleteUserMeResponses, usersDeleteUserResponses, usersReadUserByIdData, usersReadUserByIdErrors, usersReadUserByIdResponses, usersReadUserMeData, usersReadUserMeResponses, usersReadUsersData, usersReadUsersErrors, usersReadUsersResponses, usersRegisterUserData, usersRegisterUserErrors, usersRegisterUserResponses, usersUpdatePasswordMeData, usersUpdatePasswordMeErrors, usersUpdatePasswordMeResponses, usersUpdateUserData, usersUpdateUserErrors, usersUpdateUserMeData, usersUpdateUserMeErrors, usersUpdateUserMeResponses, usersUpdateUserResponses, utilsHealthCheckData, utilsHealthCheckResponses, utilsTestEmailData, utilsTestEmailErrors, utilsTestEmailResponses } from './types.gen'; +import type { itemsCreateItemData, itemsCreateItemErrors, itemsCreateItemResponses, itemsDeleteItemData, itemsDeleteItemErrors, itemsDeleteItemResponses, itemsReadItemData, itemsReadItemErrors, itemsReadItemResponses, itemsReadItemsData, itemsReadItemsErrors, itemsReadItemsResponses, itemsUpdateItemData, itemsUpdateItemErrors, itemsUpdateItemResponses, loginLoginAccessTokenData, loginLoginAccessTokenErrors, loginLoginAccessTokenResponses, loginRecoverPasswordData, loginRecoverPasswordErrors, loginRecoverPasswordHtmlContentData, loginRecoverPasswordHtmlContentErrors, loginRecoverPasswordHtmlContentResponses, loginRecoverPasswordResponses, loginResetPasswordData, loginResetPasswordErrors, loginResetPasswordResponses, loginTestTokenData, loginTestTokenResponses, metricsReadMetricsData, metricsReadMetricsResponses, privateCreateUserData, privateCreateUserErrors, privateCreateUserResponses, usersCreateUserData, usersCreateUserErrors, usersCreateUserResponses, usersDeleteUserData, usersDeleteUserErrors, usersDeleteUserMeData, usersDeleteUserMeResponses, usersDeleteUserResponses, usersReadUserByIdData, usersReadUserByIdErrors, usersReadUserByIdResponses, usersReadUserMeData, usersReadUserMeResponses, usersReadUsersData, usersReadUsersErrors, usersReadUsersResponses, usersRegisterUserData, usersRegisterUserErrors, usersRegisterUserResponses, usersUpdatePasswordMeData, usersUpdatePasswordMeErrors, usersUpdatePasswordMeResponses, usersUpdateUserData, usersUpdateUserErrors, usersUpdateUserMeData, usersUpdateUserMeErrors, usersUpdateUserMeResponses, usersUpdateUserResponses, utilsHealthCheckData, utilsHealthCheckResponses, utilsTestEmailData, utilsTestEmailErrors, utilsTestEmailResponses } from './types.gen'; export type Options = Options2 & { /** @@ -364,6 +364,22 @@ export class ItemsService { } } +export class MetricsService { + /** + * Read Metrics + * + * Basic usage metrics. A stub — not a real analytics pipeline. + */ + public static readMetrics(options?: Options) { + return (options?.client ?? client).get({ + responseType: 'json', + security: [{ scheme: 'bearer', type: 'http' }], + url: '/api/v1/metrics/', + ...options + }); + } +} + export class PrivateService { /** * Create User diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 6252d88c2f..74b227abb0 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -122,6 +122,20 @@ export type Message = { message: string; }; +/** + * MetricsPublic + */ +export type MetricsPublic = { + /** + * User Count + */ + user_count: number; + /** + * Item Count + */ + item_count: number; +}; + /** * NewPassword */ @@ -198,10 +212,6 @@ export type UserCreate = { * Is Active */ is_active?: boolean; - /** - * Is Superuser - */ - is_superuser?: boolean; /** * Full Name */ @@ -210,12 +220,16 @@ export type UserCreate = { * Password */ password: string; + /** + * Role + */ + role?: string | null; }; /** - * UserPublic + * UserMePublic */ -export type UserPublic = { +export type UserMePublic = { /** * Email */ @@ -225,9 +239,39 @@ export type UserPublic = { */ is_active?: boolean; /** - * Is Superuser + * Full Name + */ + full_name?: string | null; + /** + * Id + */ + id: string; + /** + * Created At + */ + created_at?: string | null; + /** + * Role + */ + role: string; + /** + * Permissions */ - is_superuser?: boolean; + permissions: Array; +}; + +/** + * UserPublic + */ +export type UserPublic = { + /** + * Email + */ + email: string; + /** + * Is Active + */ + is_active?: boolean; /** * Full Name */ @@ -240,6 +284,10 @@ export type UserPublic = { * Created At */ created_at?: string | null; + /** + * Role + */ + role: string; }; /** @@ -273,9 +321,9 @@ export type UserUpdate = { */ is_active?: boolean | null; /** - * Is Superuser + * Role */ - is_superuser?: boolean | null; + role?: string | null; /** * Full Name */ @@ -554,7 +602,7 @@ export type usersReadUserMeResponses = { /** * Successful Response */ - 200: UserPublic; + 200: UserMePublic; }; export type usersReadUserMeResponse = usersReadUserMeResponses[keyof usersReadUserMeResponses]; @@ -921,6 +969,22 @@ export type itemsUpdateItemResponses = { export type itemsUpdateItemResponse = itemsUpdateItemResponses[keyof itemsUpdateItemResponses]; +export type metricsReadMetricsData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/metrics/'; +}; + +export type metricsReadMetricsResponses = { + /** + * Successful Response + */ + 200: MetricsPublic; +}; + +export type metricsReadMetricsResponse = metricsReadMetricsResponses[keyof metricsReadMetricsResponses]; + export type privateCreateUserData = { body: PrivateUserCreate; path?: never; diff --git a/frontend/src/components/Admin/AddUser.tsx b/frontend/src/components/Admin/AddUser.tsx index 0cb83b2d46..2a46fa7cb1 100644 --- a/frontend/src/components/Admin/AddUser.tsx +++ b/frontend/src/components/Admin/AddUser.tsx @@ -28,8 +28,16 @@ import { } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { LoadingButton } from "@/components/ui/loading-button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import useCustomToast from "@/hooks/useCustomToast" import { handleError } from "@/utils" +import { ROLE_OPTIONS } from "./roleOptions" const formSchema = z .object({ @@ -42,7 +50,7 @@ const formSchema = z confirm_password: z .string() .min(1, { message: "Please confirm your password" }), - is_superuser: z.boolean(), + role: z.enum(["member", "manager", "admin"]), is_active: z.boolean(), }) .refine((data) => data.password === data.confirm_password, { @@ -66,7 +74,7 @@ const AddUser = () => { full_name: "", password: "", confirm_password: "", - is_superuser: false, + role: "member", is_active: false, }, }) @@ -186,16 +194,25 @@ const AddUser = () => { ( - - - - - Is superuser? + + Role + + )} /> diff --git a/frontend/src/components/Admin/EditUser.tsx b/frontend/src/components/Admin/EditUser.tsx index 9d001a2a3e..9ceb93513c 100644 --- a/frontend/src/components/Admin/EditUser.tsx +++ b/frontend/src/components/Admin/EditUser.tsx @@ -28,8 +28,16 @@ import { } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { LoadingButton } from "@/components/ui/loading-button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import useCustomToast from "@/hooks/useCustomToast" import { handleError } from "@/utils" +import { ROLE_OPTIONS } from "./roleOptions" const formSchema = z .object({ @@ -41,7 +49,7 @@ const formSchema = z .optional() .or(z.literal("")), confirm_password: z.string().optional(), - is_superuser: z.boolean().optional(), + role: z.enum(["member", "manager", "admin"]).optional(), is_active: z.boolean().optional(), }) .refine((data) => !data.password || data.password === data.confirm_password, { @@ -68,7 +76,9 @@ const EditUser = ({ user, onSuccess }: EditUserProps) => { defaultValues: { email: user.email, full_name: user.full_name ?? undefined, - is_superuser: user.is_superuser, + role: ROLE_OPTIONS.some((option) => option.value === user.role) + ? (user.role as FormData["role"]) + : "member", is_active: user.is_active, }, }) @@ -188,16 +198,25 @@ const EditUser = ({ user, onSuccess }: EditUserProps) => { ( - - - - - Is superuser? + + Role + + )} /> diff --git a/frontend/src/components/Admin/UserActionsMenu.tsx b/frontend/src/components/Admin/UserActionsMenu.tsx index 01f71cbb7a..14e69c87d2 100644 --- a/frontend/src/components/Admin/UserActionsMenu.tsx +++ b/frontend/src/components/Admin/UserActionsMenu.tsx @@ -9,6 +9,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import useAuth from "@/hooks/useAuth" +import { hasPermission, PERMISSIONS } from "@/utils" import DeleteUser from "./DeleteUser" import EditUser from "./EditUser" @@ -24,10 +25,14 @@ export const UserActionsMenu = ({ user }: UserActionsMenuProps) => { return null } + if (!hasPermission(currentUser, PERMISSIONS.usersManage)) { + return null + } + return ( - diff --git a/frontend/src/components/Admin/columns.tsx b/frontend/src/components/Admin/columns.tsx index 8b0fa13eef..b94958cc54 100644 --- a/frontend/src/components/Admin/columns.tsx +++ b/frontend/src/components/Admin/columns.tsx @@ -39,11 +39,11 @@ export const columns: ColumnDef[] = [ ), }, { - accessorKey: "is_superuser", + accessorKey: "role", header: "Role", cell: ({ row }) => ( - - {row.original.is_superuser ? "Superuser" : "User"} + + {row.original.role} ), }, diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index 8502bcb9a4..8c31b688a9 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -1,4 +1,4 @@ -import { Briefcase, Home, Users } from "lucide-react" +import { BarChart3, Briefcase, Home, Users } from "lucide-react" import { SidebarAppearance } from "@/components/Common/Appearance" import { Logo } from "@/components/Common/Logo" @@ -9,6 +9,7 @@ import { SidebarHeader, } from "@/components/ui/sidebar" import useAuth from "@/hooks/useAuth" +import { hasPermission, PERMISSIONS } from "@/utils" import { type Item, Main } from "./Main" import { User } from "./User" @@ -20,9 +21,15 @@ const baseItems: Item[] = [ export function AppSidebar() { const { user: currentUser } = useAuth() - const items = currentUser?.is_superuser - ? [...baseItems, { icon: Users, title: "Admin", path: "/admin" }] - : baseItems + const items = [ + ...baseItems, + ...(hasPermission(currentUser, PERMISSIONS.usersList) + ? [{ icon: Users, title: "Admin", path: "/admin" }] + : []), + ...(hasPermission(currentUser, PERMISSIONS.metricsView) + ? [{ icon: BarChart3, title: "Metrics", path: "/metrics" }] + : []), + ] return ( diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 051b222aee..e4068ec454 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -4,7 +4,7 @@ import { useNavigate } from "@tanstack/react-router" import { type Body_login_login_access_token as AccessToken, LoginService, - type UserPublic, + type UserMePublic, type UserRegister, UsersService, } from "@/client" @@ -20,7 +20,7 @@ const useAuth = () => { const queryClient = useQueryClient() const { showErrorToast } = useCustomToast() - const { data: user } = useQuery({ + const { data: user } = useQuery({ queryKey: ["currentUser"], queryFn: async () => (await UsersService.readUserMe()).data, enabled: isLoggedIn(), diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index b45f83a1d3..c6fa6e5da7 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -16,7 +16,9 @@ import { Route as ResetPasswordRouteImport } from './routes/reset-password' import { Route as SignupRouteImport } from './routes/signup' import { Route as LayoutIndexRouteImport } from './routes/_layout/index' import { Route as LayoutAdminRouteImport } from './routes/_layout/admin' +import { Route as LayoutForbiddenRouteImport } from './routes/_layout/forbidden' import { Route as LayoutItemsRouteImport } from './routes/_layout/items' +import { Route as LayoutMetricsRouteImport } from './routes/_layout/metrics' import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings' const LayoutRoute = LayoutRouteImport.update({ @@ -53,11 +55,21 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({ path: '/admin', getParentRoute: () => LayoutRoute, } as any) +const LayoutForbiddenRoute = LayoutForbiddenRouteImport.update({ + id: '/forbidden', + path: '/forbidden', + getParentRoute: () => LayoutRoute, +} as any) const LayoutItemsRoute = LayoutItemsRouteImport.update({ id: '/items', path: '/items', getParentRoute: () => LayoutRoute, } as any) +const LayoutMetricsRoute = LayoutMetricsRouteImport.update({ + id: '/metrics', + path: '/metrics', + getParentRoute: () => LayoutRoute, +} as any) const LayoutSettingsRoute = LayoutSettingsRouteImport.update({ id: '/settings', path: '/settings', @@ -71,7 +83,9 @@ export interface FileRoutesByFullPath { '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute '/admin': typeof LayoutAdminRoute + '/forbidden': typeof LayoutForbiddenRoute '/items': typeof LayoutItemsRoute + '/metrics': typeof LayoutMetricsRoute '/settings': typeof LayoutSettingsRoute } export interface FileRoutesByTo { @@ -80,7 +94,9 @@ export interface FileRoutesByTo { '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute '/admin': typeof LayoutAdminRoute + '/forbidden': typeof LayoutForbiddenRoute '/items': typeof LayoutItemsRoute + '/metrics': typeof LayoutMetricsRoute '/settings': typeof LayoutSettingsRoute '/': typeof LayoutIndexRoute } @@ -92,7 +108,9 @@ export interface FileRoutesById { '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute '/_layout/admin': typeof LayoutAdminRoute + '/_layout/forbidden': typeof LayoutForbiddenRoute '/_layout/items': typeof LayoutItemsRoute + '/_layout/metrics': typeof LayoutMetricsRoute '/_layout/settings': typeof LayoutSettingsRoute '/_layout/': typeof LayoutIndexRoute } @@ -105,7 +123,9 @@ export interface FileRouteTypes { | '/reset-password' | '/signup' | '/admin' + | '/forbidden' | '/items' + | '/metrics' | '/settings' fileRoutesByTo: FileRoutesByTo to: @@ -114,7 +134,9 @@ export interface FileRouteTypes { | '/reset-password' | '/signup' | '/admin' + | '/forbidden' | '/items' + | '/metrics' | '/settings' | '/' id: @@ -125,7 +147,9 @@ export interface FileRouteTypes { | '/reset-password' | '/signup' | '/_layout/admin' + | '/_layout/forbidden' | '/_layout/items' + | '/_layout/metrics' | '/_layout/settings' | '/_layout/' fileRoutesById: FileRoutesById @@ -189,6 +213,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAdminRouteImport parentRoute: typeof LayoutRoute } + '/_layout/forbidden': { + id: '/_layout/forbidden' + path: '/forbidden' + fullPath: '/forbidden' + preLoaderRoute: typeof LayoutForbiddenRouteImport + parentRoute: typeof LayoutRoute + } '/_layout/items': { id: '/_layout/items' path: '/items' @@ -196,6 +227,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutItemsRouteImport parentRoute: typeof LayoutRoute } + '/_layout/metrics': { + id: '/_layout/metrics' + path: '/metrics' + fullPath: '/metrics' + preLoaderRoute: typeof LayoutMetricsRouteImport + parentRoute: typeof LayoutRoute + } '/_layout/settings': { id: '/_layout/settings' path: '/settings' @@ -208,14 +246,18 @@ declare module '@tanstack/react-router' { interface LayoutRouteChildren { LayoutAdminRoute: typeof LayoutAdminRoute + LayoutForbiddenRoute: typeof LayoutForbiddenRoute LayoutItemsRoute: typeof LayoutItemsRoute + LayoutMetricsRoute: typeof LayoutMetricsRoute LayoutSettingsRoute: typeof LayoutSettingsRoute LayoutIndexRoute: typeof LayoutIndexRoute } const LayoutRouteChildren: LayoutRouteChildren = { LayoutAdminRoute: LayoutAdminRoute, + LayoutForbiddenRoute: LayoutForbiddenRoute, LayoutItemsRoute: LayoutItemsRoute, + LayoutMetricsRoute: LayoutMetricsRoute, LayoutSettingsRoute: LayoutSettingsRoute, LayoutIndexRoute: LayoutIndexRoute, } diff --git a/frontend/src/routes/_layout/admin.tsx b/frontend/src/routes/_layout/admin.tsx index 39241c23ca..0db2cc7aac 100644 --- a/frontend/src/routes/_layout/admin.tsx +++ b/frontend/src/routes/_layout/admin.tsx @@ -8,6 +8,7 @@ import { columns, type UserTableData } from "@/components/Admin/columns" import { DataTable } from "@/components/Common/DataTable" import PendingUsers from "@/components/Pending/PendingUsers" import useAuth from "@/hooks/useAuth" +import { hasPermission, PERMISSIONS } from "@/utils" function getUsersQueryOptions() { return { @@ -21,9 +22,9 @@ export const Route = createFileRoute("/_layout/admin")({ component: Admin, beforeLoad: async () => { const { data: user } = await UsersService.readUserMe() - if (!user.is_superuser) { + if (!hasPermission(user, PERMISSIONS.usersList)) { throw redirect({ - to: "/", + to: "/forbidden", }) } }, @@ -57,6 +58,8 @@ function UsersTable() { } function Admin() { + const { user: currentUser } = useAuth() + return (
@@ -66,7 +69,7 @@ function Admin() { Manage user accounts and permissions

- + {hasPermission(currentUser, PERMISSIONS.usersCreate) && }
diff --git a/frontend/src/routes/_layout/settings.tsx b/frontend/src/routes/_layout/settings.tsx index e109b5ae81..42ecadaae0 100644 --- a/frontend/src/routes/_layout/settings.tsx +++ b/frontend/src/routes/_layout/settings.tsx @@ -5,6 +5,7 @@ import DeleteAccount from "@/components/UserSettings/DeleteAccount" import UserInformation from "@/components/UserSettings/UserInformation" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import useAuth from "@/hooks/useAuth" +import { hasPermission, PERMISSIONS } from "@/utils" const tabsConfig = [ { value: "my-profile", title: "My profile", component: UserInformation }, @@ -25,8 +26,8 @@ export const Route = createFileRoute("/_layout/settings")({ function UserSettings() { const { user: currentUser } = useAuth() - const finalTabs = currentUser?.is_superuser - ? tabsConfig.slice(0, 3) + const finalTabs = hasPermission(currentUser, PERMISSIONS.systemAdmin) + ? tabsConfig.filter((tab) => tab.value !== "danger-zone") : tabsConfig if (!currentUser) { diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 967bb471ee..e62bca65c2 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -1,5 +1,7 @@ import { AxiosError } from "axios" +import type { UserMePublic } from "@/client" + function extractErrorMessage(err: Error): string { if (err instanceof AxiosError) { const errDetail = (err.response?.data as any)?.detail @@ -27,3 +29,18 @@ export const getInitials = (name: string): string => { .join("") .toUpperCase() } + +export const PERMISSIONS = { + usersList: "users:list", + usersCreate: "users:create", + usersManage: "users:manage", + metricsView: "metrics:view", + systemAdmin: "system:admin", +} as const + +export const hasPermission = ( + user: UserMePublic | null | undefined, + code: string, +): boolean => { + return user?.permissions.includes(code) ?? false +} diff --git a/frontend/tests/admin.spec.ts b/frontend/tests/admin.spec.ts index cd73dbad5c..475b1c12f9 100644 --- a/frontend/tests/admin.spec.ts +++ b/frontend/tests/admin.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test" import { firstSuperuser, firstSuperuserPassword } from "./config.ts" import { createUser } from "./utils/privateApi" import { randomEmail, randomPassword } from "./utils/random" -import { logInUser } from "./utils/user" +import { logInUser, logOutUser } from "./utils/user" test("Admin page is accessible and shows correct title", async ({ page }) => { await page.goto("/admin") @@ -42,7 +42,7 @@ test.describe("Admin user management", () => { await expect(userRow).toBeVisible() }) - test("Create a superuser", async ({ page }) => { + test("Create an admin user", async ({ page }) => { await page.goto("/admin") const email = randomEmail() @@ -53,7 +53,10 @@ test.describe("Admin user management", () => { await page.getByPlaceholder("Email").fill(email) await page.getByPlaceholder("Password").first().fill(password) await page.getByPlaceholder("Password").last().fill(password) - await page.getByLabel("Is superuser?").check() + + const dialog = page.getByRole("dialog") + await dialog.getByRole("combobox").click() + await page.getByRole("option", { name: "Admin" }).click() await page.getByLabel("Is active?").check() await page.getByRole("button", { name: "Save" }).click() @@ -63,7 +66,7 @@ test.describe("Admin user management", () => { await expect(page.getByRole("dialog")).not.toBeVisible() const userRow = page.getByRole("row").filter({ hasText: email }) - await expect(userRow.getByText("Superuser")).toBeVisible() + await expect(userRow.getByText("admin")).toBeVisible() }) test("Edit a user successfully", async ({ page }) => { @@ -182,7 +185,9 @@ test.describe("Admin user management", () => { test.describe("Admin page access control", () => { test.use({ storageState: { cookies: [], origins: [] } }) - test("Non-superuser cannot access admin page", async ({ page }) => { + test("Member sees Access Denied when navigating directly to admin page", async ({ + page, + }) => { const email = randomEmail() const password = randomPassword() @@ -192,14 +197,61 @@ test.describe("Admin page access control", () => { await page.goto("/admin") await expect(page.getByRole("heading", { name: "Users" })).not.toBeVisible() - await expect(page).not.toHaveURL(/\/admin/) + await expect(page).toHaveURL(/\/forbidden/) + await expect(page.getByTestId("access-denied")).toBeVisible() + await expect(page.getByText("Access Denied")).toBeVisible() + }) + + test("Member does not see the Admin nav link", async ({ page }) => { + const email = randomEmail() + const password = randomPassword() + + await createUser({ email, password }) + await logInUser(page, email, password) + + await expect( + page.getByRole("link", { name: "Admin", exact: true }), + ).not.toBeVisible() + }) + + test("Admin can access admin page", async ({ page }) => { + await logInUser(page, firstSuperuser, firstSuperuserPassword) + + await page.goto("/admin") + + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible() }) - test("Superuser can access admin page", async ({ page }) => { + test("Manager can list users but not manage them", async ({ page }) => { await logInUser(page, firstSuperuser, firstSuperuserPassword) + await page.goto("/admin") + const managerEmail = randomEmail() + const managerPassword = randomPassword() + + await page.getByRole("button", { name: "Add User" }).click() + await page.getByPlaceholder("Email").fill(managerEmail) + await page.getByPlaceholder("Password").first().fill(managerPassword) + await page.getByPlaceholder("Password").last().fill(managerPassword) + const dialog = page.getByRole("dialog") + await dialog.getByRole("combobox").click() + await page.getByRole("option", { name: "Manager" }).click() + await page.getByLabel("Is active?").check() + await page.getByRole("button", { name: "Save" }).click() + await expect(page.getByText("User created successfully")).toBeVisible() + + await logOutUser(page) + await logInUser(page, managerEmail, managerPassword) await page.goto("/admin") await expect(page.getByRole("heading", { name: "Users" })).toBeVisible() + await expect( + page.getByRole("button", { name: "Add User" }), + ).not.toBeVisible() + + const otherUserRow = page.getByRole("row").filter({ hasText: firstSuperuser }) + await expect( + otherUserRow.getByRole("button", { name: "User actions" }), + ).not.toBeVisible() }) }) From 09009258599b33b98687d1d8a180185a4a1e4713 Mon Sep 17 00:00:00 2001 From: dehbok Date: Fri, 21 Aug 2026 08:44:11 +0200 Subject: [PATCH 2/6] Added openspec, engineering rules. Added proposal, tasks. Developed feature. Added tests. Added documentation. --- CLAUDE.md | 37 ++++ ...e9889b29_add_rbac_roles_and_permissions.py | 149 ++++++++++++++++ backend/app/api/routes/metrics.py | 22 +++ backend/app/core/rbac.py | 45 +++++ backend/tests/api/routes/test_rbac.py | 80 +++++++++ docs/AUTHORIZATION.md | 93 ++++++++++ frontend/src/components/Admin/roleOptions.ts | 5 + .../src/components/Common/AccessDenied.tsx | 31 ++++ frontend/src/routes/_layout/forbidden.tsx | 14 ++ frontend/src/routes/_layout/metrics.tsx | 90 ++++++++++ openspec/changes/add-rbac/.openspec.yaml | 2 + openspec/changes/add-rbac/design.md | 132 ++++++++++++++ openspec/changes/add-rbac/proposal.md | 33 ++++ openspec/changes/add-rbac/specs/rbac/spec.md | 167 ++++++++++++++++++ openspec/changes/add-rbac/tasks.md | 29 +++ openspec/config.yaml | 32 ++++ 16 files changed, 961 insertions(+) create mode 100644 CLAUDE.md create mode 100644 backend/app/alembic/versions/2d1ae9889b29_add_rbac_roles_and_permissions.py create mode 100644 backend/app/api/routes/metrics.py create mode 100644 backend/app/core/rbac.py create mode 100644 backend/tests/api/routes/test_rbac.py create mode 100644 docs/AUTHORIZATION.md create mode 100644 frontend/src/components/Admin/roleOptions.ts create mode 100644 frontend/src/components/Common/AccessDenied.tsx create mode 100644 frontend/src/routes/_layout/forbidden.tsx create mode 100644 frontend/src/routes/_layout/metrics.tsx create mode 100644 openspec/changes/add-rbac/.openspec.yaml create mode 100644 openspec/changes/add-rbac/design.md create mode 100644 openspec/changes/add-rbac/proposal.md create mode 100644 openspec/changes/add-rbac/specs/rbac/spec.md create mode 100644 openspec/changes/add-rbac/tasks.md create mode 100644 openspec/config.yaml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..2b85c70638 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,37 @@ +# Engineering rules + +## Stack + +- **Backend**: FastAPI + SQLModel (Pydantic + SQLAlchemy) + Alembic + Postgres. Package/venv managed with `uv` from `backend/`. +- **Frontend**: React + TanStack Router/Query + generated OpenAPI client (`@hey-api/openapi-ts`) + shadcn/radix + Tailwind. Managed with `bun` from `frontend/` (or repo root via `bun run --filter frontend