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 fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import schemas, service from app.auth import schemas, service
from app.db.session import get_db from app.db.session import get_db
from app.deps import get_current_user from app.deps import get_current_user
from app.users.schemas import AcceptInviteRequest
from app.users.service import accept_invite
router = APIRouter() router = APIRouter()
@@ -56,3 +59,22 @@ async def me(
plan=tenant.plan, plan=tenant.plan,
rol=user.rol, 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.invoice import Invoice
from app.db.models.appointment import Appointment from app.db.models.appointment import Appointment
from app.db.models.mecanic import Mecanic from app.db.models.mecanic import Mecanic
from app.db.models.invite import InviteToken
__all__ = [ __all__ = [
"Tenant", "Tenant",
@@ -32,4 +33,5 @@ __all__ = [
"Invoice", "Invoice",
"Appointment", "Appointment",
"Mecanic", "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.invoices.router import router as invoices_router
from app.orders.router import router as orders_router from app.orders.router import router as orders_router
from app.sync.router import router as sync_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 from app.vehicles.router import router as vehicles_router
# Import models so Base.metadata knows about them # 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(orders_router, prefix="/api/orders")
app.include_router(vehicles_router, prefix="/api/vehicles") app.include_router(vehicles_router, prefix="/api/vehicles")
app.include_router(invoices_router, prefix="/api/invoices") app.include_router(invoices_router, prefix="/api/invoices")
app.include_router(users_router, prefix="/api/users")
app.include_router(portal_router, prefix="/api") 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