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
@@ -20,21 +20,40 @@ and expensive to get wrong:
from __future__ import annotations
import logging
import secrets
from dataclasses import dataclass
from datetime import timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import hash_token
from app.domain.base import utcnow
from app.domain.identity.models import (
Membership,
MembershipStatus,
PlatformInvite,
PlatformRole,
Role,
User,
)
from app.infrastructure.repositories.identity import (
MembershipRepository,
OrganizationRepository,
PlatformInviteRepository,
)
logger = logging.getLogger("agy.org.invites")
#: Raw token length. ``secrets.token_urlsafe(24)`` is ~144 bits — well past
#: anything a brute force against a rate-limited endpoint can reach.
TOKEN_BYTES = 24
#: Floor on the expiry, whatever the setting says. A link that dies in five
#: minutes is worse than useless: the invitee opens it hours later.
MIN_TTL_HOURS = 1
@dataclass(slots=True)
class OrganizationTransfer:
@@ -84,3 +103,112 @@ async def demote_manager_accounts_organizations(
return await transfer_owned_organizations(
session, from_user_id=manager.id, to_user=owner
)
# ---------------------------------------------------------------------------
# Platform invites — the door into the product (D12)
# ---------------------------------------------------------------------------
@dataclass(slots=True)
class IssuedInvite:
"""The invite plus its raw token — the only moment that token exists."""
invite: PlatformInvite
token: str
@property
def invite_url(self) -> str:
"""The link the owner pastes into a chat message."""
return f"{settings.app_url.rstrip('/')}/accept-invite?token={self.token}"
class PlatformInviteService:
"""Issue, list and revoke the owner's account invitations.
A *manager* account is the only kind that can create an organization, and
accounts are never self-service, so the owner creates a link and that link
turns a stranger into a manager.
What makes it a capability rather than a message: the token is stored only as
a hash and shown exactly once, it is single-use, it expires after
``PLATFORM_INVITE_TTL_HOURS``, and the owner can revoke it at any time.
One rule from the *organization* invite flow carries over unchanged — a link
can never reset the password of an account that already exists. If the
invited email already belongs to somebody, they sign in first; otherwise a
leaked link would be an account takeover for anyone who ever received one.
"""
def __init__(self, repo: PlatformInviteRepository) -> None:
self.repo = repo
def _ttl(self) -> timedelta:
hours = max(int(settings.platform_invite_ttl_hours or 1), MIN_TTL_HOURS)
return timedelta(hours=hours)
async def issue(
self,
*,
owner_user_id: str,
email: str | None = None,
platform_role: PlatformRole = PlatformRole.MANAGER,
note: str = "",
) -> IssuedInvite:
"""Create an invite and return it with its raw token, exactly once.
``platform_role`` is accepted so the owner can invite a plain ``user`` too,
but ``owner`` is refused for the same reason ``create_user`` refuses it:
the owner seat moves only through a deliberate ownership transfer.
"""
from app.core.errors import ValidationError
if platform_role == PlatformRole.OWNER:
raise ValidationError(
"دعوت نقشی برای مالک سامانه قابل صدور نیست؛ انتقال مالکیت مسیر جداگانه‌ای است."
)
token = secrets.token_urlsafe(TOKEN_BYTES)
invite = await self.repo.create(
PlatformInvite(
token_hash=hash_token(token),
email=(email or "").strip().lower() or None,
platform_role=platform_role,
created_by_user_id=owner_user_id,
expires_at=utcnow() + self._ttl(),
note=note.strip()[:500],
)
)
return IssuedInvite(invite=invite, token=token)
async def list_pending(self) -> list[PlatformInvite]:
return await self.repo.list_pending()
async def revoke(self, *, invite_id: str, by_user_id: str) -> None:
"""Revoke an unredeemed invite.
Revoking one that was already redeemed is a no-op rather than an error:
the state is already the one the operator asked for, and a second click
on "revoke" should not fail loudly.
"""
invite = await self.repo.get(invite_id)
if invite is None or invite.is_redeemed or invite.is_revoked:
return
await self.repo.mark_revoked(invite_id=invite_id, by_user_id=by_user_id)
async def redeem(
self, *, token: str, user_id: str
) -> tuple[bool, PlatformInvite | None]:
"""Burn the token for ``user_id``. Returns ``(ok, invite)``.
Atomicity matters: two requests arriving at once must not both succeed,
which is why ``mark_used`` refuses when the row is already stamped. Every
rejection path gives the *same* answer, so a probe cannot tell "no such
link" from "already used" from "revoked".
"""
invite = await self.repo.get_by_token_hash(hash_token(token))
if invite is None or invite.is_expired():
return False, None
if not await self.repo.mark_used(invite_id=invite.id, user_id=user_id):
return False, None
return True, invite