"""Invite redemption endpoints. Deliberately *not* nested under ``/organizations/{org_id}``: the person accepting an invite is by definition not yet a member, so ``get_org_context`` would (correctly) refuse them. Here the invite token itself is the authorization. It is stored only as a hash and is single-use. Because signup is closed (accounts come from the owner or from an invite), the token doubles as an account-creation capability: a brand-new invitee redeems it **with a password**, which creates their account bound to the invited email and joins the organization in one step. The token can never reset the password of an *existing* account — those must authenticate first, which keeps a leaked link from taking over an account that already exists. """ from __future__ import annotations from datetime import UTC, datetime from fastapi import APIRouter, Depends, Request, status from pydantic import BaseModel, 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 ( ConflictError, NotFoundError, PermissionDeniedError, ValidationError, ) from app.core.security import hash_password, hash_token from app.domain.audit.models import AuditAction from app.domain.identity.models import ( Invite, Membership, MembershipStatus, Organization, PlatformRole, User, ) from app.infrastructure.repositories.identity import ( InviteRepository, MembershipRepository, OrganizationRepository, UserRepository, ) from app.interfaces.http.deps import client_meta, get_optional_user router = APIRouter(prefix="/invites", tags=["invites"]) class InvitePreview(BaseModel): organization_id: str organization_name: str email: str role: str expires_at: datetime #: True when the caller is already a member, so the UI can say "open it" #: instead of "join". already_member: bool #: True when an account exists for the invited email. The invitee then logs #: in first; anonymous redemption with a password is only for brand-new #: accounts (a token must never be able to reset an existing password). has_account: bool class AcceptInviteRequest(BaseModel): token: str #: Required only when redeeming anonymously — it becomes the password of #: the account the invite creates. Ignored for a signed-in user, who has #: already proven ownership of the email. password: str | None = Field(default=None, min_length=8, max_length=128) class AcceptInviteResult(BaseModel): organization_id: str organization_name: str role: str async def _load_pending_invite( token: str, user: User | None, session: AsyncSession ) -> tuple[Invite, Organization]: """Resolve a raw token into a redeemable invite, or raise. Every rejection is a distinct, user-actionable error rather than a generic "invalid link": the real failure modes (already used, expired, wrong account) each need a different response from the person holding the link. The email binding is only enforced when a user is signed in: an anonymous holder of the token sees the preview but must create an account for exactly the invited email, which the accept path checks. """ invite = await InviteRepository(session).get_by_token_hash(hash_token(token)) if invite is None: raise NotFoundError("این لینک دعوت معتبر نیست.") if invite.accepted_at is not None: raise ConflictError("این دعوت‌نامه قبلاً پذیرافته شده است.") if invite.is_expired(): raise ValidationError("این دعوت‌نامه منقضی شده است؛ از مدیر بخواهید دوباره دعوت کند.") if user is not None and invite.email.strip().lower() != user.email.strip().lower(): raise PermissionDeniedError( "این دعوت‌نامه برای ایمیل دیگری صادر شده است؛ با همان ایمیل وارد شوید.", details={"invite_email": invite.email}, ) organization = await OrganizationRepository(session).get(invite.organization_id) if organization is None: raise NotFoundError("سازمان این دعوت‌نامه دیگر وجود ندارد.") return invite, organization @router.get("/{token}", response_model=InvitePreview) async def preview_invite( token: str, user: User | None = Depends(get_optional_user), session: AsyncSession = Depends(get_session), ) -> InvitePreview: """Show the invitee what they are about to join, without joining yet. Works without a session on purpose: a brand-new invitee must be able to see which organization invited them (and whether they already have an account) *before* deciding to sign in or create one. """ invite, organization = await _load_pending_invite(token, user, session) existing_user = await UserRepository(session).get_by_email(invite.email) membership = ( await MembershipRepository(session).get(organization.id, user.id) if user is not None else None ) return InvitePreview( organization_id=organization.id, organization_name=organization.name, email=invite.email, role=invite.role.value, expires_at=invite.expires_at, already_member=membership is not None, has_account=existing_user is not None, ) @router.post("/accept", response_model=AcceptInviteResult, status_code=status.HTTP_200_OK) async def accept_invite( payload: AcceptInviteRequest, request: Request, user: User | None = Depends(get_optional_user), session: AsyncSession = Depends(get_session), ) -> AcceptInviteResult: """Redeem an invite: create the account if needed, join, and burn the token. Two redemption paths, one outcome: - **Signed in** — the account already exists and has proven ownership of the invited email; no password is needed. - **Anonymous** — the invite itself is the capability: a *new* account is created for the invited email with the supplied password and joins the organization. If an account for that email already exists, the caller must authenticate first — a link can never become a password reset. Idempotent by construction — accepting twice is a no-op the second time, so a double-click or a retried request cannot create two memberships. """ invite, organization = await _load_pending_invite(payload.token, user, session) users = UserRepository(session) memberships = MembershipRepository(session) created_account = False if user is None: existing = await users.get_by_email(invite.email) if existing is not None: raise PermissionDeniedError( "برای این ایمیل قبلاً حساب ساخته شده است؛ وارد شوید و سپس دعوت را بپذیرید." ) if not payload.password: raise ValidationError("برای ساخت حساب جدید، تعیین رمز عبور الزامی است.") user = await users.create( User( email=invite.email, password_hash=hash_password(payload.password), name="", platform_role=PlatformRole.USER, ) ) created_account = True if await memberships.get(organization.id, user.id) is None: await memberships.add( Membership( organization_id=organization.id, user_id=user.id, role=invite.role, status=MembershipStatus.ACTIVE, ) ) ip, ua = client_meta(request) await record_audit( session, action=AuditAction.MEMBER_JOIN, organization_id=organization.id, actor_user_id=user.id, target_type="invite", target_id=invite.id, metadata={"email": invite.email, "role": invite.role.value}, ip=ip, user_agent=ua, ) if created_account: # Account creation is audited separately from the join: one is a # platform identity event, the other a tenant event. ip, ua = client_meta(request) await record_audit( session, action=AuditAction.SIGNUP, actor_user_id=user.id, target_type="user", target_id=user.id, metadata={"via": "invite", "organization_id": organization.id}, ip=ip, user_agent=ua, ) # Burn the token regardless: whether the membership was just created or # already existed, this invite must not be redeemable a second time. await InviteRepository(session).mark_accepted(invite.id, datetime.now(UTC)) return AcceptInviteResult( organization_id=organization.id, organization_name=organization.name, role=invite.role.value, )