"""Authentication endpoints. There is deliberately **no public signup**: accounts are created either by the platform owner (``POST /admin/users`` — managers and plain users) or by redeeming an organization invite (``POST /invites/accept`` with a password). Login, the profile view and self-service password change live here. """ from __future__ import annotations from fastapi import APIRouter, Depends, Request, status from pydantic import BaseModel, ConfigDict, EmailStr, Field from sqlalchemy.ext.asyncio import AsyncSession from app.application.services.audit_service import record_audit from app.core.database import get_session from app.core.errors import AuthenticationError, ValidationError from app.core.security import ( create_access_token, hash_password, verify_password, ) from app.domain.audit.models import AuditAction from app.domain.identity.models import PlatformRole, User from app.infrastructure.repositories.identity import ( MembershipRepository, OrganizationRepository, UserRepository, ) from app.interfaces.http.deps import client_meta, get_current_user router = APIRouter(prefix="/auth", tags=["auth"]) class LoginRequest(BaseModel): email: EmailStr password: str class UserOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: str email: str name: str avatar_url: str | None = None #: Platform-wide role — ``owner`` | ``manager`` | ``user``. Exactly one #: account is ``owner``; only ``owner`` and ``manager`` may create #: organizations. platform_role: str = "user" is_manager: bool = False is_platform_owner: bool = False class ChangePasswordRequest(BaseModel): current_password: str = Field(min_length=1, max_length=128) new_password: str = Field(min_length=8, max_length=128) class OrganizationSummary(BaseModel): id: str name: str slug: str role: str class TokenResponse(BaseModel): access_token: str token_type: str = "bearer" user: UserOut class MeResponse(BaseModel): user: UserOut organizations: list[OrganizationSummary] #: True when this account is a manager with no organization yet: they landed #: on the platform owner's invitation link and built nothing, so the only #: useful thing the UI can do is open the creation wizard. The owner is #: deliberately excluded — they administer the platform rather than joining it. needs_onboarding: bool = False def _to_user_out(user: User) -> UserOut: return UserOut( id=user.id, email=user.email, name=user.display_name, avatar_url=user.avatar_url, platform_role=user.platform_role.value, is_manager=user.is_manager, is_platform_owner=user.is_platform_owner, ) @router.post("/login", response_model=TokenResponse) async def login( payload: LoginRequest, request: Request, session: AsyncSession = Depends(get_session), ) -> TokenResponse: user = await UserRepository(session).get_by_email(payload.email) if user is None or not verify_password(payload.password, user.password_hash): raise AuthenticationError("ایمیل یا رمز عبور نادرست است.") if not user.is_active: raise AuthenticationError("حساب کاربری غیرفعال است.") ip, ua = client_meta(request) await record_audit( session, action=AuditAction.LOGIN, actor_user_id=user.id, target_type="user", target_id=user.id, ip=ip, user_agent=ua, ) return TokenResponse(access_token=create_access_token(user.id), user=_to_user_out(user)) @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) async def logout( request: Request, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> None: """JWTs are stateless; logout is an audit marker today and a token-revocation hook once a denylist (Redis) is wired in.""" ip, ua = client_meta(request) await record_audit( session, action=AuditAction.LOGOUT, actor_user_id=user.id, target_type="user", target_id=user.id, ip=ip, user_agent=ua, ) @router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT) async def change_password( payload: ChangePasswordRequest, request: Request, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> None: """Replace the caller's own password. This is how a temp password handed out by the platform owner (or by an invite) becomes a private one — without it, every account created by someone else would live on a shared secret forever. """ users = UserRepository(session) if not verify_password(payload.current_password, user.password_hash): raise AuthenticationError("رمز عبور فعلی نادرست است.") if payload.new_password == payload.current_password: raise ValidationError("رمز عبور جدید باید با رمز فعلی متفاوت باشد.") await users.update_password(user.id, hash_password(payload.new_password)) ip, ua = client_meta(request) await record_audit( session, action=AuditAction.PASSWORD_CHANGE, actor_user_id=user.id, target_type="user", target_id=user.id, ip=ip, user_agent=ua, ) @router.get("/me", response_model=MeResponse) async def me( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> MeResponse: orgs = await OrganizationRepository(session).list_for_user(user.id) needs_onboarding = ( user.platform_role == PlatformRole.MANAGER and len(orgs) == 0 and not user.is_platform_owner ) return MeResponse( user=_to_user_out(user), organizations=[ OrganizationSummary(id=org.id, name=org.name, slug=org.slug, role=role.value) for org, role in orgs ], needs_onboarding=needs_onboarding, ) @router.get("/me/memberships") async def my_memberships( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> list[dict]: orgs = await OrganizationRepository(session).list_for_user(user.id) memberships = MembershipRepository(session) out: list[dict] = [] for org, role in orgs: membership = await memberships.get(org.id, user.id) out.append( { "organization_id": org.id, "organization_name": org.name, "role": role.value, "status": membership.status.value if membership else "unknown", } ) return out