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
@@ -5,6 +5,75 @@ Use ``from app.core.database import Base`` and the helpers in
``app.infrastructure.db.models`` here: that module imports this one (import cycle).
Foreign keys to existing tables are written as plain strings, e.g.
``ForeignKey("organizations.id", ondelete="CASCADE")``. See docs/plan/PLAN-v2.md §6.
``platform_invites`` is the door into the product (D12). Unlike an *organization*
invite — which joins someone to an org that already exists — this one creates a
brand-new platform account, so it is a capability rather than a message:
* it is stored only as a hash, exactly like an org invite;
* it is single-use and time-limited (``PLATFORM_INVITE_TTL_HOURS``);
* the *email is optional*. A link the owner hands to somebody in a chat is a
bearer token; an email pinned to it is a stronger promise but also one more
thing for the owner to type, and the plan asks for both to be possible. When
the email is set, the account that redeems the link is bound to it;
* redeeming it creates an account with the ``manager`` platform role, which is
what lets the holder go on to create organizations.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
from app.domain.base import utcnow
from app.infrastructure.db.columns import created, pk
class PlatformInviteRow(Base):
__tablename__ = "platform_invites"
__table_args__ = (
Index("ix_platform_invite_token", "token_hash", unique=True),
Index("ix_platform_invite_email", "email"),
)
id: Mapped[str] = pk()
#: sha256 of the raw token. The token itself is shown exactly once.
token_hash: Mapped[str] = mapped_column(String(64), nullable=False)
#: Optional. When set, the redeeming account is bound to it.
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
#: The platform role the redeemed account receives. Manager only, for now.
platform_role: Mapped[str] = mapped_column(String(16), default="manager", nullable=False)
created_by_user_id: Mapped[str] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
used_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
note: Mapped[str] = mapped_column(Text, default="", nullable=False)
created_at: Mapped[datetime] = created()
@property
def is_redeemed(self) -> bool:
return self.used_at is not None
@property
def is_revoked(self) -> bool:
return self.revoked_at is not None
def is_expired(self, now: datetime | None = None) -> bool:
if self.used_at is not None or self.revoked_at is not None:
return True
reference = now or utcnow()
return self.expires_at <= reference
__all__ = ["PlatformInviteRow"]
@@ -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()