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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user