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
@@ -16,6 +16,7 @@ from app.domain.identity.models import (
Membership,
MembershipStatus,
Organization,
PlatformInvite,
PlatformRole,
Role,
User,
@@ -26,6 +27,7 @@ from app.infrastructure.db.models import (
OrganizationRow,
UserRow,
)
from app.infrastructure.db.models_org import PlatformInviteRow
def _aware(value: datetime) -> datetime:
@@ -301,6 +303,38 @@ class InviteRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, invite_id: str) -> Invite | None:
"""Tenant-safe only when the caller checks ``organization_id`` afterwards."""
row = await self.session.get(InviteRow, invite_id)
if row is None:
return None
return Invite(
id=row.id,
organization_id=row.organization_id,
email=row.email,
role=Role(row.role),
token_hash=row.token_hash,
invited_by_user_id=row.invited_by_user_id,
expires_at=row.expires_at,
accepted_at=row.accepted_at,
created_at=row.created_at,
)
async def revoke(self, invite_id: str) -> None:
"""Revoke a pending invitation by making it expire.
Deliberately *expiry* rather than a `revoked_at` column: adding one means
editing `db/models.py`, which is locked for this workstream. Collapsing
"revoked" into "no longer valid" is also semantically honest — a revoked
link and an expired link are both simply unusable — and it cannot
un-accept somebody who already joined, which is a member removal.
"""
row = await self.session.get(InviteRow, invite_id)
if row is None or row.accepted_at is not None:
return
row.expires_at = _now()
await self.session.flush()
async def get_by_token_hash(self, token_hash: str) -> Invite | None:
stmt = select(InviteRow).where(InviteRow.token_hash == token_hash)
row = (await self.session.execute(stmt)).scalar_one_or_none()
@@ -335,3 +369,106 @@ class InviteRepository:
if row:
row.accepted_at = when
await self.session.flush()
def _utcnow():
from app.domain.base import utcnow as _u
return _u()
def _now():
return datetime.now(UTC)
def _platform_invite(row: PlatformInviteRow) -> PlatformInvite:
return PlatformInvite(
id=row.id,
token_hash=row.token_hash,
email=row.email,
platform_role=PlatformRole(row.platform_role),
created_by_user_id=row.created_by_user_id,
expires_at=row.expires_at,
used_at=row.used_at,
used_by_user_id=row.used_by_user_id,
revoked_at=row.revoked_at,
revoked_by_user_id=row.revoked_by_user_id,
note=row.note,
created_at=row.created_at,
)
class PlatformInviteRepository:
"""Persistence for the platform owner's account invitations (D12).
The token is stored as a hash and the raw value is returned exactly once at
creation, so a leaked database cannot mint manager accounts. Both
single-use and revocable: a link is a capability, and capabilities have to
be revocable.
"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_by_token_hash(self, token_hash: str) -> PlatformInvite | None:
row = await self.session.scalar(
select(PlatformInviteRow).where(PlatformInviteRow.token_hash == token_hash)
)
return _platform_invite(row) if row else None
async def get(self, invite_id: str) -> PlatformInvite | None:
row = await self.session.get(PlatformInviteRow, invite_id)
return _platform_invite(row) if row else None
async def list_pending(self, *, limit: int = 100) -> list[PlatformInvite]:
"""Invites that still hold a valid, unredeemed link."""
stmt = (
select(PlatformInviteRow)
.where(
PlatformInviteRow.used_at.is_(None),
PlatformInviteRow.revoked_at.is_(None),
PlatformInviteRow.expires_at > _now(),
)
.order_by(PlatformInviteRow.created_at.desc())
.limit(min(max(limit, 1), 200))
)
return [_platform_invite(r) for r in (await self.session.execute(stmt)).scalars()]
async def create(self, invite: PlatformInvite) -> PlatformInvite:
row = PlatformInviteRow(
id=invite.id,
token_hash=invite.token_hash,
email=invite.email,
platform_role=invite.platform_role,
created_by_user_id=invite.created_by_user_id,
expires_at=invite.expires_at,
used_at=invite.used_at,
used_by_user_id=invite.used_by_user_id,
revoked_at=invite.revoked_at,
revoked_by_user_id=invite.revoked_by_user_id,
note=invite.note,
created_at=invite.created_at,
)
self.session.add(row)
await self.session.flush()
return _platform_invite(row)
async def mark_used(self, *, invite_id: str, user_id: str) -> bool:
"""Burn the token. False when someone else already did."""
row = await self.session.get(PlatformInviteRow, invite_id)
if row is None or row.used_at is not None:
# Refuse rather than silently overwrite: two workers redeeming the
# same link must not both succeed.
return False
row.used_at = _now()
row.used_by_user_id = user_id
await self.session.flush()
return True
async def mark_revoked(self, *, invite_id: str, by_user_id: str) -> None:
row = await self.session.get(PlatformInviteRow, invite_id)
if row is None:
return
row.revoked_at = _now()
row.revoked_by_user_id = by_user_id
await self.session.flush()