- 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.
392 lines
16 KiB
Python
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)
|