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 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 17:37:06 +02:00
parent 5fa72e4323
commit 8c0346e41f
10 changed files with 345 additions and 0 deletions

View File

@@ -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 ###

View File

@@ -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))

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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")

View File

View File

@@ -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))

View File

@@ -0,0 +1,11 @@
from pydantic import BaseModel, EmailStr
class InviteRequest(BaseModel):
email: EmailStr
rol: str = "mecanic"
class AcceptInviteRequest(BaseModel):
token: str
password: str

View File

@@ -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

111
backend/tests/test_users.py Normal file
View File

@@ -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