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

392 lines
16 KiB
Python

"""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, EmailStr, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.platform_users import PlatformInviteService
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,
PlatformInvite,
PlatformRole,
User,
)
from app.infrastructure.repositories.identity import (
InviteRepository,
MembershipRepository,
OrganizationRepository,
PlatformInviteRepository,
UserRepository,
)
from app.interfaces.http.deps import client_meta, get_optional_user
def _invite_service(session: AsyncSession) -> PlatformInviteService:
"""A fresh service per request; it holds no state of its own."""
return PlatformInviteService(PlatformInviteRepository(session))
router = APIRouter(prefix="/invites", tags=["invites"])
class InvitePreview(BaseModel):
#: ``platform`` invites mint a new manager account (D12); ``organization``
#: invites join an existing org. The UI shows a different title for each —
#: "ساخت حساب مدیر" versus joining a team — so the caller has to be able to
#: tell them apart.
kind: str = "organization"
organization_id: str = ""
organization_name: str = ""
email: str = ""
role: str = ""
expires_at: datetime | None = None
#: True when the caller is already a member, so the UI can say "open it"
#: instead of "join".
already_member: bool = False
#: 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 = False
#: For a platform invite: the role the redeemed account will receive.
platform_role: str = ""
class AcceptInviteRequest(BaseModel):
token: str
#: Required only for a platform invite that pinned no email — the holder
#: supplies the address they want to sign in with.
email: EmailStr | None = Field(default=None)
#: 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):
#: Same two kinds as the preview, so the caller knows which door was opened.
kind: str = "organization"
organization_id: str = ""
organization_name: str = ""
role: str = ""
async def _load_any_invite(
token: str, user: User | None, session: AsyncSession
) -> tuple[str, PlatformInvite | None, Invite | None]:
"""Resolve a raw token into whichever kind of invite it belongs to.
Returns ``(kind, platform_invite, org_invite)`` where exactly one of the two
invite slots is populated. One lookup path for both kinds keeps the two flows
honest about the same rules: a link can never reset an existing account's
password, and every rejection looks identical so a probe cannot tell "no such
link" from "already used" from "revoked".
"""
platform = await PlatformInviteRepository(session).get_by_token_hash(hash_token(token))
if platform is not None:
if platform.is_expired() or platform.is_revoked:
raise NotFoundError("این لینک دعوت معتبر نیست.")
return "platform", platform, None
org_invite = await InviteRepository(session).get_by_token_hash(hash_token(token))
if org_invite is None:
raise NotFoundError("این لینک دعوت معتبر نیست.")
if org_invite.accepted_at is not None:
raise ConflictError("این دعوت‌نامه قبلاً پذیرفته شده است.")
if org_invite.is_expired():
raise ValidationError("این دعوت‌نامه منقضی شده است؛ از مدیر بخواهید دوباره دعوت کند.")
if user is not None and org_invite.email.strip().lower() != user.email.strip().lower():
raise PermissionDeniedError(
"این دعوت‌نامه برای ایمیل دیگری صادر شده است؛ با همان ایمیل وارد شوید.",
details={"invite_email": org_invite.email},
)
return "organization", None, org_invite
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.
"""
kind, platform, org_invite = await _load_any_invite(token, user, session)
if kind == "platform":
if platform is None: # pragma: no cover - the resolver guarantees the pair
raise NotFoundError("این لینک دعوت معتبر نیست.")
# A platform invite has no organization yet — that is the point of it. The
# email is optional, so "already have an account" can only be answered
# when the owner pinned one.
existing_user = (
await UserRepository(session).get_by_email(platform.email)
if platform.email
else None
)
return InvitePreview(
kind="platform",
email=platform.email or "",
role=str(platform.platform_role),
expires_at=platform.expires_at,
has_account=existing_user is not None,
platform_role=str(platform.platform_role),
)
if org_invite is None: # pragma: no cover - the resolver guarantees the pair
raise NotFoundError("این لینک دعوت معتبر نیست.")
organization = await OrganizationRepository(session).get(org_invite.organization_id)
if organization is None:
raise NotFoundError("سازمان این دعوت‌نامه دیگر وجود ندارد.")
existing_user = await UserRepository(session).get_by_email(org_invite.email)
membership = (
await MembershipRepository(session).get(organization.id, user.id)
if user is not None
else None
)
return InvitePreview(
kind="organization",
organization_id=organization.id,
organization_name=organization.name,
email=org_invite.email,
role=org_invite.role.value,
expires_at=org_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.
"""
kind, platform, org_invite = await _load_any_invite(payload.token, user, session)
if kind == "platform":
if platform is None: # pragma: no cover - the resolver guarantees the pair
raise NotFoundError("این لینک دعوت معتبر نیست.")
return await _accept_platform_invite(payload, platform, user, request, session)
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(
kind="organization",
organization_id=organization.id,
organization_name=organization.name,
role=invite.role.value,
)
async def _accept_platform_invite(
payload: AcceptInviteRequest,
platform: PlatformInvite,
user: User | None,
request: Request,
session: AsyncSession,
) -> AcceptInviteResult:
"""Redeem a platform invite: mint a manager, or upgrade an existing account.
Two paths, mirroring the organization flow exactly:
- **Anonymous** — the link *is* the capability. A new account is created for
the pinned email (or, when the owner left it blank, for no particular email
at all, which is why the accept form asks for one) with the chosen password
and the role the invite carries.
- **Signed in** — the account already exists and has proven ownership of the
invited email; its platform role is raised to the invited one.
No password is ever needed from a signed-in user, and a link can never reset
the password of an account that already exists.
"""
users = UserRepository(session)
created_account = False
invited_role = PlatformRole(str(platform.platform_role))
if user is None:
if platform.email:
existing = await users.get_by_email(platform.email)
if existing is not None:
raise PermissionDeniedError(
"برای این ایمیل قبلاً حساب ساخته شده است؛ وارد شوید و سپس دعوت را بپذیرید."
)
if not payload.password:
raise ValidationError("برای ساخت حساب جدید، تعیین رمز عبور الزامی است.")
user = await users.create(
User(
# A blank-email invite is a bearer link: the holder supplies the
# email they want to sign in with, and must confirm it.
email=(payload.email or platform.email or "").strip().lower(),
password_hash=hash_password(payload.password or ""),
name="",
platform_role=invited_role,
)
)
created_account = True
else:
# A signed-in redeemer who is invited as a manager *becomes* a manager.
await users.set_platform_role(user.id, invited_role)
ok, _redeemed = await _invite_service(session).redeem(
token=payload.token, user_id=user.id
)
if not ok:
raise ConflictError("این لینک دعوت قبلاً استفاده یا لغو شده است.")
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.USER_PLATFORM_ROLE_CHANGE if not created_account else AuditAction.SIGNUP,
actor_user_id=user.id,
target_type="user",
target_id=user.id,
metadata={
"via": "platform_invite",
"invite_id": platform.id,
"platform_role": invited_role.value,
},
ip=ip,
user_agent=ua,
)
return AcceptInviteResult(kind="platform", role=invited_role.value)