From 5095a29df92415b96386017caf766c6056c4f508 Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Wed, 14 Oct 2026 09:14:09 +0330 Subject: [PATCH] feat(org): platform invites, roles, org sharing (v2 phase A1-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../versions/a1b2c3d4e5f1_org_platform.py | 62 +++ .../application/services/platform_users.py | 128 ++++++ apps/api/app/domain/audit/models.py | 2 + apps/api/app/domain/identity/models.py | 49 ++- apps/api/app/infrastructure/db/models_org.py | 69 ++++ .../infrastructure/repositories/identity.py | 137 +++++++ .../interfaces/http/routers/admin_users.py | 114 ++++++ apps/api/app/interfaces/http/routers/auth.py | 11 +- .../app/interfaces/http/routers/invites.py | 192 ++++++++- .../interfaces/http/routers/organizations.py | 198 +++++++++- apps/api/tests/test_org_platform_invites.py | 370 ++++++++++++++++++ apps/web/src/app/(app)/admin/users/page.tsx | 166 +++++++- apps/web/src/app/accept-invite/page.tsx | 66 +++- apps/web/src/app/onboarding/page.tsx | 8 + apps/web/src/lib/api-org.ts | 75 +++- apps/web/src/lib/api.ts | 4 +- apps/web/src/test/accept-invite.test.tsx | 4 +- apps/web/src/test/org-sharing.test.tsx | 152 +++++++ docs/plan/reports/agent-1-phase-1.md | 127 ++++++ packages/contracts/src/index.ts | 36 +- 20 files changed, 1933 insertions(+), 37 deletions(-) create mode 100644 apps/api/alembic/versions/a1b2c3d4e5f1_org_platform.py create mode 100644 apps/api/tests/test_org_platform_invites.py create mode 100644 apps/web/src/test/org-sharing.test.tsx create mode 100644 docs/plan/reports/agent-1-phase-1.md diff --git a/apps/api/alembic/versions/a1b2c3d4e5f1_org_platform.py b/apps/api/alembic/versions/a1b2c3d4e5f1_org_platform.py new file mode 100644 index 0000000..8b2dadf --- /dev/null +++ b/apps/api/alembic/versions/a1b2c3d4e5f1_org_platform.py @@ -0,0 +1,62 @@ +"""org platform: platform invites (the door into the product) + +Revision ID: a1b2c3d4e5f1 +Revises: a3b4c5d6e7f4 +Create Date: 2026-10-14 02:20:00.000000 + +Agent 1's own migration. Chained after the delivery workstream's head so the +history stays linear. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "a1b2c3d4e5f1" +down_revision: str | None = "a3b4c5d6e7f4" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "platform_invites", + sa.Column("id", sa.String(length=32), primary_key=True), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("email", sa.String(length=320), nullable=True), + sa.Column("platform_role", sa.String(length=16), nullable=False, server_default="manager"), + sa.Column( + "created_by_user_id", + sa.String(length=32), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "used_by_user_id", + sa.String(length=32), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "revoked_by_user_id", + sa.String(length=32), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("note", sa.Text(), nullable=False, server_default=""), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_platform_invite_token", "platform_invites", ["token_hash"], unique=True) + op.create_index("ix_platform_invite_email", "platform_invites", ["email"]) + op.create_index("ix_platform_invites_created_by_user_id", "platform_invites", ["created_by_user_id"]) + op.create_index("ix_platform_invites_used_by_user_id", "platform_invites", ["used_by_user_id"]) + + +def downgrade() -> None: + op.drop_table("platform_invites") diff --git a/apps/api/app/application/services/platform_users.py b/apps/api/app/application/services/platform_users.py index e7f706a..36bb0f5 100644 --- a/apps/api/app/application/services/platform_users.py +++ b/apps/api/app/application/services/platform_users.py @@ -20,21 +20,40 @@ and expensive to get wrong: from __future__ import annotations +import logging +import secrets from dataclasses import dataclass +from datetime import timedelta from sqlalchemy.ext.asyncio import AsyncSession +from app.core.config import settings +from app.core.security import hash_token +from app.domain.base import utcnow from app.domain.identity.models import ( Membership, MembershipStatus, + PlatformInvite, + PlatformRole, Role, User, ) from app.infrastructure.repositories.identity import ( MembershipRepository, OrganizationRepository, + PlatformInviteRepository, ) +logger = logging.getLogger("agy.org.invites") + +#: Raw token length. ``secrets.token_urlsafe(24)`` is ~144 bits — well past +#: anything a brute force against a rate-limited endpoint can reach. +TOKEN_BYTES = 24 + +#: Floor on the expiry, whatever the setting says. A link that dies in five +#: minutes is worse than useless: the invitee opens it hours later. +MIN_TTL_HOURS = 1 + @dataclass(slots=True) class OrganizationTransfer: @@ -84,3 +103,112 @@ async def demote_manager_accounts_organizations( return await transfer_owned_organizations( session, from_user_id=manager.id, to_user=owner ) + + +# --------------------------------------------------------------------------- +# Platform invites — the door into the product (D12) +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class IssuedInvite: + """The invite plus its raw token — the only moment that token exists.""" + + invite: PlatformInvite + token: str + + @property + def invite_url(self) -> str: + """The link the owner pastes into a chat message.""" + return f"{settings.app_url.rstrip('/')}/accept-invite?token={self.token}" + + +class PlatformInviteService: + """Issue, list and revoke the owner's account invitations. + + A *manager* account is the only kind that can create an organization, and + accounts are never self-service, so the owner creates a link and that link + turns a stranger into a manager. + + What makes it a capability rather than a message: the token is stored only as + a hash and shown exactly once, it is single-use, it expires after + ``PLATFORM_INVITE_TTL_HOURS``, and the owner can revoke it at any time. + + One rule from the *organization* invite flow carries over unchanged — a link + can never reset the password of an account that already exists. If the + invited email already belongs to somebody, they sign in first; otherwise a + leaked link would be an account takeover for anyone who ever received one. + """ + + def __init__(self, repo: PlatformInviteRepository) -> None: + self.repo = repo + + def _ttl(self) -> timedelta: + hours = max(int(settings.platform_invite_ttl_hours or 1), MIN_TTL_HOURS) + return timedelta(hours=hours) + + async def issue( + self, + *, + owner_user_id: str, + email: str | None = None, + platform_role: PlatformRole = PlatformRole.MANAGER, + note: str = "", + ) -> IssuedInvite: + """Create an invite and return it with its raw token, exactly once. + + ``platform_role`` is accepted so the owner can invite a plain ``user`` too, + but ``owner`` is refused for the same reason ``create_user`` refuses it: + the owner seat moves only through a deliberate ownership transfer. + """ + from app.core.errors import ValidationError + + if platform_role == PlatformRole.OWNER: + raise ValidationError( + "دعوت نقشی برای مالک سامانه قابل صدور نیست؛ انتقال مالکیت مسیر جداگانه‌ای است." + ) + + token = secrets.token_urlsafe(TOKEN_BYTES) + invite = await self.repo.create( + PlatformInvite( + token_hash=hash_token(token), + email=(email or "").strip().lower() or None, + platform_role=platform_role, + created_by_user_id=owner_user_id, + expires_at=utcnow() + self._ttl(), + note=note.strip()[:500], + ) + ) + return IssuedInvite(invite=invite, token=token) + + async def list_pending(self) -> list[PlatformInvite]: + return await self.repo.list_pending() + + async def revoke(self, *, invite_id: str, by_user_id: str) -> None: + """Revoke an unredeemed invite. + + Revoking one that was already redeemed is a no-op rather than an error: + the state is already the one the operator asked for, and a second click + on "revoke" should not fail loudly. + """ + invite = await self.repo.get(invite_id) + if invite is None or invite.is_redeemed or invite.is_revoked: + return + await self.repo.mark_revoked(invite_id=invite_id, by_user_id=by_user_id) + + async def redeem( + self, *, token: str, user_id: str + ) -> tuple[bool, PlatformInvite | None]: + """Burn the token for ``user_id``. Returns ``(ok, invite)``. + + Atomicity matters: two requests arriving at once must not both succeed, + which is why ``mark_used`` refuses when the row is already stamped. Every + rejection path gives the *same* answer, so a probe cannot tell "no such + link" from "already used" from "revoked". + """ + invite = await self.repo.get_by_token_hash(hash_token(token)) + if invite is None or invite.is_expired(): + return False, None + if not await self.repo.mark_used(invite_id=invite.id, user_id=user_id): + return False, None + return True, invite diff --git a/apps/api/app/domain/audit/models.py b/apps/api/app/domain/audit/models.py index 2adfb07..e60980f 100644 --- a/apps/api/app/domain/audit/models.py +++ b/apps/api/app/domain/audit/models.py @@ -30,6 +30,8 @@ class AuditAction: MEMBER_JOIN = "member.join" MEMBER_ROLE_CHANGE = "member.role_change" MEMBER_REMOVE = "member.remove" + #: An organization's pending invitation was revoked. + MEMBER_INVITE_REVOKE = "member.invite_revoke" #: The platform owner created an account (manager or plain user). USER_CREATE = "user.create" #: A user's platform role changed (user <-> manager, or manager -> user). diff --git a/apps/api/app/domain/identity/models.py b/apps/api/app/domain/identity/models.py index ba507f6..ca09c15 100644 --- a/apps/api/app/domain/identity/models.py +++ b/apps/api/app/domain/identity/models.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime +from datetime import UTC, datetime from enum import StrEnum from app.domain.base import new_id, utcnow @@ -187,3 +187,50 @@ class Invite: def is_pending(self, now: datetime | None = None) -> bool: return self.accepted_at is None and not self.is_expired(now) + + +@dataclass(slots=True, kw_only=True) +class PlatformInvite: + """The platform owner's invitation to create a new manager account (D12). + + Different in kind from an ``Invite``: an org invite joins someone to an + organization that already exists, while this one mints the platform account + itself. The token lives only as a hash. + """ + + token_hash: str + created_by_user_id: str + expires_at: datetime + email: str | None = None + platform_role: PlatformRole = PlatformRole.MANAGER + used_at: datetime | None = None + used_by_user_id: str | None = None + revoked_at: datetime | None = None + revoked_by_user_id: str | None = None + note: str = "" + id: str = field(default_factory=new_id) + created_at: datetime = field(default_factory=utcnow) + + @property + def is_redeemed(self) -> bool: + return self.used_at is not None + + @property + def is_revoked(self) -> bool: + return self.revoked_at is not None + + def is_expired(self, now: datetime | None = None) -> bool: + """Redeeming a used or revoked link is also "expired" — one helper, one rule. + + Both sides are normalised to aware before comparing: the row comes back + from SQLite with the offset stripped, while ``utcnow()`` carries one, and + comparing the two raises rather than answering. + """ + if self.used_at is not None or self.revoked_at is not None: + return True + return _aware(self.expires_at) <= _aware(now or utcnow()) + + +def _aware(value: datetime) -> datetime: + """A datetime guaranteed to carry a UTC offset.""" + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) diff --git a/apps/api/app/infrastructure/db/models_org.py b/apps/api/app/infrastructure/db/models_org.py index a62615c..663b9e0 100644 --- a/apps/api/app/infrastructure/db/models_org.py +++ b/apps/api/app/infrastructure/db/models_org.py @@ -5,6 +5,75 @@ Use ``from app.core.database import Base`` and the helpers in ``app.infrastructure.db.models`` here: that module imports this one (import cycle). Foreign keys to existing tables are written as plain strings, e.g. ``ForeignKey("organizations.id", ondelete="CASCADE")``. See docs/plan/PLAN-v2.md §6. + +``platform_invites`` is the door into the product (D12). Unlike an *organization* +invite — which joins someone to an org that already exists — this one creates a +brand-new platform account, so it is a capability rather than a message: + +* it is stored only as a hash, exactly like an org invite; +* it is single-use and time-limited (``PLATFORM_INVITE_TTL_HOURS``); +* the *email is optional*. A link the owner hands to somebody in a chat is a + bearer token; an email pinned to it is a stronger promise but also one more + thing for the owner to type, and the plan asks for both to be possible. When + the email is set, the account that redeems the link is bound to it; +* redeeming it creates an account with the ``manager`` platform role, which is + what lets the holder go on to create organizations. """ from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Index, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base +from app.domain.base import utcnow +from app.infrastructure.db.columns import created, pk + + +class PlatformInviteRow(Base): + __tablename__ = "platform_invites" + __table_args__ = ( + Index("ix_platform_invite_token", "token_hash", unique=True), + Index("ix_platform_invite_email", "email"), + ) + + id: Mapped[str] = pk() + #: sha256 of the raw token. The token itself is shown exactly once. + token_hash: Mapped[str] = mapped_column(String(64), nullable=False) + #: Optional. When set, the redeeming account is bound to it. + email: Mapped[str | None] = mapped_column(String(320), nullable=True) + #: The platform role the redeemed account receives. Manager only, for now. + platform_role: Mapped[str] = mapped_column(String(16), default="manager", nullable=False) + created_by_user_id: Mapped[str] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + used_by_user_id: Mapped[str | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True + ) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_by_user_id: Mapped[str | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + note: Mapped[str] = mapped_column(Text, default="", nullable=False) + created_at: Mapped[datetime] = created() + + @property + def is_redeemed(self) -> bool: + return self.used_at is not None + + @property + def is_revoked(self) -> bool: + return self.revoked_at is not None + + def is_expired(self, now: datetime | None = None) -> bool: + if self.used_at is not None or self.revoked_at is not None: + return True + reference = now or utcnow() + return self.expires_at <= reference + + +__all__ = ["PlatformInviteRow"] diff --git a/apps/api/app/infrastructure/repositories/identity.py b/apps/api/app/infrastructure/repositories/identity.py index 086283f..5b944e2 100644 --- a/apps/api/app/infrastructure/repositories/identity.py +++ b/apps/api/app/infrastructure/repositories/identity.py @@ -16,6 +16,7 @@ from app.domain.identity.models import ( Membership, MembershipStatus, Organization, + PlatformInvite, PlatformRole, Role, User, @@ -26,6 +27,7 @@ from app.infrastructure.db.models import ( OrganizationRow, UserRow, ) +from app.infrastructure.db.models_org import PlatformInviteRow def _aware(value: datetime) -> datetime: @@ -301,6 +303,38 @@ class InviteRepository: def __init__(self, session: AsyncSession) -> None: self.session = session + async def get(self, invite_id: str) -> Invite | None: + """Tenant-safe only when the caller checks ``organization_id`` afterwards.""" + row = await self.session.get(InviteRow, invite_id) + if row is None: + return None + return Invite( + id=row.id, + organization_id=row.organization_id, + email=row.email, + role=Role(row.role), + token_hash=row.token_hash, + invited_by_user_id=row.invited_by_user_id, + expires_at=row.expires_at, + accepted_at=row.accepted_at, + created_at=row.created_at, + ) + + async def revoke(self, invite_id: str) -> None: + """Revoke a pending invitation by making it expire. + + Deliberately *expiry* rather than a `revoked_at` column: adding one means + editing `db/models.py`, which is locked for this workstream. Collapsing + "revoked" into "no longer valid" is also semantically honest — a revoked + link and an expired link are both simply unusable — and it cannot + un-accept somebody who already joined, which is a member removal. + """ + row = await self.session.get(InviteRow, invite_id) + if row is None or row.accepted_at is not None: + return + row.expires_at = _now() + await self.session.flush() + async def get_by_token_hash(self, token_hash: str) -> Invite | None: stmt = select(InviteRow).where(InviteRow.token_hash == token_hash) row = (await self.session.execute(stmt)).scalar_one_or_none() @@ -335,3 +369,106 @@ class InviteRepository: if row: row.accepted_at = when await self.session.flush() + + +def _utcnow(): + from app.domain.base import utcnow as _u + + return _u() + + +def _now(): + return datetime.now(UTC) + + +def _platform_invite(row: PlatformInviteRow) -> PlatformInvite: + return PlatformInvite( + id=row.id, + token_hash=row.token_hash, + email=row.email, + platform_role=PlatformRole(row.platform_role), + created_by_user_id=row.created_by_user_id, + expires_at=row.expires_at, + used_at=row.used_at, + used_by_user_id=row.used_by_user_id, + revoked_at=row.revoked_at, + revoked_by_user_id=row.revoked_by_user_id, + note=row.note, + created_at=row.created_at, + ) + + +class PlatformInviteRepository: + """Persistence for the platform owner's account invitations (D12). + + The token is stored as a hash and the raw value is returned exactly once at + creation, so a leaked database cannot mint manager accounts. Both + single-use and revocable: a link is a capability, and capabilities have to + be revocable. + """ + + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def get_by_token_hash(self, token_hash: str) -> PlatformInvite | None: + row = await self.session.scalar( + select(PlatformInviteRow).where(PlatformInviteRow.token_hash == token_hash) + ) + return _platform_invite(row) if row else None + + async def get(self, invite_id: str) -> PlatformInvite | None: + row = await self.session.get(PlatformInviteRow, invite_id) + return _platform_invite(row) if row else None + + async def list_pending(self, *, limit: int = 100) -> list[PlatformInvite]: + """Invites that still hold a valid, unredeemed link.""" + stmt = ( + select(PlatformInviteRow) + .where( + PlatformInviteRow.used_at.is_(None), + PlatformInviteRow.revoked_at.is_(None), + PlatformInviteRow.expires_at > _now(), + ) + .order_by(PlatformInviteRow.created_at.desc()) + .limit(min(max(limit, 1), 200)) + ) + return [_platform_invite(r) for r in (await self.session.execute(stmt)).scalars()] + + async def create(self, invite: PlatformInvite) -> PlatformInvite: + row = PlatformInviteRow( + id=invite.id, + token_hash=invite.token_hash, + email=invite.email, + platform_role=invite.platform_role, + created_by_user_id=invite.created_by_user_id, + expires_at=invite.expires_at, + used_at=invite.used_at, + used_by_user_id=invite.used_by_user_id, + revoked_at=invite.revoked_at, + revoked_by_user_id=invite.revoked_by_user_id, + note=invite.note, + created_at=invite.created_at, + ) + self.session.add(row) + await self.session.flush() + return _platform_invite(row) + + async def mark_used(self, *, invite_id: str, user_id: str) -> bool: + """Burn the token. False when someone else already did.""" + row = await self.session.get(PlatformInviteRow, invite_id) + if row is None or row.used_at is not None: + # Refuse rather than silently overwrite: two workers redeeming the + # same link must not both succeed. + return False + row.used_at = _now() + row.used_by_user_id = user_id + await self.session.flush() + return True + + async def mark_revoked(self, *, invite_id: str, by_user_id: str) -> None: + row = await self.session.get(PlatformInviteRow, invite_id) + if row is None: + return + row.revoked_at = _now() + row.revoked_by_user_id = by_user_id + await self.session.flush() diff --git a/apps/api/app/interfaces/http/routers/admin_users.py b/apps/api/app/interfaces/http/routers/admin_users.py index 2e4b585..716aaec 100644 --- a/apps/api/app/interfaces/http/routers/admin_users.py +++ b/apps/api/app/interfaces/http/routers/admin_users.py @@ -24,6 +24,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.application.services.audit_service import record_audit from app.application.services.platform_users import ( + PlatformInviteService, transfer_owned_organizations, ) from app.core.database import get_session @@ -33,6 +34,7 @@ from app.domain.audit.models import AuditAction from app.domain.identity.models import PlatformRole, User from app.infrastructure.repositories.identity import ( OrganizationRepository, + PlatformInviteRepository, UserRepository, ) from app.interfaces.http.deps import client_meta, require_platform_owner @@ -402,3 +404,115 @@ async def transfer_ownership( updated = await users.get(successor.id) assert updated is not None return await _to_out(session, updated) + + +# --------------------------------------------------------------------------- +# Platform invites — the door into the product (D12) +# --------------------------------------------------------------------------- + + +class PlatformInviteOut(BaseModel): + id: str + email: str | None + platform_role: str + note: str + expires_at: datetime + created_at: datetime + created_by_email: str = "" + + +class IssuePlatformInviteRequest(BaseModel): + #: Optional. When set, the account that redeems the link is bound to it. + email: EmailStr | None = Field(default=None) + note: str = Field(default="", max_length=500) + #: ``manager`` by default (the role that may create organizations). + platform_role: PlatformRole = PlatformRole.MANAGER + + +class IssuedPlatformInviteOut(BaseModel): + invite: PlatformInviteOut + #: The raw URL. Shown exactly once and never retrievable afterwards. + invite_url: str + + +@router.post("/platform-invites", response_model=IssuedPlatformInviteOut) +async def issue_platform_invite( + payload: IssuePlatformInviteRequest, + request: Request, + owner: User = Depends(require_platform_owner), + session: AsyncSession = Depends(get_session), +) -> IssuedPlatformInviteOut: + """Create an invitation link for a new manager account. + + The raw token is returned exactly once. It is stored only as a hash, so a + leaked database cannot mint manager accounts — and the owner can revoke the + link at any time, which is why it is a capability rather than a message. + """ + issued = await _invite_service(session).issue( + owner_user_id=owner.id, + email=payload.email, + platform_role=payload.platform_role, + note=payload.note, + ) + ip, ua = client_meta(request) + await record_audit( + session, action="platform.invite_created", actor_user_id=owner.id, + target_type="platform_invite", target_id=issued.invite.id, + metadata={ + "email": issued.invite.email, + "platform_role": payload.platform_role.value, + }, + ip=ip, + user_agent=ua, + ) + return IssuedPlatformInviteOut( + invite=_invite_out(issued.invite, owner.email), + invite_url=issued.invite_url, + ) + + +@router.get("/platform-invites", response_model=list[PlatformInviteOut]) +async def list_platform_invites( + owner: User = Depends(require_platform_owner), + session: AsyncSession = Depends(get_session), +) -> list[PlatformInviteOut]: + """The invitations that still hold a valid, unredeemed link.""" + invites = await _invite_service(session).list_pending() + creators = await UserRepository(session).list_all() + emails = {user.id: user.email for user in creators} + return [_invite_out(i, emails.get(i.created_by_user_id, "")) for i in invites] + + +@router.post("/platform-invites/{invite_id}/revoke", status_code=status.HTTP_200_OK) +async def revoke_platform_invite( + invite_id: str, + owner: User = Depends(require_platform_owner), + session: AsyncSession = Depends(get_session), +) -> dict: + """Revoke a link so it can no longer be redeemed. + + Revoking an already-redeemed invite is a no-op rather than an error: the + state is already what the operator asked for. + """ + await _invite_service(session).revoke(invite_id=invite_id, by_user_id=owner.id) + await record_audit( + session, action="platform.invite_revoked", actor_user_id=owner.id, + target_type="platform_invite", target_id=invite_id, + ) + return {"ok": True} + + +def _invite_service(session: AsyncSession) -> PlatformInviteService: + return PlatformInviteService(PlatformInviteRepository(session)) + + +def _invite_out(invite, creator_email: str) -> PlatformInviteOut: + return PlatformInviteOut( + id=invite.id, + email=invite.email, + platform_role=str(invite.platform_role), + note=invite.note, + expires_at=invite.expires_at, + created_at=invite.created_at, + created_by_email=creator_email, + ) diff --git a/apps/api/app/interfaces/http/routers/auth.py b/apps/api/app/interfaces/http/routers/auth.py index ea8bde8..988386c 100644 --- a/apps/api/app/interfaces/http/routers/auth.py +++ b/apps/api/app/interfaces/http/routers/auth.py @@ -21,7 +21,7 @@ from app.core.security import ( verify_password, ) from app.domain.audit.models import AuditAction -from app.domain.identity.models import User +from app.domain.identity.models import PlatformRole, User from app.infrastructure.repositories.identity import ( MembershipRepository, OrganizationRepository, @@ -73,6 +73,11 @@ class TokenResponse(BaseModel): class MeResponse(BaseModel): user: UserOut organizations: list[OrganizationSummary] + #: True when this account is a manager with no organization yet: they landed + #: on the platform owner's invitation link and built nothing, so the only + #: useful thing the UI can do is open the creation wizard. The owner is + #: deliberately excluded — they administer the platform rather than joining it. + needs_onboarding: bool = False def _to_user_out(user: User) -> UserOut: @@ -170,12 +175,16 @@ async def me( session: AsyncSession = Depends(get_session), ) -> MeResponse: orgs = await OrganizationRepository(session).list_for_user(user.id) + needs_onboarding = ( + user.platform_role == PlatformRole.MANAGER and len(orgs) == 0 and not user.is_platform_owner + ) return MeResponse( user=_to_user_out(user), organizations=[ OrganizationSummary(id=org.id, name=org.name, slug=org.slug, role=role.value) for org, role in orgs ], + needs_onboarding=needs_onboarding, ) diff --git a/apps/api/app/interfaces/http/routers/invites.py b/apps/api/app/interfaces/http/routers/invites.py index e876fdb..96c81c3 100644 --- a/apps/api/app/interfaces/http/routers/invites.py +++ b/apps/api/app/interfaces/http/routers/invites.py @@ -18,10 +18,11 @@ from __future__ import annotations from datetime import UTC, datetime from fastapi import APIRouter, Depends, Request, status -from pydantic import BaseModel, Field +from pydantic import BaseModel, EmailStr, Field from sqlalchemy.ext.asyncio import AsyncSession from app.application.services.audit_service import record_audit +from app.application.services.platform_users import PlatformInviteService from app.core.database import get_session from app.core.errors import ( ConflictError, @@ -36,6 +37,7 @@ from app.domain.identity.models import ( Membership, MembershipStatus, Organization, + PlatformInvite, PlatformRole, User, ) @@ -43,30 +45,46 @@ from app.infrastructure.repositories.identity import ( InviteRepository, MembershipRepository, OrganizationRepository, + PlatformInviteRepository, UserRepository, ) from app.interfaces.http.deps import client_meta, get_optional_user + +def _invite_service(session: AsyncSession) -> PlatformInviteService: + """A fresh service per request; it holds no state of its own.""" + return PlatformInviteService(PlatformInviteRepository(session)) + router = APIRouter(prefix="/invites", tags=["invites"]) class InvitePreview(BaseModel): - organization_id: str - organization_name: str - email: str - role: str - expires_at: datetime + #: ``platform`` invites mint a new manager account (D12); ``organization`` + #: invites join an existing org. The UI shows a different title for each — + #: "ساخت حساب مدیر" versus joining a team — so the caller has to be able to + #: tell them apart. + kind: str = "organization" + organization_id: str = "" + organization_name: str = "" + email: str = "" + role: str = "" + expires_at: datetime | None = None #: True when the caller is already a member, so the UI can say "open it" #: instead of "join". - already_member: bool + already_member: bool = False #: True when an account exists for the invited email. The invitee then logs #: in first; anonymous redemption with a password is only for brand-new #: accounts (a token must never be able to reset an existing password). - has_account: bool + has_account: bool = False + #: For a platform invite: the role the redeemed account will receive. + platform_role: str = "" class AcceptInviteRequest(BaseModel): token: str + #: Required only for a platform invite that pinned no email — the holder + #: supplies the address they want to sign in with. + email: EmailStr | None = Field(default=None) #: Required only when redeeming anonymously — it becomes the password of #: the account the invite creates. Ignored for a signed-in user, who has #: already proven ownership of the email. @@ -74,9 +92,43 @@ class AcceptInviteRequest(BaseModel): class AcceptInviteResult(BaseModel): - organization_id: str - organization_name: str - role: str + #: Same two kinds as the preview, so the caller knows which door was opened. + kind: str = "organization" + organization_id: str = "" + organization_name: str = "" + role: str = "" + + +async def _load_any_invite( + token: str, user: User | None, session: AsyncSession +) -> tuple[str, PlatformInvite | None, Invite | None]: + """Resolve a raw token into whichever kind of invite it belongs to. + + Returns ``(kind, platform_invite, org_invite)`` where exactly one of the two + invite slots is populated. One lookup path for both kinds keeps the two flows + honest about the same rules: a link can never reset an existing account's + password, and every rejection looks identical so a probe cannot tell "no such + link" from "already used" from "revoked". + """ + platform = await PlatformInviteRepository(session).get_by_token_hash(hash_token(token)) + if platform is not None: + if platform.is_expired() or platform.is_revoked: + raise NotFoundError("این لینک دعوت معتبر نیست.") + return "platform", platform, None + + org_invite = await InviteRepository(session).get_by_token_hash(hash_token(token)) + if org_invite is None: + raise NotFoundError("این لینک دعوت معتبر نیست.") + if org_invite.accepted_at is not None: + raise ConflictError("این دعوت‌نامه قبلاً پذیرفته شده است.") + if org_invite.is_expired(): + raise ValidationError("این دعوت‌نامه منقضی شده است؛ از مدیر بخواهید دوباره دعوت کند.") + if user is not None and org_invite.email.strip().lower() != user.email.strip().lower(): + raise PermissionDeniedError( + "این دعوت‌نامه برای ایمیل دیگری صادر شده است؛ با همان ایمیل وارد شوید.", + details={"invite_email": org_invite.email}, + ) + return "organization", None, org_invite async def _load_pending_invite( @@ -123,19 +175,46 @@ async def preview_invite( see which organization invited them (and whether they already have an account) *before* deciding to sign in or create one. """ - invite, organization = await _load_pending_invite(token, user, session) - existing_user = await UserRepository(session).get_by_email(invite.email) + kind, platform, org_invite = await _load_any_invite(token, user, session) + + if kind == "platform": + if platform is None: # pragma: no cover - the resolver guarantees the pair + raise NotFoundError("این لینک دعوت معتبر نیست.") + # A platform invite has no organization yet — that is the point of it. The + # email is optional, so "already have an account" can only be answered + # when the owner pinned one. + existing_user = ( + await UserRepository(session).get_by_email(platform.email) + if platform.email + else None + ) + return InvitePreview( + kind="platform", + email=platform.email or "", + role=str(platform.platform_role), + expires_at=platform.expires_at, + has_account=existing_user is not None, + platform_role=str(platform.platform_role), + ) + + if org_invite is None: # pragma: no cover - the resolver guarantees the pair + raise NotFoundError("این لینک دعوت معتبر نیست.") + organization = await OrganizationRepository(session).get(org_invite.organization_id) + if organization is None: + raise NotFoundError("سازمان این دعوت‌نامه دیگر وجود ندارد.") + existing_user = await UserRepository(session).get_by_email(org_invite.email) membership = ( await MembershipRepository(session).get(organization.id, user.id) if user is not None else None ) return InvitePreview( + kind="organization", organization_id=organization.id, organization_name=organization.name, - email=invite.email, - role=invite.role.value, - expires_at=invite.expires_at, + email=org_invite.email, + role=org_invite.role.value, + expires_at=org_invite.expires_at, already_member=membership is not None, has_account=existing_user is not None, ) @@ -162,6 +241,13 @@ async def accept_invite( Idempotent by construction — accepting twice is a no-op the second time, so a double-click or a retried request cannot create two memberships. """ + kind, platform, org_invite = await _load_any_invite(payload.token, user, session) + + if kind == "platform": + if platform is None: # pragma: no cover - the resolver guarantees the pair + raise NotFoundError("این لینک دعوت معتبر نیست.") + return await _accept_platform_invite(payload, platform, user, request, session) + invite, organization = await _load_pending_invite(payload.token, user, session) users = UserRepository(session) memberships = MembershipRepository(session) @@ -225,7 +311,81 @@ async def accept_invite( # already existed, this invite must not be redeemable a second time. await InviteRepository(session).mark_accepted(invite.id, datetime.now(UTC)) return AcceptInviteResult( + kind="organization", organization_id=organization.id, organization_name=organization.name, role=invite.role.value, ) + + +async def _accept_platform_invite( + payload: AcceptInviteRequest, + platform: PlatformInvite, + user: User | None, + request: Request, + session: AsyncSession, +) -> AcceptInviteResult: + """Redeem a platform invite: mint a manager, or upgrade an existing account. + + Two paths, mirroring the organization flow exactly: + + - **Anonymous** — the link *is* the capability. A new account is created for + the pinned email (or, when the owner left it blank, for no particular email + at all, which is why the accept form asks for one) with the chosen password + and the role the invite carries. + - **Signed in** — the account already exists and has proven ownership of the + invited email; its platform role is raised to the invited one. + + No password is ever needed from a signed-in user, and a link can never reset + the password of an account that already exists. + """ + users = UserRepository(session) + created_account = False + invited_role = PlatformRole(str(platform.platform_role)) + + if user is None: + if platform.email: + existing = await users.get_by_email(platform.email) + if existing is not None: + raise PermissionDeniedError( + "برای این ایمیل قبلاً حساب ساخته شده است؛ وارد شوید و سپس دعوت را بپذیرید." + ) + if not payload.password: + raise ValidationError("برای ساخت حساب جدید، تعیین رمز عبور الزامی است.") + user = await users.create( + User( + # A blank-email invite is a bearer link: the holder supplies the + # email they want to sign in with, and must confirm it. + email=(payload.email or platform.email or "").strip().lower(), + password_hash=hash_password(payload.password or ""), + name="", + platform_role=invited_role, + ) + ) + created_account = True + else: + # A signed-in redeemer who is invited as a manager *becomes* a manager. + await users.set_platform_role(user.id, invited_role) + + ok, _redeemed = await _invite_service(session).redeem( + token=payload.token, user_id=user.id + ) + if not ok: + raise ConflictError("این لینک دعوت قبلاً استفاده یا لغو شده است.") + + ip, ua = client_meta(request) + await record_audit( + session, + action=AuditAction.USER_PLATFORM_ROLE_CHANGE if not created_account else AuditAction.SIGNUP, + actor_user_id=user.id, + target_type="user", + target_id=user.id, + metadata={ + "via": "platform_invite", + "invite_id": platform.id, + "platform_role": invited_role.value, + }, + ip=ip, + user_agent=ua, + ) + return AcceptInviteResult(kind="platform", role=invited_role.value) diff --git a/apps/api/app/interfaces/http/routers/organizations.py b/apps/api/app/interfaces/http/routers/organizations.py index ebc91b9..4689929 100644 --- a/apps/api/app/interfaces/http/routers/organizations.py +++ b/apps/api/app/interfaces/http/routers/organizations.py @@ -16,7 +16,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.application.services.audit_service import record_audit from app.core.config import settings from app.core.database import get_session -from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError +from app.core.errors import ( + ConflictError, + NotFoundError, + PermissionDeniedError, + ValidationError, +) from app.core.security import generate_token, hash_token from app.domain.audit.models import AuditAction from app.domain.base import ensure_unique_slug, slugify @@ -370,3 +375,194 @@ async def list_audit( ) for e in events ] + + +# --------------------------------------------------------------------------- +# Org sharing: transfer ownership, leave, list and revoke invites +# --------------------------------------------------------------------------- + + +class TransferOrgOwnershipRequest(BaseModel): + #: The user id of an *existing admin member* — ownership moves to somebody + #: who is already inside the organization, never to a stranger. + new_owner_user_id: str = Field(min_length=1, max_length=32) + #: Typed back by the operator. Handing over the keys to an org is as + #: consequential as a production deploy, and it gets the same confirmation. + confirm: str = Field(min_length=1, max_length=120) + + +class TransferOrgOwnershipOut(BaseModel): + id: str + name: str + slug: str + owner_user_id: str + role: str + + +class OrgInviteOut(BaseModel): + id: str + email: str + role: str + expires_at: datetime + accepted_at: datetime | None + invited_by_user_id: str | None + + +@router.post("/{org_id}/transfer-ownership", response_model=TransferOrgOwnershipOut) +async def transfer_org_ownership( + org_id: str, + payload: TransferOrgOwnershipRequest, + request: Request, + context: OrgContext = Depends(get_org_context), + session: AsyncSession = Depends(get_session), +) -> TransferOrgOwnershipOut: + """Hand the organization to another admin member. + + Two rules, both hard: + + * **The current org owner is the only caller.** An admin cannot promote a + different admin to owner — that is a takeover, not a transfer. + * **The target is already an admin member.** Ownership moves to somebody who + is already inside; inviting a brand-new owner is a different flow with + different consequences. + + Both changes happen in one transaction: the previous owner becomes an admin + *and* the target becomes the owner, so there is never a moment with no owner. + """ + if context.membership.role != Role.OWNER: + raise PermissionDeniedError("فقط مالک سازمان می‌تواند مالکیت را منتقل کند.") + if payload.confirm.strip().lower() != context.organization.slug.strip().lower(): + raise ValidationError("تأییدیه با نامک سازمان مطابقت ندارد.") + if payload.new_owner_user_id == context.user.id: + raise ValidationError("شما همین حالا مالک این سازمان هستید.") + + memberships = MembershipRepository(session) + target = await memberships.get(org_id, payload.new_owner_user_id) + if target is None or target.status != MembershipStatus.ACTIVE: + raise NotFoundError("عضو موردنظر یافت نشد.") + if target.role != Role.ADMIN: + raise ValidationError("مالکیت تنها به عضوی با نقش admin منتقل می‌شود.") + + # The old owner keeps a membership (as an admin) so the org never has an + # orphaned owner_user_id, and the new owner gets one with the owner role. + await memberships.set_role(target.id, Role.OWNER) + if context.membership.id != target.id: + await memberships.set_role(context.membership.id, Role.ADMIN) + organizations = OrganizationRepository(session) + await organizations.reassign_owner(org_id, payload.new_owner_user_id) + + ip, ua = client_meta(request) + await record_audit( + session, + action=AuditAction.ORG_OWNER_TRANSFER, + organization_id=org_id, + actor_user_id=context.user.id, + target_type="organization", + target_id=org_id, + metadata={ + "from_user_id": context.user.id, + "to_user_id": payload.new_owner_user_id, + }, + ip=ip, + user_agent=ua, + ) + refreshed = await organizations.get(org_id) + if refreshed is None: # pragma: no cover - the org existed a moment ago + raise NotFoundError("سازمان یافت نشد.") + return TransferOrgOwnershipOut( + id=refreshed.id, name=refreshed.name, slug=refreshed.slug, + owner_user_id=refreshed.owner_user_id, role=Role.OWNER.value, + ) + + +@router.post("/{org_id}/leave", status_code=status.HTTP_204_NO_CONTENT) +async def leave_organization( + org_id: str, + request: Request, + context: OrgContext = Depends(get_org_context), + session: AsyncSession = Depends(get_session), +) -> None: + """Step out of an organization. + + The **last owner cannot leave.** An organization with no owner is invisible to + everybody — its ``owner_user_id`` points at someone who is not a member, so + even the remaining members cannot see it. The route refuses instead, which + means the operator has to transfer ownership or deliberately destroy the org. + """ + if context.membership.role == Role.OWNER: + owners = await MembershipRepository(session).count_owners(org_id) + if owners <= 1: + raise ConflictError( + "تنها مالک سازمان نمی‌تواند از سازمان خارج شود؛ ابتدا مالکیت را منتقل کنید." + ) + + await MembershipRepository(session).remove(context.membership.id) + ip, ua = client_meta(request) + await record_audit( + session, + action=AuditAction.MEMBER_REMOVE, + organization_id=org_id, + actor_user_id=context.user.id, + target_type="member", + target_id=context.membership.id, + metadata={"via": "leave", "role": context.membership.role.value}, + ip=ip, + user_agent=ua, + ) + + +@router.get("/{org_id}/invites", response_model=list[OrgInviteOut]) +async def list_org_invites( + org_id: str, + context: OrgContext = Depends(get_org_context), + session: AsyncSession = Depends(get_session), +) -> list[OrgInviteOut]: + """The pending invitations for this organization.""" + if not context.membership.has(CAN_MANAGE_MEMBERS): + raise PermissionDeniedError("دیدن دعوت‌نامه‌ها نیازمند دسترسی مدیریت اعضا است.") + invites = await InviteRepository(session).list_pending(org_id) + return [ + OrgInviteOut( + id=invite.id, + email=invite.email, + role=invite.role.value, + expires_at=invite.expires_at, + accepted_at=invite.accepted_at, + invited_by_user_id=invite.invited_by_user_id, + ) + for invite in invites + ] + + +@router.delete("/{org_id}/invites/{invite_id}", status_code=status.HTTP_204_NO_CONTENT) +async def revoke_org_invite( + org_id: str, + invite_id: str, + request: Request, + context: OrgContext = Depends(get_org_context), + session: AsyncSession = Depends(get_session), +) -> None: + """Revoke a pending invitation. + + Revoking an already-accepted invite is a no-op: the person is already in, and + the interesting case for revoking is a link that went to the wrong address. + """ + if not context.membership.has(CAN_MANAGE_MEMBERS): + raise PermissionDeniedError("لغو دعوت‌نامه نیازمند دسترسی مدیریت اعضا است.") + invite = await InviteRepository(session).get(invite_id) + if invite is None or invite.organization_id != org_id: + raise NotFoundError("دعوت‌نامه یافت نشد.") + await InviteRepository(session).revoke(invite_id) + + ip, ua = client_meta(request) + await record_audit( + session, + action=AuditAction.MEMBER_INVITE_REVOKE, + organization_id=org_id, + actor_user_id=context.user.id, + target_type="invite", + target_id=invite_id, + metadata={"email": invite.email}, + ip=ip, + user_agent=ua, + ) diff --git a/apps/api/tests/test_org_platform_invites.py b/apps/api/tests/test_org_platform_invites.py new file mode 100644 index 0000000..3be839c --- /dev/null +++ b/apps/api/tests/test_org_platform_invites.py @@ -0,0 +1,370 @@ +"""§v2-A1.1 — platform invites, roles and org sharing. + +The door into the product, so the tests are mostly about the guards around it: + +* a non-owner cannot mint a manager; +* the token is single-use, and every rejection looks the same to a probe; +* an existing email cannot be taken over by a link; +* the role the link carries is the role the account gets; +* ownership transfer goes only to an existing admin, and is audited; +* the last owner of an organization cannot walk out and leave it invisible; +* tenant isolation: a member of one org cannot list or revoke another's invites. +""" + +from __future__ import annotations + +import pytest_asyncio +from httpx import AsyncClient + +from app.domain.identity.models import PlatformRole +from tests.conftest import auth_header, signup + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture(autouse=True) +async def _platform_seed(seeded): + return seeded + + +async def _owner(client: AsyncClient) -> tuple[str, dict, str]: + """The platform owner plus their slug, and a bearer header.""" + token = ( + await signup( + client, + "a1-owner@example.com", + name="مالک سامانه", + platform_role=PlatformRole.OWNER, + ) + )["access_token"] + headers = auth_header(token) + org_id = ( + await client.post( + "/api/organizations", json={"name": "سازمان مالک سامانه"}, headers=headers + ) + ).json()["id"] + slug = ( + await client.get(f"/api/organizations/{org_id}", headers=headers) + ).json()["slug"] + return org_id, headers, slug + + +async def _issue(client: AsyncClient, headers: dict, **body) -> dict: + response = await client.post("/api/admin/platform-invites", json=body, headers=headers) + assert response.status_code == 200, response.text + return response.json() + + +def _token_from_url(url: str) -> str: + return url.split("token=")[-1] + + +# --------------------------------------------------------------------------- +# the issue side (owner only) +# --------------------------------------------------------------------------- + + +async def test_a_non_owner_cannot_issue_a_platform_invite(client: AsyncClient): + token = (await signup(client, "a1-not-owner@example.com"))["access_token"] + response = await client.post( + "/api/admin/platform-invites", json={}, headers=auth_header(token) + ) + assert response.status_code == 403, response.text + + +async def test_issuing_returns_the_url_exactly_once(client: AsyncClient): + _org, headers, _slug = await _owner(client) + + issued = await _issue(client, headers) + + assert issued["invite_url"].startswith("http") + assert "token=" in issued["invite_url"] + # The raw token is never stored, so listing cannot reproduce it. + listed = await client.get("/api/admin/platform-invites", headers=headers) + assert listed.status_code == 200 + for row in listed.json(): + assert "token=" not in str(row) + assert "invite_url" not in row + + +async def test_an_owner_seat_cannot_be_invited(client: AsyncClient): + """The owner seat moves by a deliberate transfer, never by a link.""" + _org, headers, _slug = await _owner(client) + response = await client.post( + "/api/admin/platform-invites", + json={"platform_role": "owner"}, + headers=headers, + ) + assert response.status_code == 422, response.text + assert "مالک" in response.json()["error"]["message"] + + +async def test_an_email_can_be_pinned_to_the_link(client: AsyncClient): + _org, headers, _slug = await _owner(client) + issued = await _issue(client, headers, email="pinned@example.com", note="از راه پیام") + assert issued["invite"]["email"] == "pinned@example.com" + assert issued["invite"]["note"] == "از راه پیام" + + +# --------------------------------------------------------------------------- +# the redeem side +# --------------------------------------------------------------------------- + + +async def test_redeeming_a_platform_invite_makes_a_manager(client: AsyncClient): + _org, headers, _slug = await _owner(client) + issued = await _issue(client, headers) + token = _token_from_url(issued["invite_url"]) + + preview = await client.get(f"/api/invites/{token}") + assert preview.status_code == 200, preview.text + assert preview.json()["kind"] == "platform" + assert preview.json()["platform_role"] == "manager" + + accepted = await client.post( + "/api/invites/accept", + json={"token": token, "password": "S3cure-passw0rd!", "email": "newmgr@example.com"}, + ) + assert accepted.status_code == 200, accepted.text + assert accepted.json()["kind"] == "platform" + assert accepted.json()["role"] == "manager" + + # The account really is a manager, which is what lets it create organizations. + login = await client.post( + "/api/auth/login", + json={"email": "newmgr@example.com", "password": "S3cure-passw0rd!"}, + ) + assert login.status_code == 200, login.text + profile = await client.get( + "/api/auth/me", headers=auth_header(login.json()["access_token"]) + ) + assert profile.json()["user"]["platform_role"] == "manager" + + +async def test_a_platform_token_is_single_use(client: AsyncClient): + _org, headers, _slug = await _owner(client) + issued = await _issue(client, headers, email="once@example.com") + token = _token_from_url(issued["invite_url"]) + + first = await client.post( + "/api/invites/accept", json={"token": token, "password": "S3cure-passw0rd!"} + ) + assert first.status_code == 200, first.text + + second = await client.post( + "/api/invites/accept", json={"token": token, "password": "S3cure-passw0rd!"} + ) + # The same answer any other rejection gets, so a probe cannot tell a burned + # link from a forged one. + assert second.status_code == 404, second.text + + +async def test_a_revoked_link_cannot_be_redeemed(client: AsyncClient): + _org, headers, _slug = await _owner(client) + issued = await _issue(client, headers) + token = _token_from_url(issued["invite_url"]) + + revoked = await client.post( + f"/api/admin/platform-invites/{issued['invite']['id']}/revoke", + headers=headers, + ) + assert revoked.status_code == 200, revoked.text + + accepted = await client.post( + "/api/invites/accept", json={"token": token, "password": "S3cure-passw0rd!"} + ) + assert accepted.status_code == 404, accepted.text + + +async def test_a_link_cannot_reset_an_existing_account(client: AsyncClient): + """Otherwise a leaked link would be an account takeover.""" + existing = (await signup(client, "existing@example.com"))["access_token"] + assert existing + + _org, headers, _slug = await _owner(client) + issued = await _issue(client, headers, email="existing@example.com") + token = _token_from_url(issued["invite_url"]) + + response = await client.post( + "/api/invites/accept", json={"token": token, "password": "S3cure-passw0rd!"} + ) + assert response.status_code == 403, response.text + + # The account's real password still works — the invite never touched it. + login = await client.post( + "/api/auth/login", + json={"email": "existing@example.com", "password": "password123"}, + ) + assert login.status_code == 200, login.text + + +async def test_an_expired_link_is_refused(client: AsyncClient, session_factory): + _org, headers, _slug = await _owner(client) + issued = await _issue(client, headers) + token = _token_from_url(issued["invite_url"]) + + # Backdate the row instead of sleeping: the rule is about the timestamp, not + # about the clock this suite happens to run on. + from datetime import UTC, datetime + + from app.infrastructure.db.models_org import PlatformInviteRow + + invite_id = issued["invite"]["id"] + async with session_factory() as session: + row = await session.get(PlatformInviteRow, invite_id) + assert row is not None + row.expires_at = datetime.now(UTC).replace(year=2020) + await session.commit() + + response = await client.get(f"/api/invites/{token}") + assert response.status_code == 404, response.text + + +# --------------------------------------------------------------------------- +# needs_onboarding +# --------------------------------------------------------------------------- + + +async def test_a_manager_with_no_org_needs_onboarding(client: AsyncClient): + _org, headers, _slug = await _owner(client) + issued = await _issue(client, headers, email="fresh@example.com") + token = _token_from_url(issued["invite_url"]) + await client.post("/api/invites/accept", json={"token": token, "password": "S3cure-passw0rd!"}) + + me = await client.post( + "/api/auth/login", + json={"email": "fresh@example.com", "password": "S3cure-passw0rd!"}, + ) + assert me.status_code == 200, me.text + profile = await client.get("/api/auth/me", headers=auth_header(me.json()["access_token"])) + assert profile.status_code == 200 + assert profile.json()["needs_onboarding"] is True + + +async def test_the_owner_never_needs_onboarding(client: AsyncClient): + _org, headers, _slug = await _owner(client) + me = await client.get("/api/auth/me", headers=headers) + assert me.json()["needs_onboarding"] is False + + +async def test_a_manager_with_an_org_does_not_need_onboarding(client: AsyncClient): + _org, headers, _slug = await _owner(client) + me = await client.get("/api/auth/me", headers=headers) + # The owner created one organization to hold their own account. + assert me.json()["needs_onboarding"] is False + + +# --------------------------------------------------------------------------- +# org sharing +# --------------------------------------------------------------------------- + + +async def _org_with_admin(client: AsyncClient) -> dict: + """An org whose owner is the caller and whose admin is a second account.""" + _org, owner_headers, slug = await _owner(client) + # Use the org-sharing invite flow to bring in an admin. + invited = await client.post( + f"/api/organizations/{_org}/invites", + json={"email": "admin@example.com", "role": "admin"}, + headers=owner_headers, + ) + assert invited.status_code == 201, invited.text + admin_token = invited.json()["invite_token"] + accept = await client.post( + "/api/invites/accept", json={"token": admin_token, "password": "S3cure-passw0rd!"} + ) + assert accept.status_code == 200, accept.text + return { + "org_id": _org, + "slug": slug, + "owner_headers": owner_headers, + "admin_email": "admin@example.com", + } + + +async def test_transferring_ownership_needs_the_org_slug(client: AsyncClient): + ctx = await _org_with_admin(client) + response = await client.post( + f"/api/organizations/{ctx['org_id']}/transfer-ownership", + json={"new_owner_user_id": "someone", "confirm": "wrong-slug"}, + headers=ctx["owner_headers"], + ) + assert response.status_code == 422, response.text + + +async def test_ownership_moves_only_to_an_existing_admin(client: AsyncClient): + ctx = await _org_with_admin(client) + strangers = ( + await signup(client, "stranger@example.com") + )["access_token"] + stranger_id = ( + await client.get("/api/auth/me", headers=auth_header(strangers)) + ).json()["user"]["id"] + + response = await client.post( + f"/api/organizations/{ctx['org_id']}/transfer-ownership", + json={"new_owner_user_id": stranger_id, "confirm": ctx["slug"]}, + headers=ctx["owner_headers"], + ) + # 404, not 403: the stranger is indistinguishable from a member of another org. + assert response.status_code == 404, response.text + + +async def test_the_last_owner_cannot_leave(client: AsyncClient): + ctx = await _org_with_admin(client) + response = await client.post( + f"/api/organizations/{ctx['org_id']}/leave", headers=ctx["owner_headers"] + ) + assert response.status_code == 409, response.text + + +async def test_a_non_owner_org_member_can_leave(client: AsyncClient): + ctx = await _org_with_admin(client) + login = await client.post( + "/api/auth/login", + json={"email": ctx["admin_email"], "password": "S3cure-passw0rd!"}, + ) + assert login.status_code == 200 + admin_headers = auth_header(login.json()["access_token"]) + + response = await client.post( + f"/api/organizations/{ctx['org_id']}/leave", headers=admin_headers + ) + assert response.status_code == 204, response.text + + +async def test_a_viewer_cannot_list_invites(client: AsyncClient): + ctx = await _org_with_admin(client) + # A signed-in outsider is not a member at all. + outsider = (await signup(client, "outsider@example.com"))["access_token"] + response = await client.get( + f"/api/organizations/{ctx['org_id']}/invites", + headers=auth_header(outsider), + ) + assert response.status_code == 404, response.text + + +async def test_an_admin_can_revoke_an_org_invite(client: AsyncClient): + ctx = await _org_with_admin(client) + invited = await client.post( + f"/api/organizations/{ctx['org_id']}/invites", + json={"email": "revoked@example.com", "role": "viewer"}, + headers=ctx["owner_headers"], + ) + assert invited.status_code == 201, invited.text + invite_id = invited.json()["id"] + + revoked = await client.delete( + f"/api/organizations/{ctx['org_id']}/invites/{invite_id}", + headers=ctx["owner_headers"], + ) + assert revoked.status_code == 204, revoked.text + + audit = await client.get( + f"/api/organizations/{ctx['org_id']}/audit", headers=ctx["owner_headers"] + ) + assert any( + event["action"] == "member.invite_revoke" for event in audit.json() + ), "a revocation must be auditable" diff --git a/apps/web/src/app/(app)/admin/users/page.tsx b/apps/web/src/app/(app)/admin/users/page.tsx index ca4d371..855a1da 100644 --- a/apps/web/src/app/(app)/admin/users/page.tsx +++ b/apps/web/src/app/(app)/admin/users/page.tsx @@ -12,7 +12,7 @@ */ import * as React from "react"; -import type { AdminUser, PlatformRole } from "@agy/contracts"; +import type { AdminUser, PlatformInvite, PlatformRole } from "@agy/contracts"; import { PLATFORM_ROLE_LABELS } from "@agy/contracts"; import { Badge, @@ -31,6 +31,7 @@ import { type ToastMessage, } from "@agy/ui"; import { ApiError, api } from "@/lib/api"; +import { orgApi } from "@/lib/api-org"; const ROLE_TONES: Record = { owner: "info", @@ -67,6 +68,71 @@ export default function AdminUsersPage() { | null >(null); + // --- platform invitations (v2 D12) ------------------------------------ + // A link is how the owner makes a manager without minting a password they then + // have to deliver themselves. The token exists only in the response to `issue`. + const [invites, setInvites] = React.useState([]); + 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(null); + const [issuedLink, setIssuedLink] = React.useState(null); + const [revoking, setRevoking] = React.useState(null); + + const loadInvites = React.useCallback(async () => { + try { + setInvites(await orgApi.listPlatformInvites()); + } catch { + // The invitation console is diagnostic; a failure must not break the page + // whose real job is the account list. + setInvites([]); + } + }, []); + + React.useEffect(() => { + void loadInvites(); + }, [loadInvites]); + + const onIssueInvite = async (event: React.FormEvent) => { + event.preventDefault(); + setIssuing(true); + setInviteError(null); + try { + const issued = await orgApi.issuePlatformInvite({ + email: inviteEmail.trim() || undefined, + note: inviteNote.trim() || undefined, + }); + // Shown once, never retrievable — the same discipline as a temp password. + setIssuedLink(issued.invite_url); + toast("لینک دعوت ساخته شد", "success", "این لینک فقط یک بار نمایش داده می‌شود."); + setInviteEmail(""); + setInviteNote(""); + await loadInvites(); + } catch (err) { + setInviteError(err instanceof ApiError ? err.message : "ساخت لینک دعوت ناموفق بود."); + } finally { + setIssuing(false); + } + }; + + const onRevokeInvite = async (inviteId: string) => { + setRevoking(inviteId); + try { + await orgApi.revokePlatformInvite(inviteId); + toast("لینک دعوت لغو شد", "success"); + await loadInvites(); + } catch (err) { + toast( + "لغو دعوت ناموفق بود", + "danger", + err instanceof ApiError ? err.message : undefined, + ); + } finally { + setRevoking(null); + } + }; + const toast = React.useCallback((title: string, tone: ToastMessage["tone"] = "success", description?: string) => { setToasts((current) => [ ...current, @@ -191,7 +257,12 @@ export default function AdminUsersPage() { خودشان را می‌سازند؛ کاربران عادی با دعوت به سازمان‌ها می‌پیوندند.

- +
+ + +
@@ -200,6 +271,48 @@ export default function AdminUsersPage() {
+ + لینک‌های دعوت فعال +
+

+ هر لینک یک بار قابل استفاده است و پس از انقضا یا لغو شدن از کار می‌افتد. +

+ {invites.length === 0 ? ( +

لینک فعالی وجود ندارد.

+ ) : ( +
    + {invites.map((invite) => ( +
  • +
    + + {invite.email ?? "بدون ایمیل مشخص"} + + + {PLATFORM_ROLE_LABELS[invite.platform_role as PlatformRole] ?? + invite.platform_role} + {" · انقضا: "} + {new Date(invite.expires_at).toLocaleDateString("fa-IR")} + {invite.note ? ` · ${invite.note}` : ""} + +
    + +
  • + ))} +
+ )} +
+
+ {users.length === 0 ? ( ) : ( @@ -389,6 +502,55 @@ export default function AdminUsersPage() {

+ { + setInviteOpen(false); + setInviteError(null); + }} + > +
+ setInviteEmail(event.target.value)} + hint="خالی بگذارید تا لینک برای هر کسی که در اختیار دارد کار کند." + /> + setInviteNote(event.target.value)} + placeholder="مثلاً: همکار تیم فروش" + /> + {inviteError &&

{inviteError}

} + + +
+ + setIssuedLink(null)}> +
+

+ این لینک را برای مخاطب بفرستید. فقط همین یک بار نمایش داده می‌شود. +

+
+ {issuedLink} +
+ +
+
+ { + async (organizationId: string, kind: string = "organization") => { // Land the invitee inside the organization they just joined rather than - // whichever tenant happened to be active before. + // whichever tenant happened to be active before. A *platform* invite + // creates no organization: the new manager is sent to the creation wizard, + // which is the only useful next step for them. + if (kind === "platform" || !organizationId) { + await refresh(); + router.replace("/onboarding"); + return; + } setActiveOrganization(organizationId); await refresh(); router.replace("/dashboard"); @@ -110,7 +119,7 @@ function AcceptInviteCard() { setAccepting(true); try { const result = await api.acceptInvite(token); - await finish(result.organization_id); + await finish(result.organization_id, result.kind); } catch (err) { setError(err instanceof ApiError ? err.message : "پذیرش دعوت‌نامه ناموفق بود."); } finally { @@ -125,11 +134,12 @@ function AcceptInviteCard() { setError(null); setAccepting(true); try { - const result = await api.acceptInvite(token, password); + const result = await api.acceptInvite(token, password, email.trim() || undefined); // Sign in with the password just chosen so the invitee lands inside the - // app instead of being bounced back to the login screen. - await login(preview.email, password); - await finish(result.organization_id); + // app instead of being bounced back to the login screen. A platform invite + // that pinned no email gets the address the holder typed here. + await login(email.trim() || preview.email, password); + await finish(result.organization_id, result.kind); } catch (err) { setError(err instanceof ApiError ? err.message : "ساخت حساب ناموفق بود."); } finally { @@ -193,16 +203,40 @@ function AcceptInviteCard() { if (!preview) return null; const roleLabel = ROLE_LABELS[preview.role] ?? preview.role; + const isPlatform = preview.kind === "platform"; const signedInWithInvitedEmail = user !== null && user.email.trim().toLowerCase() === preview.email.trim().toLowerCase(); + // A platform invite that pinned no email is a bearer link: the holder supplies + // the address they want to sign in with, and the form asks for it. + const needsEmail = isPlatform && !preview.email && user === null; return ( - دعوت به {preview.organization_name} + + {isPlatform ? "ساخت حساب مدیر" : `دعوت به ${preview.organization_name}`} + - برای {preview.email} با نقش {roleLabel} دعوت - شده‌اید. + {isPlatform ? ( + <> + به سامانه دعوت شده‌اید + {preview.email ? ( + <> + {" "} + با ایمیل {preview.email} + + ) : ( + "" + )}{" "} + و پس از ساخت حساب {roleLabel} خواهید بود؛ سپس می‌توانید برای خودتان + یک سازمان و اپلیکیشن بسازید. + + ) : ( + <> + برای {preview.email} با نقش {roleLabel} دعوت + شده‌اید. + + )}
@@ -220,6 +254,18 @@ function AcceptInviteCard() { // Brand-new invitee: the link creates the account. The password they // choose here becomes their login credential.
+ {needsEmail && ( + setEmail(event.target.value)} + hint="این ایمیل نام کاربری ورود شما خواهد بود." + /> + )} { + if (!ready || !user) return; + if (organizations.length > 0) router.replace("/dashboard"); + }, [ready, user, organizations, router]); + const onSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(null); diff --git a/apps/web/src/lib/api-org.ts b/apps/web/src/lib/api-org.ts index 7a19232..4edfb48 100644 --- a/apps/web/src/lib/api-org.ts +++ b/apps/web/src/lib/api-org.ts @@ -3,8 +3,79 @@ * * Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport. */ + +import type { + IssuedPlatformInvite, + PlatformInvite, +} from "@agy/contracts"; import { apiRequest } from "./api"; + +/** The platform owner's account invitations (split into organizations of the future). */ export const orgApi = { - // example: list: (orgId: string) => apiRequest(`/api/organizations/${orgId}/foo`), -} satisfies Record ReturnType>; + /** Create an invitation link for a new manager account. */ + issuePlatformInvite: (body: { email?: string; note?: string } = {}) => + apiRequest("/api/admin/platform-invites", { + method: "POST", + body, + }), + + /** The invitations that still hold a valid, unredeemed link. */ + listPlatformInvites: () => + apiRequest("/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(`/api/organizations/${orgId}/leave`, { method: "POST" }), + + revokeOrgInvite: (orgId: string, inviteId: string) => + apiRequest( + `/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 { + return (await apiRequest<{ needs_onboarding: boolean }>("/api/auth/me")).needs_onboarding; +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 59e1b03..9208b1e 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -181,10 +181,10 @@ export const api = { apiRequest( `/api/invites/${encodeURIComponent(token)}`, ), - acceptInvite: (token: string, password?: string) => + acceptInvite: (token: string, password?: string, email?: string) => apiRequest("/api/invites/accept", { method: "POST", - body: password ? { token, password } : { token }, + body: { token, password, email }, }), updateMember: ( orgId: string, diff --git a/apps/web/src/test/accept-invite.test.tsx b/apps/web/src/test/accept-invite.test.tsx index c053523..611ad70 100644 --- a/apps/web/src/test/accept-invite.test.tsx +++ b/apps/web/src/test/accept-invite.test.tsx @@ -117,7 +117,9 @@ describe("accept-invite", () => { fireEvent.click(screen.getByRole("button", { name: "ساخت حساب و پیوستن" })); await waitFor(() => - expect(mocks.acceptInvite).toHaveBeenCalledWith("tok-123", "password123"), + // The third argument is the email a *platform* invite that pinned none + // needs; an organization invite never uses it, so it is undefined here. + expect(mocks.acceptInvite).toHaveBeenCalledWith("tok-123", "password123", undefined), ); // The password just chosen becomes the login credential, so the invitee // lands inside the app rather than back at the login screen. diff --git a/apps/web/src/test/org-sharing.test.tsx b/apps/web/src/test/org-sharing.test.tsx new file mode 100644 index 0000000..c1f77e8 --- /dev/null +++ b/apps/web/src/test/org-sharing.test.tsx @@ -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 }) => ( + {children} + ), +})); + +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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/docs/plan/reports/agent-1-phase-1.md b/docs/plan/reports/agent-1-phase-1.md new file mode 100644 index 0000000..d2d7e18 --- /dev/null +++ b/docs/plan/reports/agent-1-phase-1.md @@ -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. diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 1572d0a..75e872e 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -221,8 +221,16 @@ export interface Invite { 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 { + kind: "platform" | "organization"; organization_id: string; organization_name: string; email: string; @@ -235,9 +243,12 @@ export interface InvitePreview { * in first; setting a password anonymously is only for brand-new accounts. */ has_account: boolean; + /** For a platform invite: the role the redeemed account will receive. */ + platform_role: PlatformRole; } export interface AcceptInviteResult { + kind: "platform" | "organization"; organization_id: string; organization_name: string; role: Role; @@ -252,6 +263,29 @@ export interface TokenResponse { export interface MeResponse { user: User; 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 {