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:
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 (
|
from app.domain.identity.models import (
|
||||||
Membership,
|
Membership,
|
||||||
MembershipStatus,
|
MembershipStatus,
|
||||||
|
PlatformInvite,
|
||||||
|
PlatformRole,
|
||||||
Role,
|
Role,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.infrastructure.repositories.identity import (
|
from app.infrastructure.repositories.identity import (
|
||||||
MembershipRepository,
|
MembershipRepository,
|
||||||
OrganizationRepository,
|
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)
|
@dataclass(slots=True)
|
||||||
class OrganizationTransfer:
|
class OrganizationTransfer:
|
||||||
@@ -84,3 +103,112 @@ async def demote_manager_accounts_organizations(
|
|||||||
return await transfer_owned_organizations(
|
return await transfer_owned_organizations(
|
||||||
session, from_user_id=manager.id, to_user=owner
|
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_JOIN = "member.join"
|
||||||
MEMBER_ROLE_CHANGE = "member.role_change"
|
MEMBER_ROLE_CHANGE = "member.role_change"
|
||||||
MEMBER_REMOVE = "member.remove"
|
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).
|
#: The platform owner created an account (manager or plain user).
|
||||||
USER_CREATE = "user.create"
|
USER_CREATE = "user.create"
|
||||||
#: A user's platform role changed (user <-> manager, or manager -> user).
|
#: A user's platform role changed (user <-> manager, or manager -> user).
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
|
||||||
from app.domain.base import new_id, utcnow
|
from app.domain.base import new_id, utcnow
|
||||||
@@ -187,3 +187,50 @@ class Invite:
|
|||||||
|
|
||||||
def is_pending(self, now: datetime | None = None) -> bool:
|
def is_pending(self, now: datetime | None = None) -> bool:
|
||||||
return self.accepted_at is None and not self.is_expired(now)
|
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).
|
``app.infrastructure.db.models`` here: that module imports this one (import cycle).
|
||||||
Foreign keys to existing tables are written as plain strings, e.g.
|
Foreign keys to existing tables are written as plain strings, e.g.
|
||||||
``ForeignKey("organizations.id", ondelete="CASCADE")``. See docs/plan/PLAN-v2.md §6.
|
``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 __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,
|
Membership,
|
||||||
MembershipStatus,
|
MembershipStatus,
|
||||||
Organization,
|
Organization,
|
||||||
|
PlatformInvite,
|
||||||
PlatformRole,
|
PlatformRole,
|
||||||
Role,
|
Role,
|
||||||
User,
|
User,
|
||||||
@@ -26,6 +27,7 @@ from app.infrastructure.db.models import (
|
|||||||
OrganizationRow,
|
OrganizationRow,
|
||||||
UserRow,
|
UserRow,
|
||||||
)
|
)
|
||||||
|
from app.infrastructure.db.models_org import PlatformInviteRow
|
||||||
|
|
||||||
|
|
||||||
def _aware(value: datetime) -> datetime:
|
def _aware(value: datetime) -> datetime:
|
||||||
@@ -301,6 +303,38 @@ class InviteRepository:
|
|||||||
def __init__(self, session: AsyncSession) -> None:
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
self.session = session
|
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:
|
async def get_by_token_hash(self, token_hash: str) -> Invite | None:
|
||||||
stmt = select(InviteRow).where(InviteRow.token_hash == token_hash)
|
stmt = select(InviteRow).where(InviteRow.token_hash == token_hash)
|
||||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
@@ -335,3 +369,106 @@ class InviteRepository:
|
|||||||
if row:
|
if row:
|
||||||
row.accepted_at = when
|
row.accepted_at = when
|
||||||
await self.session.flush()
|
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.audit_service import record_audit
|
||||||
from app.application.services.platform_users import (
|
from app.application.services.platform_users import (
|
||||||
|
PlatformInviteService,
|
||||||
transfer_owned_organizations,
|
transfer_owned_organizations,
|
||||||
)
|
)
|
||||||
from app.core.database import get_session
|
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.domain.identity.models import PlatformRole, User
|
||||||
from app.infrastructure.repositories.identity import (
|
from app.infrastructure.repositories.identity import (
|
||||||
OrganizationRepository,
|
OrganizationRepository,
|
||||||
|
PlatformInviteRepository,
|
||||||
UserRepository,
|
UserRepository,
|
||||||
)
|
)
|
||||||
from app.interfaces.http.deps import client_meta, require_platform_owner
|
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)
|
updated = await users.get(successor.id)
|
||||||
assert updated is not None
|
assert updated is not None
|
||||||
return await _to_out(session, updated)
|
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,
|
verify_password,
|
||||||
)
|
)
|
||||||
from app.domain.audit.models import AuditAction
|
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 (
|
from app.infrastructure.repositories.identity import (
|
||||||
MembershipRepository,
|
MembershipRepository,
|
||||||
OrganizationRepository,
|
OrganizationRepository,
|
||||||
@@ -73,6 +73,11 @@ class TokenResponse(BaseModel):
|
|||||||
class MeResponse(BaseModel):
|
class MeResponse(BaseModel):
|
||||||
user: UserOut
|
user: UserOut
|
||||||
organizations: list[OrganizationSummary]
|
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:
|
def _to_user_out(user: User) -> UserOut:
|
||||||
@@ -170,12 +175,16 @@ async def me(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> MeResponse:
|
) -> MeResponse:
|
||||||
orgs = await OrganizationRepository(session).list_for_user(user.id)
|
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(
|
return MeResponse(
|
||||||
user=_to_user_out(user),
|
user=_to_user_out(user),
|
||||||
organizations=[
|
organizations=[
|
||||||
OrganizationSummary(id=org.id, name=org.name, slug=org.slug, role=role.value)
|
OrganizationSummary(id=org.id, name=org.name, slug=org.slug, role=role.value)
|
||||||
for org, role in orgs
|
for org, role in orgs
|
||||||
],
|
],
|
||||||
|
needs_onboarding=needs_onboarding,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ from __future__ import annotations
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request, status
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.application.services.audit_service import record_audit
|
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.database import get_session
|
||||||
from app.core.errors import (
|
from app.core.errors import (
|
||||||
ConflictError,
|
ConflictError,
|
||||||
@@ -36,6 +37,7 @@ from app.domain.identity.models import (
|
|||||||
Membership,
|
Membership,
|
||||||
MembershipStatus,
|
MembershipStatus,
|
||||||
Organization,
|
Organization,
|
||||||
|
PlatformInvite,
|
||||||
PlatformRole,
|
PlatformRole,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
@@ -43,30 +45,46 @@ from app.infrastructure.repositories.identity import (
|
|||||||
InviteRepository,
|
InviteRepository,
|
||||||
MembershipRepository,
|
MembershipRepository,
|
||||||
OrganizationRepository,
|
OrganizationRepository,
|
||||||
|
PlatformInviteRepository,
|
||||||
UserRepository,
|
UserRepository,
|
||||||
)
|
)
|
||||||
from app.interfaces.http.deps import client_meta, get_optional_user
|
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"])
|
router = APIRouter(prefix="/invites", tags=["invites"])
|
||||||
|
|
||||||
|
|
||||||
class InvitePreview(BaseModel):
|
class InvitePreview(BaseModel):
|
||||||
organization_id: str
|
#: ``platform`` invites mint a new manager account (D12); ``organization``
|
||||||
organization_name: str
|
#: invites join an existing org. The UI shows a different title for each —
|
||||||
email: str
|
#: "ساخت حساب مدیر" versus joining a team — so the caller has to be able to
|
||||||
role: str
|
#: tell them apart.
|
||||||
expires_at: datetime
|
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"
|
#: True when the caller is already a member, so the UI can say "open it"
|
||||||
#: instead of "join".
|
#: instead of "join".
|
||||||
already_member: bool
|
already_member: bool = False
|
||||||
#: True when an account exists for the invited email. The invitee then logs
|
#: 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
|
#: in first; anonymous redemption with a password is only for brand-new
|
||||||
#: accounts (a token must never be able to reset an existing password).
|
#: 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):
|
class AcceptInviteRequest(BaseModel):
|
||||||
token: str
|
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
|
#: Required only when redeeming anonymously — it becomes the password of
|
||||||
#: the account the invite creates. Ignored for a signed-in user, who has
|
#: the account the invite creates. Ignored for a signed-in user, who has
|
||||||
#: already proven ownership of the email.
|
#: already proven ownership of the email.
|
||||||
@@ -74,9 +92,43 @@ class AcceptInviteRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AcceptInviteResult(BaseModel):
|
class AcceptInviteResult(BaseModel):
|
||||||
organization_id: str
|
#: Same two kinds as the preview, so the caller knows which door was opened.
|
||||||
organization_name: str
|
kind: str = "organization"
|
||||||
role: str
|
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(
|
async def _load_pending_invite(
|
||||||
@@ -123,19 +175,46 @@ async def preview_invite(
|
|||||||
see which organization invited them (and whether they already have an
|
see which organization invited them (and whether they already have an
|
||||||
account) *before* deciding to sign in or create one.
|
account) *before* deciding to sign in or create one.
|
||||||
"""
|
"""
|
||||||
invite, organization = await _load_pending_invite(token, user, session)
|
kind, platform, org_invite = await _load_any_invite(token, user, session)
|
||||||
existing_user = await UserRepository(session).get_by_email(invite.email)
|
|
||||||
|
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 = (
|
membership = (
|
||||||
await MembershipRepository(session).get(organization.id, user.id)
|
await MembershipRepository(session).get(organization.id, user.id)
|
||||||
if user is not None
|
if user is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
return InvitePreview(
|
return InvitePreview(
|
||||||
|
kind="organization",
|
||||||
organization_id=organization.id,
|
organization_id=organization.id,
|
||||||
organization_name=organization.name,
|
organization_name=organization.name,
|
||||||
email=invite.email,
|
email=org_invite.email,
|
||||||
role=invite.role.value,
|
role=org_invite.role.value,
|
||||||
expires_at=invite.expires_at,
|
expires_at=org_invite.expires_at,
|
||||||
already_member=membership is not None,
|
already_member=membership is not None,
|
||||||
has_account=existing_user 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,
|
Idempotent by construction — accepting twice is a no-op the second time,
|
||||||
so a double-click or a retried request cannot create two memberships.
|
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)
|
invite, organization = await _load_pending_invite(payload.token, user, session)
|
||||||
users = UserRepository(session)
|
users = UserRepository(session)
|
||||||
memberships = MembershipRepository(session)
|
memberships = MembershipRepository(session)
|
||||||
@@ -225,7 +311,81 @@ async def accept_invite(
|
|||||||
# already existed, this invite must not be redeemable a second time.
|
# already existed, this invite must not be redeemable a second time.
|
||||||
await InviteRepository(session).mark_accepted(invite.id, datetime.now(UTC))
|
await InviteRepository(session).mark_accepted(invite.id, datetime.now(UTC))
|
||||||
return AcceptInviteResult(
|
return AcceptInviteResult(
|
||||||
|
kind="organization",
|
||||||
organization_id=organization.id,
|
organization_id=organization.id,
|
||||||
organization_name=organization.name,
|
organization_name=organization.name,
|
||||||
role=invite.role.value,
|
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.application.services.audit_service import record_audit
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import get_session
|
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.core.security import generate_token, hash_token
|
||||||
from app.domain.audit.models import AuditAction
|
from app.domain.audit.models import AuditAction
|
||||||
from app.domain.base import ensure_unique_slug, slugify
|
from app.domain.base import ensure_unique_slug, slugify
|
||||||
@@ -370,3 +375,194 @@ async def list_audit(
|
|||||||
)
|
)
|
||||||
for e in events
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as React from "react";
|
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 { PLATFORM_ROLE_LABELS } from "@agy/contracts";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
type ToastMessage,
|
type ToastMessage,
|
||||||
} from "@agy/ui";
|
} from "@agy/ui";
|
||||||
import { ApiError, api } from "@/lib/api";
|
import { ApiError, api } from "@/lib/api";
|
||||||
|
import { orgApi } from "@/lib/api-org";
|
||||||
|
|
||||||
const ROLE_TONES: Record<PlatformRole, "info" | "success" | "neutral"> = {
|
const ROLE_TONES: Record<PlatformRole, "info" | "success" | "neutral"> = {
|
||||||
owner: "info",
|
owner: "info",
|
||||||
@@ -67,6 +68,71 @@ export default function AdminUsersPage() {
|
|||||||
| null
|
| null
|
||||||
>(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) => {
|
const toast = React.useCallback((title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
|
||||||
setToasts((current) => [
|
setToasts((current) => [
|
||||||
...current,
|
...current,
|
||||||
@@ -191,7 +257,12 @@ export default function AdminUsersPage() {
|
|||||||
خودشان را میسازند؛ کاربران عادی با دعوت به سازمانها میپیوندند.
|
خودشان را میسازند؛ کاربران عادی با دعوت به سازمانها میپیوندند.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => setInviteOpen(true)}>
|
||||||
|
دعوت مدیر جدید
|
||||||
|
</Button>
|
||||||
<Button onClick={() => setCreateOpen(true)}>ساخت کاربر</Button>
|
<Button onClick={() => setCreateOpen(true)}>ساخت کاربر</Button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
@@ -200,6 +271,48 @@ export default function AdminUsersPage() {
|
|||||||
<Stat label="کاربران عادی" value={plainUsers.length} />
|
<Stat label="کاربران عادی" value={plainUsers.length} />
|
||||||
</div>
|
</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 ? (
|
{users.length === 0 ? (
|
||||||
<EmptyState title="کاربری وجود ندارد" description="هنوز هیچ حسابی ساخته نشده است." />
|
<EmptyState title="کاربری وجود ندارد" description="هنوز هیچ حسابی ساخته نشده است." />
|
||||||
) : (
|
) : (
|
||||||
@@ -389,6 +502,55 @@ export default function AdminUsersPage() {
|
|||||||
</p>
|
</p>
|
||||||
</Modal>
|
</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
|
<ConfirmDialog
|
||||||
open={confirm?.kind === "demote"}
|
open={confirm?.kind === "demote"}
|
||||||
title="تنزل مدیر به کاربر عادی؟"
|
title="تنزل مدیر به کاربر عادی؟"
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ function AcceptInviteCard() {
|
|||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [accepting, setAccepting] = React.useState(false);
|
const [accepting, setAccepting] = React.useState(false);
|
||||||
const [password, setPassword] = React.useState("");
|
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
|
// The invitee may not have an account yet; after they sign in we send them
|
||||||
// straight back here so the token is not lost.
|
// straight back here so the token is not lost.
|
||||||
@@ -94,9 +96,16 @@ function AcceptInviteCard() {
|
|||||||
}, [ready, token, user]);
|
}, [ready, token, user]);
|
||||||
|
|
||||||
const finish = React.useCallback(
|
const finish = React.useCallback(
|
||||||
async (organizationId: string) => {
|
async (organizationId: string, kind: string = "organization") => {
|
||||||
// Land the invitee inside the organization they just joined rather than
|
// 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);
|
setActiveOrganization(organizationId);
|
||||||
await refresh();
|
await refresh();
|
||||||
router.replace("/dashboard");
|
router.replace("/dashboard");
|
||||||
@@ -110,7 +119,7 @@ function AcceptInviteCard() {
|
|||||||
setAccepting(true);
|
setAccepting(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.acceptInvite(token);
|
const result = await api.acceptInvite(token);
|
||||||
await finish(result.organization_id);
|
await finish(result.organization_id, result.kind);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : "پذیرش دعوتنامه ناموفق بود.");
|
setError(err instanceof ApiError ? err.message : "پذیرش دعوتنامه ناموفق بود.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -125,11 +134,12 @@ function AcceptInviteCard() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setAccepting(true);
|
setAccepting(true);
|
||||||
try {
|
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
|
// Sign in with the password just chosen so the invitee lands inside the
|
||||||
// app instead of being bounced back to the login screen.
|
// app instead of being bounced back to the login screen. A platform invite
|
||||||
await login(preview.email, password);
|
// that pinned no email gets the address the holder typed here.
|
||||||
await finish(result.organization_id);
|
await login(email.trim() || preview.email, password);
|
||||||
|
await finish(result.organization_id, result.kind);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : "ساخت حساب ناموفق بود.");
|
setError(err instanceof ApiError ? err.message : "ساخت حساب ناموفق بود.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -193,16 +203,40 @@ function AcceptInviteCard() {
|
|||||||
if (!preview) return null;
|
if (!preview) return null;
|
||||||
|
|
||||||
const roleLabel = ROLE_LABELS[preview.role] ?? preview.role;
|
const roleLabel = ROLE_LABELS[preview.role] ?? preview.role;
|
||||||
|
const isPlatform = preview.kind === "platform";
|
||||||
const signedInWithInvitedEmail =
|
const signedInWithInvitedEmail =
|
||||||
user !== null && user.email.trim().toLowerCase() === preview.email.trim().toLowerCase();
|
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 (
|
return (
|
||||||
<Centered>
|
<Centered>
|
||||||
<Card className="anim-rise">
|
<Card className="anim-rise">
|
||||||
<CardTitle>دعوت به {preview.organization_name}</CardTitle>
|
<CardTitle>
|
||||||
|
{isPlatform ? "ساخت حساب مدیر" : `دعوت به ${preview.organization_name}`}
|
||||||
|
</CardTitle>
|
||||||
<CardHint>
|
<CardHint>
|
||||||
|
{isPlatform ? (
|
||||||
|
<>
|
||||||
|
به سامانه دعوت شدهاید
|
||||||
|
{preview.email ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
با ایمیل <span className="ltr-inline">{preview.email}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}{" "}
|
||||||
|
و پس از ساخت حساب <b>{roleLabel}</b> خواهید بود؛ سپس میتوانید برای خودتان
|
||||||
|
یک سازمان و اپلیکیشن بسازید.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
برای <span className="ltr-inline">{preview.email}</span> با نقش {roleLabel} دعوت
|
برای <span className="ltr-inline">{preview.email}</span> با نقش {roleLabel} دعوت
|
||||||
شدهاید.
|
شدهاید.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardHint>
|
</CardHint>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center gap-2">
|
<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
|
// Brand-new invitee: the link creates the account. The password they
|
||||||
// choose here becomes their login credential.
|
// choose here becomes their login credential.
|
||||||
<form onSubmit={onCreateAndAccept} className="mt-5 flex flex-col gap-4">
|
<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
|
<Field
|
||||||
label="رمز عبور برای ساخت حساب"
|
label="رمز عبور برای ساخت حساب"
|
||||||
type="password"
|
type="password"
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ export default function OnboardingPage() {
|
|||||||
if (ready && !user) router.replace("/login");
|
if (ready && !user) router.replace("/login");
|
||||||
}, [ready, user, router]);
|
}, [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) => {
|
const onSubmit = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|||||||
@@ -3,8 +3,79 @@
|
|||||||
*
|
*
|
||||||
* Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport.
|
* 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";
|
import { apiRequest } from "./api";
|
||||||
|
|
||||||
|
|
||||||
|
/** The platform owner's account invitations (split into organizations of the future). */
|
||||||
export const orgApi = {
|
export const orgApi = {
|
||||||
// example: list: (orgId: string) => apiRequest<Foo[]>(`/api/organizations/${orgId}/foo`),
|
/** Create an invitation link for a new manager account. */
|
||||||
} satisfies Record<string, (...args: never[]) => ReturnType<typeof apiRequest>>;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -181,10 +181,10 @@ export const api = {
|
|||||||
apiRequest<import("@agy/contracts").InvitePreview>(
|
apiRequest<import("@agy/contracts").InvitePreview>(
|
||||||
`/api/invites/${encodeURIComponent(token)}`,
|
`/api/invites/${encodeURIComponent(token)}`,
|
||||||
),
|
),
|
||||||
acceptInvite: (token: string, password?: string) =>
|
acceptInvite: (token: string, password?: string, email?: string) =>
|
||||||
apiRequest<import("@agy/contracts").AcceptInviteResult>("/api/invites/accept", {
|
apiRequest<import("@agy/contracts").AcceptInviteResult>("/api/invites/accept", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: password ? { token, password } : { token },
|
body: { token, password, email },
|
||||||
}),
|
}),
|
||||||
updateMember: (
|
updateMember: (
|
||||||
orgId: string,
|
orgId: string,
|
||||||
|
|||||||
@@ -117,7 +117,9 @@ describe("accept-invite", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "ساخت حساب و پیوستن" }));
|
fireEvent.click(screen.getByRole("button", { name: "ساخت حساب و پیوستن" }));
|
||||||
|
|
||||||
await waitFor(() =>
|
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
|
// The password just chosen becomes the login credential, so the invitee
|
||||||
// lands inside the app rather than back at the login screen.
|
// lands inside the app rather than back at the login screen.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Agent 1 — Phase 1 report: platform invites, roles and org sharing
|
||||||
|
|
||||||
|
Date: 2026-10-14 · Branch: `dev`
|
||||||
|
|
||||||
|
## What was done
|
||||||
|
|
||||||
|
### 1.1 `platform_invites` (`db/models_org.py`, migration `a1b2c3d4e5f1`)
|
||||||
|
|
||||||
|
The door into the product (D12). Unlike an org invite — which joins someone to
|
||||||
|
an org that already exists — this one **mints the platform account**, so it is a
|
||||||
|
capability: the token is stored only as a hash, it is single-use, it expires after
|
||||||
|
`PLATFORM_INVITE_TTL_HOURS`, and the owner can revoke it. The email is optional;
|
||||||
|
when pinned, the account it creates is bound to it.
|
||||||
|
|
||||||
|
### 1.2 `PlatformInviteService`
|
||||||
|
|
||||||
|
`issue / list_pending / revoke / redeem`. Two rules carry over from the org-invite
|
||||||
|
flow unchanged, deliberately:
|
||||||
|
|
||||||
|
* **A link can never reset the password of an existing account.** A redeemed link
|
||||||
|
whose email already belongs to somebody is refused; they sign in first.
|
||||||
|
* **Every rejection gives the same answer.** "No such link", "already used" and
|
||||||
|
"revoked" are indistinguishable to a caller, so a probe cannot enumerate.
|
||||||
|
|
||||||
|
`revoke` refuses to un-redeem, and `redeem` is atomic (`mark_used` rejects a
|
||||||
|
second caller rather than silently overwriting), so two workers redeeming the same
|
||||||
|
link cannot both win.
|
||||||
|
|
||||||
|
### 1.2 Endpoints (`admin_users.py`, owner-only via the router's dependency)
|
||||||
|
|
||||||
|
`POST /admin/platform-invites` (returns `invite_url` **once**), `GET
|
||||||
|
/admin/platform-invites`, `POST /admin/platform-invites/{id}/revoke`. All audited.
|
||||||
|
`owner` is refused as a platform role — the seat moves only via a deliberate
|
||||||
|
transfer.
|
||||||
|
|
||||||
|
### 1.3 `invites.py` — one lookup path, two kinds
|
||||||
|
|
||||||
|
`_load_any_invite` resolves a token to whichever kind it is, and the preview and
|
||||||
|
accept routes both branch on it. A platform invite carries `kind: "platform"` and
|
||||||
|
no organization. Redemption either mints a manager (anonymous, with the password —
|
||||||
|
and the email the holder types when the owner pinned none) or raises an existing
|
||||||
|
account to the invited role.
|
||||||
|
|
||||||
|
### 1.4 `GET /auth/me` gains `needs_onboarding`
|
||||||
|
|
||||||
|
True for a manager with zero organizations and not the owner. The client reads it
|
||||||
|
through `fetchNeedsOnboarding()` rather than recomputing the rule locally, so it
|
||||||
|
cannot drift from the server's.
|
||||||
|
|
||||||
|
### 1.5 Org sharing (`organizations.py`)
|
||||||
|
|
||||||
|
* `POST /transfer-ownership` — the current org owner only, to an **existing admin
|
||||||
|
member**, with the org slug typed back, both role changes and the assignment in
|
||||||
|
one transaction so there is never a moment with no owner. Audited.
|
||||||
|
* `POST /leave` — the **last owner cannot leave**: an organization with no owner is
|
||||||
|
invisible to everyone, because its `owner_user_id` points at a non-member.
|
||||||
|
* `GET/DELETE /invites` — list and revoke. Revoking is expressed as *expiry* rather
|
||||||
|
than a `revoked_at` column, because adding one would mean editing the locked
|
||||||
|
`db/models.py`; a revoked link and an expired link are both simply unusable, and
|
||||||
|
it cannot un-accept somebody who already joined.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
`/accept-invite` titles a platform invite **ساخت حساب مدیر**, shows the pinned
|
||||||
|
email when there is one, and asks for an address when there is none. `/admin/users`
|
||||||
|
gains a "دعوت مدیر جدید" button → modal → the link shown once, plus a pending-links
|
||||||
|
card with revoke. `/onboarding` redirects anyone who already has an organization.
|
||||||
|
`lib/api-org.ts` is no longer a stub; contracts gained `PlatformInvite`,
|
||||||
|
`IssuedPlatformInvite`, `needs_onboarding` and the `kind` discriminator.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
Backend: `db/models_org.py`, `alembic/versions/a1b2c3d4e5f1_org_platform.py`,
|
||||||
|
`domain/identity/models.py` (`PlatformInvite`), `domain/audit/models.py`
|
||||||
|
(`MEMBER_INVITE_REVOKE`), `application/services/platform_users.py`
|
||||||
|
(`PlatformInviteService`), `infrastructure/repositories/identity.py`
|
||||||
|
(`PlatformInviteRepository`; `InviteRepository.get/revoke`),
|
||||||
|
`interfaces/http/routers/{invites,auth,organizations,admin_users}.py`.
|
||||||
|
|
||||||
|
Frontend: `packages/contracts/src/index.ts`, `apps/web/src/lib/{api.ts,api-org.ts}`,
|
||||||
|
`apps/web/src/app/accept-invite/page.tsx`, `onboarding/page.tsx`,
|
||||||
|
`(app)/admin/users/page.tsx`, `src/test/org-sharing.test.tsx`,
|
||||||
|
`src/test/accept-invite.test.tsx` (one assertion widened for the new argument).
|
||||||
|
|
||||||
|
## Test output
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/api: 520 passed, 1 skipped (was 502; +18)
|
||||||
|
ruff check . -> All checks passed!
|
||||||
|
mypy app -> Success: no issues found in 136 source files
|
||||||
|
alembic: single head; upgrade / downgrade -1 / upgrade / check all clean
|
||||||
|
apps/web: 83 passed (was 78; +5)
|
||||||
|
tsc --noEmit -> clean · next lint -> no warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deviations from the TODO
|
||||||
|
|
||||||
|
1. **Org-invite revocation is expiry, not a flag.** `db/models.py` is locked for this
|
||||||
|
workstream and `invites` has no `revoked_at`. Semantically honest and strictly
|
||||||
|
safer: it cannot un-accept a member.
|
||||||
|
2. **The migration chains after the delivery workstream's head** (`a3b4c5d6e7f4`)
|
||||||
|
rather than the foundation. The two workstreams are being built sequentially
|
||||||
|
here, so this keeps one head rather than introducing a merge revision that §8.3
|
||||||
|
only asks for when genuinely parallel branches exist.
|
||||||
|
3. **`admin_users.py` holds the platform-invite endpoints.** The TODO names that
|
||||||
|
file for them, and it already carries the router-level
|
||||||
|
`require_platform_owner` dependency — adding a second owner-gated router would
|
||||||
|
duplicate the guarantee rather than strengthen it.
|
||||||
|
|
||||||
|
## Open questions / CHANGE REQUESTs
|
||||||
|
|
||||||
|
- **`deploy_target.py` / `repo_workspace.py` field additions** (from Phase 5)
|
||||||
|
remain open: `DeployTargetSpec` would benefit from `post_deploy_command` and the
|
||||||
|
org/project ids. Nothing is broken; they are passed as explicit arguments.
|
||||||
|
- **`MeResponse` should carry the org role** so the web can gate the production
|
||||||
|
buttons client-side. Noted in the Phase 6 report as well.
|
||||||
|
|
||||||
|
## Known limitations — 1.10 NOT DONE
|
||||||
|
|
||||||
|
**`(app)/settings` has no "اشتراکگذاری سازمان" section.** Phase 1's frontend is
|
||||||
|
otherwise complete (1.7–1.9, 1.11); the settings section — members with a role
|
||||||
|
selector, invite form, pending invites with revoke, transfer-ownership modal that
|
||||||
|
requires typing the org slug, leave org — is not built. The *endpoints* it needs
|
||||||
|
(`transfer-ownership`, `leave`, `GET/DELETE /invites`) are done and tested, so the
|
||||||
|
remaining work is presentational. The same is true of the `/tasks/[id]`-style extra
|
||||||
|
test named in 1.11 — `src/test/org-sharing.test.tsx` covers the invite screen
|
||||||
|
instead, which is where the phase's risk actually is.
|
||||||
@@ -221,8 +221,16 @@ export interface Invite {
|
|||||||
invite_url: string;
|
invite_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the accept-invite screen shows before the invitee commits. */
|
/**
|
||||||
|
* What the accept-invite screen shows before the invitee commits.
|
||||||
|
*
|
||||||
|
* Two kinds share one endpoint: a `platform` invite mints a new manager account
|
||||||
|
* (v2 D12) and has no organization yet, while an `organization` invite joins an
|
||||||
|
* existing one. The screen shows a different title for each — "ساخت حساب مدیر"
|
||||||
|
* versus joining a team — so the caller has to be able to tell them apart.
|
||||||
|
*/
|
||||||
export interface InvitePreview {
|
export interface InvitePreview {
|
||||||
|
kind: "platform" | "organization";
|
||||||
organization_id: string;
|
organization_id: string;
|
||||||
organization_name: string;
|
organization_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -235,9 +243,12 @@ export interface InvitePreview {
|
|||||||
* in first; setting a password anonymously is only for brand-new accounts.
|
* in first; setting a password anonymously is only for brand-new accounts.
|
||||||
*/
|
*/
|
||||||
has_account: boolean;
|
has_account: boolean;
|
||||||
|
/** For a platform invite: the role the redeemed account will receive. */
|
||||||
|
platform_role: PlatformRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AcceptInviteResult {
|
export interface AcceptInviteResult {
|
||||||
|
kind: "platform" | "organization";
|
||||||
organization_id: string;
|
organization_id: string;
|
||||||
organization_name: string;
|
organization_name: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
@@ -252,6 +263,29 @@ export interface TokenResponse {
|
|||||||
export interface MeResponse {
|
export interface MeResponse {
|
||||||
user: User;
|
user: User;
|
||||||
organizations: OrganizationWithRole[];
|
organizations: OrganizationWithRole[];
|
||||||
|
/**
|
||||||
|
* True for a manager who belongs to no organization yet — they arrived on the
|
||||||
|
* platform owner's invitation link and have built nothing, so the only useful
|
||||||
|
* thing the UI can do is open the creation wizard. The owner is excluded.
|
||||||
|
*/
|
||||||
|
needs_onboarding: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An invitation the platform owner issued, as it appears in the console. */
|
||||||
|
export interface PlatformInvite {
|
||||||
|
id: string;
|
||||||
|
email: string | null;
|
||||||
|
platform_role: string;
|
||||||
|
note: string;
|
||||||
|
expires_at: string;
|
||||||
|
created_at: string;
|
||||||
|
created_by_email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssuedPlatformInvite {
|
||||||
|
invite: PlatformInvite;
|
||||||
|
/** The raw URL. Shown exactly once, at creation. */
|
||||||
|
invite_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
|
|||||||
Reference in New Issue
Block a user