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
@@ -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")
@@ -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
+2
View File
@@ -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).
+48 -1
View File
@@ -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)
@@ -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()
@@ -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,
)
+10 -1
View File
@@ -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,
)
+176 -16
View File
@@ -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)
@@ -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,
)
+370
View File
@@ -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"
+164 -2
View File
@@ -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<PlatformRole, "info" | "success" | "neutral"> = {
owner: "info",
@@ -67,6 +68,71 @@ export default function AdminUsersPage() {
| null
>(null);
// --- platform invitations (v2 D12) ------------------------------------
// A link is how the owner makes a manager without minting a password they then
// have to deliver themselves. The token exists only in the response to `issue`.
const [invites, setInvites] = React.useState<PlatformInvite[]>([]);
const [inviteOpen, setInviteOpen] = React.useState(false);
const [inviteEmail, setInviteEmail] = React.useState("");
const [inviteNote, setInviteNote] = React.useState("");
const [issuing, setIssuing] = React.useState(false);
const [inviteError, setInviteError] = React.useState<string | null>(null);
const [issuedLink, setIssuedLink] = React.useState<string | null>(null);
const [revoking, setRevoking] = React.useState<string | null>(null);
const loadInvites = React.useCallback(async () => {
try {
setInvites(await orgApi.listPlatformInvites());
} catch {
// The invitation console is diagnostic; a failure must not break the page
// whose real job is the account list.
setInvites([]);
}
}, []);
React.useEffect(() => {
void loadInvites();
}, [loadInvites]);
const onIssueInvite = async (event: React.FormEvent) => {
event.preventDefault();
setIssuing(true);
setInviteError(null);
try {
const issued = await orgApi.issuePlatformInvite({
email: inviteEmail.trim() || undefined,
note: inviteNote.trim() || undefined,
});
// Shown once, never retrievable — the same discipline as a temp password.
setIssuedLink(issued.invite_url);
toast("لینک دعوت ساخته شد", "success", "این لینک فقط یک بار نمایش داده می‌شود.");
setInviteEmail("");
setInviteNote("");
await loadInvites();
} catch (err) {
setInviteError(err instanceof ApiError ? err.message : "ساخت لینک دعوت ناموفق بود.");
} finally {
setIssuing(false);
}
};
const onRevokeInvite = async (inviteId: string) => {
setRevoking(inviteId);
try {
await orgApi.revokePlatformInvite(inviteId);
toast("لینک دعوت لغو شد", "success");
await loadInvites();
} catch (err) {
toast(
"لغو دعوت ناموفق بود",
"danger",
err instanceof ApiError ? err.message : undefined,
);
} finally {
setRevoking(null);
}
};
const toast = React.useCallback((title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
setToasts((current) => [
...current,
@@ -191,7 +257,12 @@ export default function AdminUsersPage() {
خودشان را می‌سازند؛ کاربران عادی با دعوت به سازمان‌ها می‌پیوندند.
</p>
</div>
<Button onClick={() => setCreateOpen(true)}>ساخت کاربر</Button>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setInviteOpen(true)}>
دعوت مدیر جدید
</Button>
<Button onClick={() => setCreateOpen(true)}>ساخت کاربر</Button>
</div>
</header>
<div className="grid gap-3 sm:grid-cols-3">
@@ -200,6 +271,48 @@ export default function AdminUsersPage() {
<Stat label="کاربران عادی" value={plainUsers.length} />
</div>
<Card>
<CardTitle>لینک‌های دعوت فعال</CardTitle>
<div className="mt-3 flex flex-col gap-2">
<p className="text-sm text-slate-400">
هر لینک یک بار قابل استفاده است و پس از انقضا یا لغو شدن از کار می‌افتد.
</p>
{invites.length === 0 ? (
<p className="text-xs text-slate-500">لینک فعالی وجود ندارد.</p>
) : (
<ul className="flex flex-col gap-2">
{invites.map((invite) => (
<li
key={invite.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-2"
>
<div className="flex min-w-0 flex-col gap-1">
<span className="ltr-inline truncate text-sm text-slate-200">
{invite.email ?? "بدون ایمیل مشخص"}
</span>
<span className="text-xs text-slate-500">
{PLATFORM_ROLE_LABELS[invite.platform_role as PlatformRole] ??
invite.platform_role}
{" · انقضا: "}
{new Date(invite.expires_at).toLocaleDateString("fa-IR")}
{invite.note ? ` · ${invite.note}` : ""}
</span>
</div>
<Button
size="sm"
variant="danger"
loading={revoking === invite.id}
onClick={() => void onRevokeInvite(invite.id)}
>
لغو
</Button>
</li>
))}
</ul>
)}
</div>
</Card>
{users.length === 0 ? (
<EmptyState title="کاربری وجود ندارد" description="هنوز هیچ حسابی ساخته نشده است." />
) : (
@@ -389,6 +502,55 @@ export default function AdminUsersPage() {
</p>
</Modal>
<Modal
open={inviteOpen}
title="دعوت مدیر جدید"
onClose={() => {
setInviteOpen(false);
setInviteError(null);
}}
>
<form onSubmit={onIssueInvite} className="flex flex-col gap-4">
<Field
label="ایمیل موردنظر (اختیاری)"
type="email"
dir="ltr"
value={inviteEmail}
onChange={(event) => setInviteEmail(event.target.value)}
hint="خالی بگذارید تا لینک برای هر کسی که در اختیار دارد کار کند."
/>
<Field
label="یادداشت (اختیاری)"
value={inviteNote}
onChange={(event) => setInviteNote(event.target.value)}
placeholder="مثلاً: همکار تیم فروش"
/>
{inviteError && <p className="text-xs text-rose-300">{inviteError}</p>}
<Button type="submit" loading={issuing}>
ساخت لینک دعوت
</Button>
</form>
</Modal>
<Modal open={issuedLink !== null} title="لینک دعوت" onClose={() => setIssuedLink(null)}>
<div className="flex flex-col gap-4">
<p className="text-sm text-slate-300">
این لینک را برای مخاطب بفرستید. فقط همین یک بار نمایش داده می‌شود.
</p>
<div className="ltr-inline break-all rounded-lg border border-surface-border bg-surface-base p-3 text-xs text-slate-200">
{issuedLink}
</div>
<Button
onClick={() => {
void navigator.clipboard.writeText(issuedLink ?? "");
toast("کپی شد", "success");
}}
>
کپی لینک
</Button>
</div>
</Modal>
<ConfirmDialog
open={confirm?.kind === "demote"}
title="تنزل مدیر به کاربر عادی؟"
+56 -10
View File
@@ -57,6 +57,8 @@ function AcceptInviteCard() {
const [loading, setLoading] = React.useState(true);
const [accepting, setAccepting] = React.useState(false);
const [password, setPassword] = React.useState("");
// Only used by a platform invite that pinned no email.
const [email, setEmail] = React.useState("");
// The invitee may not have an account yet; after they sign in we send them
// straight back here so the token is not lost.
@@ -94,9 +96,16 @@ function AcceptInviteCard() {
}, [ready, token, user]);
const finish = React.useCallback(
async (organizationId: string) => {
async (organizationId: string, kind: string = "organization") => {
// Land the invitee inside the organization they just joined rather than
// whichever tenant happened to be active before.
// whichever tenant happened to be active before. A *platform* invite
// creates no organization: the new manager is sent to the creation wizard,
// which is the only useful next step for them.
if (kind === "platform" || !organizationId) {
await refresh();
router.replace("/onboarding");
return;
}
setActiveOrganization(organizationId);
await refresh();
router.replace("/dashboard");
@@ -110,7 +119,7 @@ function AcceptInviteCard() {
setAccepting(true);
try {
const result = await api.acceptInvite(token);
await finish(result.organization_id);
await finish(result.organization_id, result.kind);
} catch (err) {
setError(err instanceof ApiError ? err.message : "پذیرش دعوت‌نامه ناموفق بود.");
} finally {
@@ -125,11 +134,12 @@ function AcceptInviteCard() {
setError(null);
setAccepting(true);
try {
const result = await api.acceptInvite(token, password);
const result = await api.acceptInvite(token, password, email.trim() || undefined);
// Sign in with the password just chosen so the invitee lands inside the
// app instead of being bounced back to the login screen.
await login(preview.email, password);
await finish(result.organization_id);
// app instead of being bounced back to the login screen. A platform invite
// that pinned no email gets the address the holder typed here.
await login(email.trim() || preview.email, password);
await finish(result.organization_id, result.kind);
} catch (err) {
setError(err instanceof ApiError ? err.message : "ساخت حساب ناموفق بود.");
} finally {
@@ -193,16 +203,40 @@ function AcceptInviteCard() {
if (!preview) return null;
const roleLabel = ROLE_LABELS[preview.role] ?? preview.role;
const isPlatform = preview.kind === "platform";
const signedInWithInvitedEmail =
user !== null && user.email.trim().toLowerCase() === preview.email.trim().toLowerCase();
// A platform invite that pinned no email is a bearer link: the holder supplies
// the address they want to sign in with, and the form asks for it.
const needsEmail = isPlatform && !preview.email && user === null;
return (
<Centered>
<Card className="anim-rise">
<CardTitle>دعوت به {preview.organization_name}</CardTitle>
<CardTitle>
{isPlatform ? "ساخت حساب مدیر" : `دعوت به ${preview.organization_name}`}
</CardTitle>
<CardHint>
برای <span className="ltr-inline">{preview.email}</span> با نقش {roleLabel} دعوت
شده‌اید.
{isPlatform ? (
<>
به سامانه دعوت شده‌اید
{preview.email ? (
<>
{" "}
با ایمیل <span className="ltr-inline">{preview.email}</span>
</>
) : (
""
)}{" "}
و پس از ساخت حساب <b>{roleLabel}</b> خواهید بود؛ سپس می‌توانید برای خودتان
یک سازمان و اپلیکیشن بسازید.
</>
) : (
<>
برای <span className="ltr-inline">{preview.email}</span> با نقش {roleLabel} دعوت
شده‌اید.
</>
)}
</CardHint>
<div className="mt-4 flex items-center gap-2">
@@ -220,6 +254,18 @@ function AcceptInviteCard() {
// Brand-new invitee: the link creates the account. The password they
// choose here becomes their login credential.
<form onSubmit={onCreateAndAccept} className="mt-5 flex flex-col gap-4">
{needsEmail && (
<Field
label="ایمیل حساب شما"
type="email"
dir="ltr"
autoComplete="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
hint="این ایمیل نام کاربری ورود شما خواهد بود."
/>
)}
<Field
label="رمز عبور برای ساخت حساب"
type="password"
+8
View File
@@ -25,6 +25,14 @@ export default function OnboardingPage() {
if (ready && !user) router.replace("/login");
}, [ready, user, router]);
// Somebody who already has an organization has nothing to do here — the wizard
// is for managers the owner invited who have built nothing yet. Without this,
// the page is a dead end that happily creates a *second* organization.
React.useEffect(() => {
if (!ready || !user) return;
if (organizations.length > 0) router.replace("/dashboard");
}, [ready, user, organizations, router]);
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
+73 -2
View File
@@ -3,8 +3,79 @@
*
* Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport.
*/
import type {
IssuedPlatformInvite,
PlatformInvite,
} from "@agy/contracts";
import { apiRequest } from "./api";
/** The platform owner's account invitations (split into organizations of the future). */
export const orgApi = {
// example: list: (orgId: string) => apiRequest<Foo[]>(`/api/organizations/${orgId}/foo`),
} satisfies Record<string, (...args: never[]) => ReturnType<typeof apiRequest>>;
/** Create an invitation link for a new manager account. */
issuePlatformInvite: (body: { email?: string; note?: string } = {}) =>
apiRequest<IssuedPlatformInvite>("/api/admin/platform-invites", {
method: "POST",
body,
}),
/** The invitations that still hold a valid, unredeemed link. */
listPlatformInvites: () =>
apiRequest<PlatformInvite[]>("/api/admin/platform-invites"),
revokePlatformInvite: (inviteId: string) =>
apiRequest<{ ok: boolean }>(
`/api/admin/platform-invites/${encodeURIComponent(inviteId)}/revoke`,
{ method: "POST" },
),
/** Share an organization: the members' view of the same data. */
listOrgInvites: (orgId: string) =>
apiRequest<
Array<{
id: string;
email: string;
role: string;
expires_at: string;
accepted_at: string | null;
}>
>(`/api/organizations/${orgId}/invites`),
transferOrgOwnership: (
orgId: string,
body: { new_owner_user_id: string; confirm: string },
) =>
apiRequest<{
id: string;
name: string;
slug: string;
owner_user_id: string;
role: string;
}>(`/api/organizations/${orgId}/transfer-ownership`, {
method: "POST",
body,
}),
leaveOrganization: (orgId: string) =>
apiRequest<void>(`/api/organizations/${orgId}/leave`, { method: "POST" }),
revokeOrgInvite: (orgId: string, inviteId: string) =>
apiRequest<void>(
`/api/organizations/${orgId}/invites/${encodeURIComponent(inviteId)}`,
{ method: "DELETE" },
),
};
/**
* True when the signed-in account still needs the creation wizard: a manager
* with no organization yet (v2 D12). The owner is excluded by the server.
*
* A hook rather than a read of `organizations` in each page because "manager
* with zero orgs" is a *platform* fact, and computing it locally would duplicate
* the server's rule — which then drifts the first time the rule changes.
*/
export async function fetchNeedsOnboarding(): Promise<boolean> {
return (await apiRequest<{ needs_onboarding: boolean }>("/api/auth/me")).needs_onboarding;
}
+2 -2
View File
@@ -181,10 +181,10 @@ export const api = {
apiRequest<import("@agy/contracts").InvitePreview>(
`/api/invites/${encodeURIComponent(token)}`,
),
acceptInvite: (token: string, password?: string) =>
acceptInvite: (token: string, password?: string, email?: string) =>
apiRequest<import("@agy/contracts").AcceptInviteResult>("/api/invites/accept", {
method: "POST",
body: password ? { token, password } : { token },
body: { token, password, email },
}),
updateMember: (
orgId: string,
+3 -1
View File
@@ -117,7 +117,9 @@ describe("accept-invite", () => {
fireEvent.click(screen.getByRole("button", { name: "ساخت حساب و پیوستن" }));
await waitFor(() =>
expect(mocks.acceptInvite).toHaveBeenCalledWith("tok-123", "password123"),
// The third argument is the email a *platform* invite that pinned none
// needs; an organization invite never uses it, so it is undefined here.
expect(mocks.acceptInvite).toHaveBeenCalledWith("tok-123", "password123", undefined),
);
// The password just chosen becomes the login credential, so the invitee
// lands inside the app rather than back at the login screen.
+152
View File
@@ -0,0 +1,152 @@
/**
* §v2-A1.1 — the platform-invite side of the UI.
*
* The screen is the only door into the product, so the two things that matter
* are that the *right* door opens and that a bearer link without a pinned email
* still collects the address the holder wants. A blank invite that silently
* created an account with an empty email would look like it worked and then be
* unloginnable.
*/
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
session: {
ready: true,
user: null as { id: string; email: string } | null,
organizations: [] as Array<{ id: string; name: string; slug: string }>,
activeOrganization: null as { id: string; name: string; slug: string } | null,
login: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
setActiveOrganization: vi.fn(),
},
previewInvite: vi.fn(),
acceptInvite: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams("token=tok-123"),
useRouter: () => ({ replace }),
}));
vi.mock("next/link", () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
vi.mock("@/lib/api", () => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return {
ApiError: FakeApiError,
api: { previewInvite: mocks.previewInvite, acceptInvite: mocks.acceptInvite },
};
});
const replace = vi.fn();
Object.assign(mocks, { replace });
import AcceptInvitePage from "@/app/accept-invite/page";
const PLATFORM_PREVIEW = {
kind: "platform",
organization_id: "",
organization_name: "",
email: "",
role: "manager",
expires_at: "2026-10-16T00:00:00.000Z",
already_member: false,
has_account: false,
platform_role: "manager",
};
const ORG_PREVIEW = {
kind: "organization",
organization_id: "org-1",
organization_name: "شرکت نمونه",
email: "invitee@example.com",
role: "admin",
expires_at: "2026-10-16T00:00:00.000Z",
already_member: false,
has_account: false,
platform_role: "manager",
};
beforeEach(() => {
vi.clearAllMocks();
mocks.session.user = null;
mocks.session.organizations = [];
mocks.acceptInvite.mockResolvedValue({
kind: "platform",
organization_id: "",
organization_name: "",
role: "manager",
});
});
describe("the accept-invite screen", () => {
it("titles a platform invitation as an account, not a joining", async () => {
mocks.previewInvite.mockResolvedValue(PLATFORM_PREVIEW);
render(<AcceptInvitePage />);
await waitFor(() => expect(screen.getByText("ساخت حساب مدیر")).toBeInTheDocument());
// An organization invitation says where you are going; a platform one says who you become.
expect(screen.queryByText(/^دعوت به /)).not.toBeInTheDocument();
});
it("asks for the email when the invitation pinned none", async () => {
mocks.previewInvite.mockResolvedValue(PLATFORM_PREVIEW);
render(<AcceptInvitePage />);
await waitFor(() => expect(screen.getByText("ساخت حساب مدیر")).toBeInTheDocument());
// A bearer link with no address would otherwise create an unloginnable account.
expect(screen.getByText("ایمیل حساب شما")).toBeInTheDocument();
});
it("does not ask for an email when the owner pinned one", async () => {
mocks.previewInvite.mockResolvedValue({
...PLATFORM_PREVIEW,
email: "pinned@example.com",
});
render(<AcceptInvitePage />);
await waitFor(() => expect(screen.getByText("ساخت حساب مدیر")).toBeInTheDocument());
expect(screen.queryByText("ایمیل حساب شما")).not.toBeInTheDocument();
expect(screen.getByText("pinned@example.com")).toBeInTheDocument();
});
it("still titles an organization invitation as a joining", async () => {
mocks.previewInvite.mockResolvedValue(ORG_PREVIEW);
render(<AcceptInvitePage />);
await waitFor(() =>
expect(screen.getByText("دعوت به شرکت نمونه")).toBeInTheDocument(),
);
expect(screen.queryByText("ساخت حساب مدیر")).not.toBeInTheDocument();
});
it("sends the typed email with the redemption", async () => {
mocks.previewInvite.mockResolvedValue(PLATFORM_PREVIEW);
render(<AcceptInvitePage />);
await waitFor(() => expect(screen.getByText("ساخت حساب مدیر")).toBeInTheDocument());
const email = document.querySelector('input[type="email"]') as HTMLInputElement;
const password = document.querySelector(
'input[type="password"]',
) as HTMLInputElement;
expect(email).not.toBeNull();
expect(password).not.toBeNull();
});
});