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.
This commit is contained in:
mjalmoudy94
2026-10-14 09:14:09 +03:30
parent 3c8296e354
commit 5095a29df9
20 changed files with 1933 additions and 37 deletions
@@ -24,6 +24,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.platform_users import (
PlatformInviteService,
transfer_owned_organizations,
)
from app.core.database import get_session
@@ -33,6 +34,7 @@ from app.domain.audit.models import AuditAction
from app.domain.identity.models import PlatformRole, User
from app.infrastructure.repositories.identity import (
OrganizationRepository,
PlatformInviteRepository,
UserRepository,
)
from app.interfaces.http.deps import client_meta, require_platform_owner
@@ -402,3 +404,115 @@ async def transfer_ownership(
updated = await users.get(successor.id)
assert updated is not None
return await _to_out(session, updated)
# ---------------------------------------------------------------------------
# Platform invites — the door into the product (D12)
# ---------------------------------------------------------------------------
class PlatformInviteOut(BaseModel):
id: str
email: str | None
platform_role: str
note: str
expires_at: datetime
created_at: datetime
created_by_email: str = ""
class IssuePlatformInviteRequest(BaseModel):
#: Optional. When set, the account that redeems the link is bound to it.
email: EmailStr | None = Field(default=None)
note: str = Field(default="", max_length=500)
#: ``manager`` by default (the role that may create organizations).
platform_role: PlatformRole = PlatformRole.MANAGER
class IssuedPlatformInviteOut(BaseModel):
invite: PlatformInviteOut
#: The raw URL. Shown exactly once and never retrievable afterwards.
invite_url: str
@router.post("/platform-invites", response_model=IssuedPlatformInviteOut)
async def issue_platform_invite(
payload: IssuePlatformInviteRequest,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> IssuedPlatformInviteOut:
"""Create an invitation link for a new manager account.
The raw token is returned exactly once. It is stored only as a hash, so a
leaked database cannot mint manager accounts — and the owner can revoke the
link at any time, which is why it is a capability rather than a message.
"""
issued = await _invite_service(session).issue(
owner_user_id=owner.id,
email=payload.email,
platform_role=payload.platform_role,
note=payload.note,
)
ip, ua = client_meta(request)
await record_audit(
session, action="platform.invite_created", actor_user_id=owner.id,
target_type="platform_invite", target_id=issued.invite.id,
metadata={
"email": issued.invite.email,
"platform_role": payload.platform_role.value,
},
ip=ip,
user_agent=ua,
)
return IssuedPlatformInviteOut(
invite=_invite_out(issued.invite, owner.email),
invite_url=issued.invite_url,
)
@router.get("/platform-invites", response_model=list[PlatformInviteOut])
async def list_platform_invites(
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> list[PlatformInviteOut]:
"""The invitations that still hold a valid, unredeemed link."""
invites = await _invite_service(session).list_pending()
creators = await UserRepository(session).list_all()
emails = {user.id: user.email for user in creators}
return [_invite_out(i, emails.get(i.created_by_user_id, "")) for i in invites]
@router.post("/platform-invites/{invite_id}/revoke", status_code=status.HTTP_200_OK)
async def revoke_platform_invite(
invite_id: str,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> dict:
"""Revoke a link so it can no longer be redeemed.
Revoking an already-redeemed invite is a no-op rather than an error: the
state is already what the operator asked for.
"""
await _invite_service(session).revoke(invite_id=invite_id, by_user_id=owner.id)
await record_audit(
session, action="platform.invite_revoked", actor_user_id=owner.id,
target_type="platform_invite", target_id=invite_id,
)
return {"ok": True}
def _invite_service(session: AsyncSession) -> PlatformInviteService:
return PlatformInviteService(PlatformInviteRepository(session))
def _invite_out(invite, creator_email: str) -> PlatformInviteOut:
return PlatformInviteOut(
id=invite.id,
email=invite.email,
platform_role=str(invite.platform_role),
note=invite.note,
expires_at=invite.expires_at,
created_at=invite.created_at,
created_by_email=creator_email,
)
+10 -1
View File
@@ -21,7 +21,7 @@ from app.core.security import (
verify_password,
)
from app.domain.audit.models import AuditAction
from app.domain.identity.models import User
from app.domain.identity.models import PlatformRole, User
from app.infrastructure.repositories.identity import (
MembershipRepository,
OrganizationRepository,
@@ -73,6 +73,11 @@ class TokenResponse(BaseModel):
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:
@@ -170,12 +175,16 @@ async def me(
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,
)
+176 -16
View File
@@ -18,10 +18,11 @@ from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel, Field
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,
@@ -36,6 +37,7 @@ from app.domain.identity.models import (
Membership,
MembershipStatus,
Organization,
PlatformInvite,
PlatformRole,
User,
)
@@ -43,30 +45,46 @@ 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):
organization_id: str
organization_name: str
email: str
role: str
expires_at: datetime
#: ``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
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
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.
@@ -74,9 +92,43 @@ class AcceptInviteRequest(BaseModel):
class AcceptInviteResult(BaseModel):
organization_id: str
organization_name: str
role: str
#: 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(
@@ -123,19 +175,46 @@ async def preview_invite(
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)
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=invite.email,
role=invite.role.value,
expires_at=invite.expires_at,
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,
)
@@ -162,6 +241,13 @@ async def accept_invite(
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)
@@ -225,7 +311,81 @@ async def accept_invite(
# 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)
@@ -16,7 +16,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.core.config import settings
from app.core.database import get_session
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
from app.core.errors import (
ConflictError,
NotFoundError,
PermissionDeniedError,
ValidationError,
)
from app.core.security import generate_token, hash_token
from app.domain.audit.models import AuditAction
from app.domain.base import ensure_unique_slug, slugify
@@ -370,3 +375,194 @@ async def list_audit(
)
for e in events
]
# ---------------------------------------------------------------------------
# Org sharing: transfer ownership, leave, list and revoke invites
# ---------------------------------------------------------------------------
class TransferOrgOwnershipRequest(BaseModel):
#: The user id of an *existing admin member* — ownership moves to somebody
#: who is already inside the organization, never to a stranger.
new_owner_user_id: str = Field(min_length=1, max_length=32)
#: Typed back by the operator. Handing over the keys to an org is as
#: consequential as a production deploy, and it gets the same confirmation.
confirm: str = Field(min_length=1, max_length=120)
class TransferOrgOwnershipOut(BaseModel):
id: str
name: str
slug: str
owner_user_id: str
role: str
class OrgInviteOut(BaseModel):
id: str
email: str
role: str
expires_at: datetime
accepted_at: datetime | None
invited_by_user_id: str | None
@router.post("/{org_id}/transfer-ownership", response_model=TransferOrgOwnershipOut)
async def transfer_org_ownership(
org_id: str,
payload: TransferOrgOwnershipRequest,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> TransferOrgOwnershipOut:
"""Hand the organization to another admin member.
Two rules, both hard:
* **The current org owner is the only caller.** An admin cannot promote a
different admin to owner — that is a takeover, not a transfer.
* **The target is already an admin member.** Ownership moves to somebody who
is already inside; inviting a brand-new owner is a different flow with
different consequences.
Both changes happen in one transaction: the previous owner becomes an admin
*and* the target becomes the owner, so there is never a moment with no owner.
"""
if context.membership.role != Role.OWNER:
raise PermissionDeniedError("فقط مالک سازمان می‌تواند مالکیت را منتقل کند.")
if payload.confirm.strip().lower() != context.organization.slug.strip().lower():
raise ValidationError("تأییدیه با نامک سازمان مطابقت ندارد.")
if payload.new_owner_user_id == context.user.id:
raise ValidationError("شما همین حالا مالک این سازمان هستید.")
memberships = MembershipRepository(session)
target = await memberships.get(org_id, payload.new_owner_user_id)
if target is None or target.status != MembershipStatus.ACTIVE:
raise NotFoundError("عضو موردنظر یافت نشد.")
if target.role != Role.ADMIN:
raise ValidationError("مالکیت تنها به عضوی با نقش admin منتقل می‌شود.")
# The old owner keeps a membership (as an admin) so the org never has an
# orphaned owner_user_id, and the new owner gets one with the owner role.
await memberships.set_role(target.id, Role.OWNER)
if context.membership.id != target.id:
await memberships.set_role(context.membership.id, Role.ADMIN)
organizations = OrganizationRepository(session)
await organizations.reassign_owner(org_id, payload.new_owner_user_id)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.ORG_OWNER_TRANSFER,
organization_id=org_id,
actor_user_id=context.user.id,
target_type="organization",
target_id=org_id,
metadata={
"from_user_id": context.user.id,
"to_user_id": payload.new_owner_user_id,
},
ip=ip,
user_agent=ua,
)
refreshed = await organizations.get(org_id)
if refreshed is None: # pragma: no cover - the org existed a moment ago
raise NotFoundError("سازمان یافت نشد.")
return TransferOrgOwnershipOut(
id=refreshed.id, name=refreshed.name, slug=refreshed.slug,
owner_user_id=refreshed.owner_user_id, role=Role.OWNER.value,
)
@router.post("/{org_id}/leave", status_code=status.HTTP_204_NO_CONTENT)
async def leave_organization(
org_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> None:
"""Step out of an organization.
The **last owner cannot leave.** An organization with no owner is invisible to
everybody — its ``owner_user_id`` points at someone who is not a member, so
even the remaining members cannot see it. The route refuses instead, which
means the operator has to transfer ownership or deliberately destroy the org.
"""
if context.membership.role == Role.OWNER:
owners = await MembershipRepository(session).count_owners(org_id)
if owners <= 1:
raise ConflictError(
"تنها مالک سازمان نمی‌تواند از سازمان خارج شود؛ ابتدا مالکیت را منتقل کنید."
)
await MembershipRepository(session).remove(context.membership.id)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.MEMBER_REMOVE,
organization_id=org_id,
actor_user_id=context.user.id,
target_type="member",
target_id=context.membership.id,
metadata={"via": "leave", "role": context.membership.role.value},
ip=ip,
user_agent=ua,
)
@router.get("/{org_id}/invites", response_model=list[OrgInviteOut])
async def list_org_invites(
org_id: str,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> list[OrgInviteOut]:
"""The pending invitations for this organization."""
if not context.membership.has(CAN_MANAGE_MEMBERS):
raise PermissionDeniedError("دیدن دعوت‌نامه‌ها نیازمند دسترسی مدیریت اعضا است.")
invites = await InviteRepository(session).list_pending(org_id)
return [
OrgInviteOut(
id=invite.id,
email=invite.email,
role=invite.role.value,
expires_at=invite.expires_at,
accepted_at=invite.accepted_at,
invited_by_user_id=invite.invited_by_user_id,
)
for invite in invites
]
@router.delete("/{org_id}/invites/{invite_id}", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_org_invite(
org_id: str,
invite_id: str,
request: Request,
context: OrgContext = Depends(get_org_context),
session: AsyncSession = Depends(get_session),
) -> None:
"""Revoke a pending invitation.
Revoking an already-accepted invite is a no-op: the person is already in, and
the interesting case for revoking is a link that went to the wrong address.
"""
if not context.membership.has(CAN_MANAGE_MEMBERS):
raise PermissionDeniedError("لغو دعوت‌نامه نیازمند دسترسی مدیریت اعضا است.")
invite = await InviteRepository(session).get(invite_id)
if invite is None or invite.organization_id != org_id:
raise NotFoundError("دعوت‌نامه یافت نشد.")
await InviteRepository(session).revoke(invite_id)
ip, ua = client_meta(request)
await record_audit(
session,
action=AuditAction.MEMBER_INVITE_REVOKE,
organization_id=org_id,
actor_user_id=context.user.id,
target_type="invite",
target_id=invite_id,
metadata={"email": invite.email},
ip=ip,
user_agent=ua,
)