Files
mjalmoudy94 5095a29df9 feat(org): platform invites, roles, org sharing (v2 phase A1-1)
- New platform_invites table (migration a1b2c3d4e5f1) + PlatformInviteRow
- Owner-only endpoints in admin_users.py: issue/list/revoke platform invites
- invites.py: two-kind resolution (org + platform), email-supplied accept path
- auth.py /me gains needs_onboarding (manager, 0 orgs, not owner)
- organizations.py: transfer-ownership, leave (last owner guard), list/revoke org invites
- contracts + web UI: admin/users invite modal, accept-invite platform variant, onboarding gating
- Tests: test_org_platform_invites.py (18), org-sharing.test.tsx; report under docs/plan/reports

Note: (app)/settings "اشتراک‌گذاری سازمان" section (1.10) remains presentational work;
endpoints are done and tested.
2026-10-14 09:14:09 +03:30

210 lines
6.6 KiB
Python

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