From 8c0346e41fdc171eeefe4ba1eecafd60988c1cad Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Fri, 13 Mar 2026 17:37:06 +0200 Subject: [PATCH] feat(backend): invite system + user management - InviteToken model with unique token for each invite - POST /users/invite - create invite by email with role (admin/mecanic) - POST /auth/accept-invite - accept invite, set password, return JWT - GET /users - list all users in tenant - DELETE /users/{id} - deactivate user (cannot deactivate owner) - Alembic migration for invites table - 25 passing tests (auth + sync + orders + pdf + portal + invoices + users) Co-Authored-By: Claude Sonnet 4.6 --- .../1a4da27efc65_add_invites_table.py | 44 +++++++ backend/app/auth/router.py | 22 ++++ backend/app/db/models/__init__.py | 2 + backend/app/db/models/invite.py | 12 ++ backend/app/main.py | 2 + backend/app/users/__init__.py | 0 backend/app/users/router.py | 43 +++++++ backend/app/users/schemas.py | 11 ++ backend/app/users/service.py | 98 ++++++++++++++++ backend/tests/test_users.py | 111 ++++++++++++++++++ 10 files changed, 345 insertions(+) create mode 100644 backend/alembic/versions/1a4da27efc65_add_invites_table.py create mode 100644 backend/app/db/models/invite.py create mode 100644 backend/app/users/__init__.py create mode 100644 backend/app/users/router.py create mode 100644 backend/app/users/schemas.py create mode 100644 backend/app/users/service.py create mode 100644 backend/tests/test_users.py diff --git a/backend/alembic/versions/1a4da27efc65_add_invites_table.py b/backend/alembic/versions/1a4da27efc65_add_invites_table.py new file mode 100644 index 0000000..1e0e323 --- /dev/null +++ b/backend/alembic/versions/1a4da27efc65_add_invites_table.py @@ -0,0 +1,44 @@ +"""add_invites_table + +Revision ID: 1a4da27efc65 +Revises: eec3c13599e7 +Create Date: 2026-03-13 17:36:58.364672 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '1a4da27efc65' +down_revision: Union[str, None] = 'eec3c13599e7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('invites', + sa.Column('email', sa.String(length=200), nullable=False), + sa.Column('rol', sa.String(length=20), nullable=False), + sa.Column('token', sa.String(length=36), nullable=False), + sa.Column('used', sa.Text(), nullable=True), + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('created_at', sa.Text(), nullable=False), + sa.Column('updated_at', sa.Text(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_invites_tenant_id'), 'invites', ['tenant_id'], unique=False) + op.create_index(op.f('ix_invites_token'), 'invites', ['token'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_invites_token'), table_name='invites') + op.drop_index(op.f('ix_invites_tenant_id'), table_name='invites') + op.drop_table('invites') + # ### end Alembic commands ### diff --git a/backend/app/auth/router.py b/backend/app/auth/router.py index 099e324..9a6d80d 100644 --- a/backend/app/auth/router.py +++ b/backend/app/auth/router.py @@ -1,9 +1,12 @@ from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth import schemas, service from app.db.session import get_db from app.deps import get_current_user +from app.users.schemas import AcceptInviteRequest +from app.users.service import accept_invite router = APIRouter() @@ -56,3 +59,22 @@ async def me( plan=tenant.plan, rol=user.rol, ) + + +@router.post("/accept-invite", response_model=schemas.TokenResponse) +async def accept_invite_endpoint( + data: AcceptInviteRequest, db: AsyncSession = Depends(get_db) +): + try: + user = await accept_invite(db, data.token, data.password) + from app.db.models.tenant import Tenant + + r = await db.execute(select(Tenant).where(Tenant.id == user.tenant_id)) + tenant = r.scalar_one() + return schemas.TokenResponse( + access_token=service.create_token(user.id, tenant.id, tenant.plan), + tenant_id=tenant.id, + plan=tenant.plan, + ) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) diff --git a/backend/app/db/models/__init__.py b/backend/app/db/models/__init__.py index 9146f62..c02c417 100644 --- a/backend/app/db/models/__init__.py +++ b/backend/app/db/models/__init__.py @@ -15,6 +15,7 @@ from app.db.models.catalog import ( from app.db.models.invoice import Invoice from app.db.models.appointment import Appointment from app.db.models.mecanic import Mecanic +from app.db.models.invite import InviteToken __all__ = [ "Tenant", @@ -32,4 +33,5 @@ __all__ = [ "Invoice", "Appointment", "Mecanic", + "InviteToken", ] diff --git a/backend/app/db/models/invite.py b/backend/app/db/models/invite.py new file mode 100644 index 0000000..6845823 --- /dev/null +++ b/backend/app/db/models/invite.py @@ -0,0 +1,12 @@ +from sqlalchemy import String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin + + +class InviteToken(Base, UUIDMixin, TenantMixin, TimestampMixin): + __tablename__ = "invites" + email: Mapped[str] = mapped_column(String(200)) + rol: Mapped[str] = mapped_column(String(20)) + token: Mapped[str] = mapped_column(String(36), unique=True, index=True) + used: Mapped[str | None] = mapped_column(Text) # ISO8601 when used, null if pending diff --git a/backend/app/main.py b/backend/app/main.py index 4bb5275..f24dcc3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,6 +11,7 @@ from app.db.session import engine from app.invoices.router import router as invoices_router from app.orders.router import router as orders_router from app.sync.router import router as sync_router +from app.users.router import router as users_router from app.vehicles.router import router as vehicles_router # Import models so Base.metadata knows about them @@ -37,6 +38,7 @@ app.include_router(sync_router, prefix="/api/sync") app.include_router(orders_router, prefix="/api/orders") app.include_router(vehicles_router, prefix="/api/vehicles") app.include_router(invoices_router, prefix="/api/invoices") +app.include_router(users_router, prefix="/api/users") app.include_router(portal_router, prefix="/api") diff --git a/backend/app/users/__init__.py b/backend/app/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/users/router.py b/backend/app/users/router.py new file mode 100644 index 0000000..f4cecab --- /dev/null +++ b/backend/app/users/router.py @@ -0,0 +1,43 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.session import get_db +from app.deps import get_current_user, get_tenant_id +from app.users import schemas, service + +router = APIRouter() + + +@router.get("") +async def list_users( + tenant_id: str = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + return await service.list_users(db, tenant_id) + + +@router.post("/invite") +async def invite_user( + data: schemas.InviteRequest, + current_user: dict = Depends(get_current_user), + tenant_id: str = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + try: + invite = await service.invite_user(db, tenant_id, data.email, data.rol) + return {"token": invite.token, "email": invite.email} + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + + +@router.delete("/{user_id}") +async def delete_user( + user_id: str, + tenant_id: str = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + try: + await service.deactivate_user(db, tenant_id, user_id) + return {"ok": True} + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) diff --git a/backend/app/users/schemas.py b/backend/app/users/schemas.py new file mode 100644 index 0000000..9b98abd --- /dev/null +++ b/backend/app/users/schemas.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, EmailStr + + +class InviteRequest(BaseModel): + email: EmailStr + rol: str = "mecanic" + + +class AcceptInviteRequest(BaseModel): + token: str + password: str diff --git a/backend/app/users/service.py b/backend/app/users/service.py new file mode 100644 index 0000000..cdcbc44 --- /dev/null +++ b/backend/app/users/service.py @@ -0,0 +1,98 @@ +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.service import hash_password +from app.db.base import uuid7 +from app.db.models.invite import InviteToken +from app.db.models.user import User + + +async def invite_user( + db: AsyncSession, tenant_id: str, email: str, rol: str +) -> InviteToken: + # Check if user already exists in this tenant + r = await db.execute( + select(User).where(User.email == email, User.tenant_id == tenant_id) + ) + if r.scalar_one_or_none(): + raise ValueError("User already exists in this tenant") + + token = uuid7() + invite = InviteToken( + id=uuid7(), + tenant_id=tenant_id, + email=email, + rol=rol, + token=token, + ) + db.add(invite) + await db.commit() + await db.refresh(invite) + return invite + + +async def accept_invite( + db: AsyncSession, token: str, password: str +) -> User: + r = await db.execute( + select(InviteToken).where( + InviteToken.token == token, InviteToken.used == None + ) + ) + invite = r.scalar_one_or_none() + if not invite: + raise ValueError("Invalid or already used invite token") + + # Check if email already registered + r = await db.execute(select(User).where(User.email == invite.email)) + if r.scalar_one_or_none(): + raise ValueError("Email already registered") + + user = User( + id=uuid7(), + tenant_id=invite.tenant_id, + email=invite.email, + password_hash=hash_password(password), + nume=invite.email.split("@")[0], + rol=invite.rol, + ) + db.add(user) + + invite.used = datetime.now(UTC).isoformat() + await db.commit() + await db.refresh(user) + return user + + +async def list_users(db: AsyncSession, tenant_id: str) -> list: + r = await db.execute( + select(User).where(User.tenant_id == tenant_id) + ) + users = r.scalars().all() + return [ + { + "id": u.id, + "email": u.email, + "rol": u.rol, + "activ": u.activ, + } + for u in users + ] + + +async def deactivate_user( + db: AsyncSession, tenant_id: str, user_id: str +) -> bool: + r = await db.execute( + select(User).where(User.id == user_id, User.tenant_id == tenant_id) + ) + user = r.scalar_one_or_none() + if not user: + raise ValueError("User not found") + if user.rol == "owner": + raise ValueError("Cannot deactivate owner") + user.activ = False + await db.commit() + return True diff --git a/backend/tests/test_users.py b/backend/tests/test_users.py new file mode 100644 index 0000000..137ad77 --- /dev/null +++ b/backend/tests/test_users.py @@ -0,0 +1,111 @@ +import pytest +from httpx import ASGITransport, AsyncClient + +from app.main import app + + +@pytest.mark.asyncio +async def test_invite_and_accept_flow(client, auth_headers): + # Invite a new user + r = await client.post( + "/api/users/invite", + headers=auth_headers, + json={"email": "mecanic@service.ro", "rol": "mecanic"}, + ) + assert r.status_code == 200 + data = r.json() + assert "token" in data + assert data["email"] == "mecanic@service.ro" + invite_token = data["token"] + + # Accept invite (creates user + returns JWT) + r = await client.post( + "/api/auth/accept-invite", + json={"token": invite_token, "password": "mecanic123"}, + ) + assert r.status_code == 200 + data = r.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + # New user can access /me + new_headers = {"Authorization": f"Bearer {data['access_token']}"} + r = await client.get("/api/auth/me", headers=new_headers) + assert r.status_code == 200 + me = r.json() + assert me["email"] == "mecanic@service.ro" + assert me["rol"] == "mecanic" + + +@pytest.mark.asyncio +async def test_list_users(client, auth_headers): + r = await client.get("/api/users", headers=auth_headers) + assert r.status_code == 200 + users = r.json() + assert len(users) >= 1 + assert users[0]["rol"] == "owner" + + +@pytest.mark.asyncio +async def test_deactivate_user(client, auth_headers): + # Invite and accept a user first + r = await client.post( + "/api/users/invite", + headers=auth_headers, + json={"email": "delete@service.ro", "rol": "mecanic"}, + ) + invite_token = r.json()["token"] + await client.post( + "/api/auth/accept-invite", + json={"token": invite_token, "password": "pass123"}, + ) + + # List users to get the new user's id + r = await client.get("/api/users", headers=auth_headers) + users = r.json() + mecanic = next(u for u in users if u["email"] == "delete@service.ro") + + # Deactivate + r = await client.delete( + f"/api/users/{mecanic['id']}", headers=auth_headers + ) + assert r.status_code == 200 + assert r.json()["ok"] is True + + +@pytest.mark.asyncio +async def test_cannot_deactivate_owner(client, auth_headers): + r = await client.get("/api/users", headers=auth_headers) + users = r.json() + owner = next(u for u in users if u["rol"] == "owner") + + r = await client.delete( + f"/api/users/{owner['id']}", headers=auth_headers + ) + assert r.status_code == 422 + + +@pytest.mark.asyncio +async def test_invalid_invite_token(client): + r = await client.post( + "/api/auth/accept-invite", + json={"token": "invalid-token", "password": "pass123"}, + ) + assert r.status_code == 422 + + +@pytest.mark.asyncio +async def test_duplicate_invite(client, auth_headers): + # Invite same email twice - first should succeed, second may fail if user exists + await client.post( + "/api/users/invite", + headers=auth_headers, + json={"email": "dup@service.ro", "rol": "mecanic"}, + ) + # Second invite for same email is allowed (user not created yet) + r = await client.post( + "/api/users/invite", + headers=auth_headers, + json={"email": "dup@service.ro", "rol": "admin"}, + ) + assert r.status_code == 200