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