- 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.
210 lines
6.6 KiB
Python
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
|