diff --git a/apps/api/alembic/versions/a1b2c3d4e5f1_org_platform.py b/apps/api/alembic/versions/a1b2c3d4e5f1_org_platform.py
new file mode 100644
index 0000000..8b2dadf
--- /dev/null
+++ b/apps/api/alembic/versions/a1b2c3d4e5f1_org_platform.py
@@ -0,0 +1,62 @@
+"""org platform: platform invites (the door into the product)
+
+Revision ID: a1b2c3d4e5f1
+Revises: a3b4c5d6e7f4
+Create Date: 2026-10-14 02:20:00.000000
+
+Agent 1's own migration. Chained after the delivery workstream's head so the
+history stays linear.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "a1b2c3d4e5f1"
+down_revision: str | None = "a3b4c5d6e7f4"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "platform_invites",
+ sa.Column("id", sa.String(length=32), primary_key=True),
+ sa.Column("token_hash", sa.String(length=64), nullable=False),
+ sa.Column("email", sa.String(length=320), nullable=True),
+ sa.Column("platform_role", sa.String(length=16), nullable=False, server_default="manager"),
+ sa.Column(
+ "created_by_user_id",
+ sa.String(length=32),
+ sa.ForeignKey("users.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column(
+ "used_by_user_id",
+ sa.String(length=32),
+ sa.ForeignKey("users.id", ondelete="SET NULL"),
+ nullable=True,
+ ),
+ sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column(
+ "revoked_by_user_id",
+ sa.String(length=32),
+ sa.ForeignKey("users.id", ondelete="SET NULL"),
+ nullable=True,
+ ),
+ sa.Column("note", sa.Text(), nullable=False, server_default=""),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ )
+ op.create_index("ix_platform_invite_token", "platform_invites", ["token_hash"], unique=True)
+ op.create_index("ix_platform_invite_email", "platform_invites", ["email"])
+ op.create_index("ix_platform_invites_created_by_user_id", "platform_invites", ["created_by_user_id"])
+ op.create_index("ix_platform_invites_used_by_user_id", "platform_invites", ["used_by_user_id"])
+
+
+def downgrade() -> None:
+ op.drop_table("platform_invites")
diff --git a/apps/api/app/application/services/platform_users.py b/apps/api/app/application/services/platform_users.py
index e7f706a..36bb0f5 100644
--- a/apps/api/app/application/services/platform_users.py
+++ b/apps/api/app/application/services/platform_users.py
@@ -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
diff --git a/apps/api/app/domain/audit/models.py b/apps/api/app/domain/audit/models.py
index 2adfb07..e60980f 100644
--- a/apps/api/app/domain/audit/models.py
+++ b/apps/api/app/domain/audit/models.py
@@ -30,6 +30,8 @@ class AuditAction:
MEMBER_JOIN = "member.join"
MEMBER_ROLE_CHANGE = "member.role_change"
MEMBER_REMOVE = "member.remove"
+ #: An organization's pending invitation was revoked.
+ MEMBER_INVITE_REVOKE = "member.invite_revoke"
#: The platform owner created an account (manager or plain user).
USER_CREATE = "user.create"
#: A user's platform role changed (user <-> manager, or manager -> user).
diff --git a/apps/api/app/domain/identity/models.py b/apps/api/app/domain/identity/models.py
index ba507f6..ca09c15 100644
--- a/apps/api/app/domain/identity/models.py
+++ b/apps/api/app/domain/identity/models.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
-from datetime import datetime
+from datetime import UTC, datetime
from enum import StrEnum
from app.domain.base import new_id, utcnow
@@ -187,3 +187,50 @@ class Invite:
def is_pending(self, now: datetime | None = None) -> bool:
return self.accepted_at is None and not self.is_expired(now)
+
+
+@dataclass(slots=True, kw_only=True)
+class PlatformInvite:
+ """The platform owner's invitation to create a new manager account (D12).
+
+ Different in kind from an ``Invite``: an org invite joins someone to an
+ organization that already exists, while this one mints the platform account
+ itself. The token lives only as a hash.
+ """
+
+ token_hash: str
+ created_by_user_id: str
+ expires_at: datetime
+ email: str | None = None
+ platform_role: PlatformRole = PlatformRole.MANAGER
+ used_at: datetime | None = None
+ used_by_user_id: str | None = None
+ revoked_at: datetime | None = None
+ revoked_by_user_id: str | None = None
+ note: str = ""
+ id: str = field(default_factory=new_id)
+ created_at: datetime = field(default_factory=utcnow)
+
+ @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:
+ """Redeeming a used or revoked link is also "expired" — one helper, one rule.
+
+ Both sides are normalised to aware before comparing: the row comes back
+ from SQLite with the offset stripped, while ``utcnow()`` carries one, and
+ comparing the two raises rather than answering.
+ """
+ if self.used_at is not None or self.revoked_at is not None:
+ return True
+ return _aware(self.expires_at) <= _aware(now or utcnow())
+
+
+def _aware(value: datetime) -> datetime:
+ """A datetime guaranteed to carry a UTC offset."""
+ return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
diff --git a/apps/api/app/infrastructure/db/models_org.py b/apps/api/app/infrastructure/db/models_org.py
index a62615c..663b9e0 100644
--- a/apps/api/app/infrastructure/db/models_org.py
+++ b/apps/api/app/infrastructure/db/models_org.py
@@ -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"]
diff --git a/apps/api/app/infrastructure/repositories/identity.py b/apps/api/app/infrastructure/repositories/identity.py
index 086283f..5b944e2 100644
--- a/apps/api/app/infrastructure/repositories/identity.py
+++ b/apps/api/app/infrastructure/repositories/identity.py
@@ -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()
diff --git a/apps/api/app/interfaces/http/routers/admin_users.py b/apps/api/app/interfaces/http/routers/admin_users.py
index 2e4b585..716aaec 100644
--- a/apps/api/app/interfaces/http/routers/admin_users.py
+++ b/apps/api/app/interfaces/http/routers/admin_users.py
@@ -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,
+ )
diff --git a/apps/api/app/interfaces/http/routers/auth.py b/apps/api/app/interfaces/http/routers/auth.py
index ea8bde8..988386c 100644
--- a/apps/api/app/interfaces/http/routers/auth.py
+++ b/apps/api/app/interfaces/http/routers/auth.py
@@ -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,
)
diff --git a/apps/api/app/interfaces/http/routers/invites.py b/apps/api/app/interfaces/http/routers/invites.py
index e876fdb..96c81c3 100644
--- a/apps/api/app/interfaces/http/routers/invites.py
+++ b/apps/api/app/interfaces/http/routers/invites.py
@@ -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)
diff --git a/apps/api/app/interfaces/http/routers/organizations.py b/apps/api/app/interfaces/http/routers/organizations.py
index ebc91b9..4689929 100644
--- a/apps/api/app/interfaces/http/routers/organizations.py
+++ b/apps/api/app/interfaces/http/routers/organizations.py
@@ -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,
+ )
diff --git a/apps/api/tests/test_org_platform_invites.py b/apps/api/tests/test_org_platform_invites.py
new file mode 100644
index 0000000..3be839c
--- /dev/null
+++ b/apps/api/tests/test_org_platform_invites.py
@@ -0,0 +1,370 @@
+"""§v2-A1.1 — platform invites, roles and org sharing.
+
+The door into the product, so the tests are mostly about the guards around it:
+
+* a non-owner cannot mint a manager;
+* the token is single-use, and every rejection looks the same to a probe;
+* an existing email cannot be taken over by a link;
+* the role the link carries is the role the account gets;
+* ownership transfer goes only to an existing admin, and is audited;
+* the last owner of an organization cannot walk out and leave it invisible;
+* tenant isolation: a member of one org cannot list or revoke another's invites.
+"""
+
+from __future__ import annotations
+
+import pytest_asyncio
+from httpx import AsyncClient
+
+from app.domain.identity.models import PlatformRole
+from tests.conftest import auth_header, signup
+
+# ---------------------------------------------------------------------------
+# helpers
+# ---------------------------------------------------------------------------
+
+
+@pytest_asyncio.fixture(autouse=True)
+async def _platform_seed(seeded):
+ return seeded
+
+
+async def _owner(client: AsyncClient) -> tuple[str, dict, str]:
+ """The platform owner plus their slug, and a bearer header."""
+ token = (
+ await signup(
+ client,
+ "a1-owner@example.com",
+ name="مالک سامانه",
+ platform_role=PlatformRole.OWNER,
+ )
+ )["access_token"]
+ headers = auth_header(token)
+ org_id = (
+ await client.post(
+ "/api/organizations", json={"name": "سازمان مالک سامانه"}, headers=headers
+ )
+ ).json()["id"]
+ slug = (
+ await client.get(f"/api/organizations/{org_id}", headers=headers)
+ ).json()["slug"]
+ return org_id, headers, slug
+
+
+async def _issue(client: AsyncClient, headers: dict, **body) -> dict:
+ response = await client.post("/api/admin/platform-invites", json=body, headers=headers)
+ assert response.status_code == 200, response.text
+ return response.json()
+
+
+def _token_from_url(url: str) -> str:
+ return url.split("token=")[-1]
+
+
+# ---------------------------------------------------------------------------
+# the issue side (owner only)
+# ---------------------------------------------------------------------------
+
+
+async def test_a_non_owner_cannot_issue_a_platform_invite(client: AsyncClient):
+ token = (await signup(client, "a1-not-owner@example.com"))["access_token"]
+ response = await client.post(
+ "/api/admin/platform-invites", json={}, headers=auth_header(token)
+ )
+ assert response.status_code == 403, response.text
+
+
+async def test_issuing_returns_the_url_exactly_once(client: AsyncClient):
+ _org, headers, _slug = await _owner(client)
+
+ issued = await _issue(client, headers)
+
+ assert issued["invite_url"].startswith("http")
+ assert "token=" in issued["invite_url"]
+ # The raw token is never stored, so listing cannot reproduce it.
+ listed = await client.get("/api/admin/platform-invites", headers=headers)
+ assert listed.status_code == 200
+ for row in listed.json():
+ assert "token=" not in str(row)
+ assert "invite_url" not in row
+
+
+async def test_an_owner_seat_cannot_be_invited(client: AsyncClient):
+ """The owner seat moves by a deliberate transfer, never by a link."""
+ _org, headers, _slug = await _owner(client)
+ response = await client.post(
+ "/api/admin/platform-invites",
+ json={"platform_role": "owner"},
+ headers=headers,
+ )
+ assert response.status_code == 422, response.text
+ assert "مالک" in response.json()["error"]["message"]
+
+
+async def test_an_email_can_be_pinned_to_the_link(client: AsyncClient):
+ _org, headers, _slug = await _owner(client)
+ issued = await _issue(client, headers, email="pinned@example.com", note="از راه پیام")
+ assert issued["invite"]["email"] == "pinned@example.com"
+ assert issued["invite"]["note"] == "از راه پیام"
+
+
+# ---------------------------------------------------------------------------
+# the redeem side
+# ---------------------------------------------------------------------------
+
+
+async def test_redeeming_a_platform_invite_makes_a_manager(client: AsyncClient):
+ _org, headers, _slug = await _owner(client)
+ issued = await _issue(client, headers)
+ token = _token_from_url(issued["invite_url"])
+
+ preview = await client.get(f"/api/invites/{token}")
+ assert preview.status_code == 200, preview.text
+ assert preview.json()["kind"] == "platform"
+ assert preview.json()["platform_role"] == "manager"
+
+ accepted = await client.post(
+ "/api/invites/accept",
+ json={"token": token, "password": "S3cure-passw0rd!", "email": "newmgr@example.com"},
+ )
+ assert accepted.status_code == 200, accepted.text
+ assert accepted.json()["kind"] == "platform"
+ assert accepted.json()["role"] == "manager"
+
+ # The account really is a manager, which is what lets it create organizations.
+ login = await client.post(
+ "/api/auth/login",
+ json={"email": "newmgr@example.com", "password": "S3cure-passw0rd!"},
+ )
+ assert login.status_code == 200, login.text
+ profile = await client.get(
+ "/api/auth/me", headers=auth_header(login.json()["access_token"])
+ )
+ assert profile.json()["user"]["platform_role"] == "manager"
+
+
+async def test_a_platform_token_is_single_use(client: AsyncClient):
+ _org, headers, _slug = await _owner(client)
+ issued = await _issue(client, headers, email="once@example.com")
+ token = _token_from_url(issued["invite_url"])
+
+ first = await client.post(
+ "/api/invites/accept", json={"token": token, "password": "S3cure-passw0rd!"}
+ )
+ assert first.status_code == 200, first.text
+
+ second = await client.post(
+ "/api/invites/accept", json={"token": token, "password": "S3cure-passw0rd!"}
+ )
+ # The same answer any other rejection gets, so a probe cannot tell a burned
+ # link from a forged one.
+ assert second.status_code == 404, second.text
+
+
+async def test_a_revoked_link_cannot_be_redeemed(client: AsyncClient):
+ _org, headers, _slug = await _owner(client)
+ issued = await _issue(client, headers)
+ token = _token_from_url(issued["invite_url"])
+
+ revoked = await client.post(
+ f"/api/admin/platform-invites/{issued['invite']['id']}/revoke",
+ headers=headers,
+ )
+ assert revoked.status_code == 200, revoked.text
+
+ accepted = await client.post(
+ "/api/invites/accept", json={"token": token, "password": "S3cure-passw0rd!"}
+ )
+ assert accepted.status_code == 404, accepted.text
+
+
+async def test_a_link_cannot_reset_an_existing_account(client: AsyncClient):
+ """Otherwise a leaked link would be an account takeover."""
+ existing = (await signup(client, "existing@example.com"))["access_token"]
+ assert existing
+
+ _org, headers, _slug = await _owner(client)
+ issued = await _issue(client, headers, email="existing@example.com")
+ token = _token_from_url(issued["invite_url"])
+
+ response = await client.post(
+ "/api/invites/accept", json={"token": token, "password": "S3cure-passw0rd!"}
+ )
+ assert response.status_code == 403, response.text
+
+ # The account's real password still works — the invite never touched it.
+ login = await client.post(
+ "/api/auth/login",
+ json={"email": "existing@example.com", "password": "password123"},
+ )
+ assert login.status_code == 200, login.text
+
+
+async def test_an_expired_link_is_refused(client: AsyncClient, session_factory):
+ _org, headers, _slug = await _owner(client)
+ issued = await _issue(client, headers)
+ token = _token_from_url(issued["invite_url"])
+
+ # Backdate the row instead of sleeping: the rule is about the timestamp, not
+ # about the clock this suite happens to run on.
+ from datetime import UTC, datetime
+
+ from app.infrastructure.db.models_org import PlatformInviteRow
+
+ invite_id = issued["invite"]["id"]
+ async with session_factory() as session:
+ row = await session.get(PlatformInviteRow, invite_id)
+ assert row is not None
+ row.expires_at = datetime.now(UTC).replace(year=2020)
+ await session.commit()
+
+ response = await client.get(f"/api/invites/{token}")
+ assert response.status_code == 404, response.text
+
+
+# ---------------------------------------------------------------------------
+# needs_onboarding
+# ---------------------------------------------------------------------------
+
+
+async def test_a_manager_with_no_org_needs_onboarding(client: AsyncClient):
+ _org, headers, _slug = await _owner(client)
+ issued = await _issue(client, headers, email="fresh@example.com")
+ token = _token_from_url(issued["invite_url"])
+ await client.post("/api/invites/accept", json={"token": token, "password": "S3cure-passw0rd!"})
+
+ me = await client.post(
+ "/api/auth/login",
+ json={"email": "fresh@example.com", "password": "S3cure-passw0rd!"},
+ )
+ assert me.status_code == 200, me.text
+ profile = await client.get("/api/auth/me", headers=auth_header(me.json()["access_token"]))
+ assert profile.status_code == 200
+ assert profile.json()["needs_onboarding"] is True
+
+
+async def test_the_owner_never_needs_onboarding(client: AsyncClient):
+ _org, headers, _slug = await _owner(client)
+ me = await client.get("/api/auth/me", headers=headers)
+ assert me.json()["needs_onboarding"] is False
+
+
+async def test_a_manager_with_an_org_does_not_need_onboarding(client: AsyncClient):
+ _org, headers, _slug = await _owner(client)
+ me = await client.get("/api/auth/me", headers=headers)
+ # The owner created one organization to hold their own account.
+ assert me.json()["needs_onboarding"] is False
+
+
+# ---------------------------------------------------------------------------
+# org sharing
+# ---------------------------------------------------------------------------
+
+
+async def _org_with_admin(client: AsyncClient) -> dict:
+ """An org whose owner is the caller and whose admin is a second account."""
+ _org, owner_headers, slug = await _owner(client)
+ # Use the org-sharing invite flow to bring in an admin.
+ invited = await client.post(
+ f"/api/organizations/{_org}/invites",
+ json={"email": "admin@example.com", "role": "admin"},
+ headers=owner_headers,
+ )
+ assert invited.status_code == 201, invited.text
+ admin_token = invited.json()["invite_token"]
+ accept = await client.post(
+ "/api/invites/accept", json={"token": admin_token, "password": "S3cure-passw0rd!"}
+ )
+ assert accept.status_code == 200, accept.text
+ return {
+ "org_id": _org,
+ "slug": slug,
+ "owner_headers": owner_headers,
+ "admin_email": "admin@example.com",
+ }
+
+
+async def test_transferring_ownership_needs_the_org_slug(client: AsyncClient):
+ ctx = await _org_with_admin(client)
+ response = await client.post(
+ f"/api/organizations/{ctx['org_id']}/transfer-ownership",
+ json={"new_owner_user_id": "someone", "confirm": "wrong-slug"},
+ headers=ctx["owner_headers"],
+ )
+ assert response.status_code == 422, response.text
+
+
+async def test_ownership_moves_only_to_an_existing_admin(client: AsyncClient):
+ ctx = await _org_with_admin(client)
+ strangers = (
+ await signup(client, "stranger@example.com")
+ )["access_token"]
+ stranger_id = (
+ await client.get("/api/auth/me", headers=auth_header(strangers))
+ ).json()["user"]["id"]
+
+ response = await client.post(
+ f"/api/organizations/{ctx['org_id']}/transfer-ownership",
+ json={"new_owner_user_id": stranger_id, "confirm": ctx["slug"]},
+ headers=ctx["owner_headers"],
+ )
+ # 404, not 403: the stranger is indistinguishable from a member of another org.
+ assert response.status_code == 404, response.text
+
+
+async def test_the_last_owner_cannot_leave(client: AsyncClient):
+ ctx = await _org_with_admin(client)
+ response = await client.post(
+ f"/api/organizations/{ctx['org_id']}/leave", headers=ctx["owner_headers"]
+ )
+ assert response.status_code == 409, response.text
+
+
+async def test_a_non_owner_org_member_can_leave(client: AsyncClient):
+ ctx = await _org_with_admin(client)
+ login = await client.post(
+ "/api/auth/login",
+ json={"email": ctx["admin_email"], "password": "S3cure-passw0rd!"},
+ )
+ assert login.status_code == 200
+ admin_headers = auth_header(login.json()["access_token"])
+
+ response = await client.post(
+ f"/api/organizations/{ctx['org_id']}/leave", headers=admin_headers
+ )
+ assert response.status_code == 204, response.text
+
+
+async def test_a_viewer_cannot_list_invites(client: AsyncClient):
+ ctx = await _org_with_admin(client)
+ # A signed-in outsider is not a member at all.
+ outsider = (await signup(client, "outsider@example.com"))["access_token"]
+ response = await client.get(
+ f"/api/organizations/{ctx['org_id']}/invites",
+ headers=auth_header(outsider),
+ )
+ assert response.status_code == 404, response.text
+
+
+async def test_an_admin_can_revoke_an_org_invite(client: AsyncClient):
+ ctx = await _org_with_admin(client)
+ invited = await client.post(
+ f"/api/organizations/{ctx['org_id']}/invites",
+ json={"email": "revoked@example.com", "role": "viewer"},
+ headers=ctx["owner_headers"],
+ )
+ assert invited.status_code == 201, invited.text
+ invite_id = invited.json()["id"]
+
+ revoked = await client.delete(
+ f"/api/organizations/{ctx['org_id']}/invites/{invite_id}",
+ headers=ctx["owner_headers"],
+ )
+ assert revoked.status_code == 204, revoked.text
+
+ audit = await client.get(
+ f"/api/organizations/{ctx['org_id']}/audit", headers=ctx["owner_headers"]
+ )
+ assert any(
+ event["action"] == "member.invite_revoke" for event in audit.json()
+ ), "a revocation must be auditable"
diff --git a/apps/web/src/app/(app)/admin/users/page.tsx b/apps/web/src/app/(app)/admin/users/page.tsx
index ca4d371..855a1da 100644
--- a/apps/web/src/app/(app)/admin/users/page.tsx
+++ b/apps/web/src/app/(app)/admin/users/page.tsx
@@ -12,7 +12,7 @@
*/
import * as React from "react";
-import type { AdminUser, PlatformRole } from "@agy/contracts";
+import type { AdminUser, PlatformInvite, PlatformRole } from "@agy/contracts";
import { PLATFORM_ROLE_LABELS } from "@agy/contracts";
import {
Badge,
@@ -31,6 +31,7 @@ import {
type ToastMessage,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
+import { orgApi } from "@/lib/api-org";
const ROLE_TONES: Record
+ هر لینک یک بار قابل استفاده است و پس از انقضا یا لغو شدن از کار میافتد. +
+ {invites.length === 0 ? ( +لینک فعالی وجود ندارد.
+ ) : ( ++ این لینک را برای مخاطب بفرستید. فقط همین یک بار نمایش داده میشود. +
+