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:
44
backend/alembic/versions/1a4da27efc65_add_invites_table.py
Normal file
44
backend/alembic/versions/1a4da27efc65_add_invites_table.py
Normal 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 ###
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
12
backend/app/db/models/invite.py
Normal file
12
backend/app/db/models/invite.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
0
backend/app/users/__init__.py
Normal file
0
backend/app/users/__init__.py
Normal file
43
backend/app/users/router.py
Normal file
43
backend/app/users/router.py
Normal 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))
|
||||
11
backend/app/users/schemas.py
Normal file
11
backend/app/users/schemas.py
Normal 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
|
||||
98
backend/app/users/service.py
Normal file
98
backend/app/users/service.py
Normal 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
111
backend/tests/test_users.py
Normal 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
|
||||
Reference in New Issue
Block a user