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