- users.is_platform_owner → users.platform_role (owner|manager|user) با مهاجرت d4e5f6a7b8c9 و backfill. ایندکس یکتای جزئی uq_single_platform_owner تضمین میکند دقیقاً یک مالک وجود داشته باشد — در سطح دیتابیس، نه فقط در کد. - ثبتنام عمومی حذف شد (POST /auth/signup). حسابها فقط با POST /admin/users (فقط مالک، با رمز موقت یکبارمصرف) یا با پذیرش لینک دعوت ساخته میشوند. در مسیر دعوت، اگر حساب وجود نداشته باشد خودِ دعوتشده رمز تعیین میکند؛ اگر وجود داشته باشد لینک هرگز جای reset رمز را نمیگیرد. - ساخت سازمان فقط برای مالک و مدیران؛ کاربر عادی با دعوت عضو میشود. - تنزل یا حذف مدیر، سازمانهایش را به مالک سامانه منتقل میکند (مالکیت + عضویت، با audit). حساب مالک از API تغییرناپذیر است؛ جابهجایی صندلی فقط با POST /admin/transfer-ownership و در یک تراکنش انجام میشود. - افزودن POST /auth/change-password برای تبدیل رمز موقت به رمز خصوصی. - فرانت: صفحهٔ /admin/users، صفحهٔ /account، بازنویسی /accept-invite، حذف /signup، گارد /onboarding برای کاربر عادی، و نشان نقش پلتفرم در پوستهٔ اپ. - PRAGMA foreign_keys=ON برای SQLite تا ON DELETE CASCADE در dev و prod یکسان رفتار کند (پیشتر حذف کاربر، عضویتهایش را یتیم میگذاشت). - تست: ۳۰۰ تست بکاند (شامل tests/test_platform_hierarchy.py با ۲۲ تست) و ۶۴ تست فرانتاند؛ ruff، mypy، tsc، eslint و build تمیز.
232 lines
8.9 KiB
Python
232 lines
8.9 KiB
Python
"""Invite redemption endpoints.
|
|
|
|
Deliberately *not* nested under ``/organizations/{org_id}``: the person
|
|
accepting an invite is by definition not yet a member, so ``get_org_context``
|
|
would (correctly) refuse them. Here the invite token itself is the
|
|
authorization. It is stored only as a hash and is single-use.
|
|
|
|
Because signup is closed (accounts come from the owner or from an invite), the
|
|
token doubles as an account-creation capability: a brand-new invitee redeems
|
|
it **with a password**, which creates their account bound to the invited email
|
|
and joins the organization in one step. The token can never reset the password
|
|
of an *existing* account — those must authenticate first, which keeps a leaked
|
|
link from taking over an account that already exists.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
from fastapi import APIRouter, Depends, Request, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.application.services.audit_service import record_audit
|
|
from app.core.database import get_session
|
|
from app.core.errors import (
|
|
ConflictError,
|
|
NotFoundError,
|
|
PermissionDeniedError,
|
|
ValidationError,
|
|
)
|
|
from app.core.security import hash_password, hash_token
|
|
from app.domain.audit.models import AuditAction
|
|
from app.domain.identity.models import (
|
|
Invite,
|
|
Membership,
|
|
MembershipStatus,
|
|
Organization,
|
|
PlatformRole,
|
|
User,
|
|
)
|
|
from app.infrastructure.repositories.identity import (
|
|
InviteRepository,
|
|
MembershipRepository,
|
|
OrganizationRepository,
|
|
UserRepository,
|
|
)
|
|
from app.interfaces.http.deps import client_meta, get_optional_user
|
|
|
|
router = APIRouter(prefix="/invites", tags=["invites"])
|
|
|
|
|
|
class InvitePreview(BaseModel):
|
|
organization_id: str
|
|
organization_name: str
|
|
email: str
|
|
role: str
|
|
expires_at: datetime
|
|
#: True when the caller is already a member, so the UI can say "open it"
|
|
#: instead of "join".
|
|
already_member: bool
|
|
#: 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
|
|
|
|
|
|
class AcceptInviteRequest(BaseModel):
|
|
token: str
|
|
#: 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.
|
|
password: str | None = Field(default=None, min_length=8, max_length=128)
|
|
|
|
|
|
class AcceptInviteResult(BaseModel):
|
|
organization_id: str
|
|
organization_name: str
|
|
role: str
|
|
|
|
|
|
async def _load_pending_invite(
|
|
token: str, user: User | None, session: AsyncSession
|
|
) -> tuple[Invite, Organization]:
|
|
"""Resolve a raw token into a redeemable invite, or raise.
|
|
|
|
Every rejection is a distinct, user-actionable error rather than a generic
|
|
"invalid link": the real failure modes (already used, expired, wrong
|
|
account) each need a different response from the person holding the link.
|
|
|
|
The email binding is only enforced when a user is signed in: an anonymous
|
|
holder of the token sees the preview but must create an account for exactly
|
|
the invited email, which the accept path checks.
|
|
"""
|
|
invite = await InviteRepository(session).get_by_token_hash(hash_token(token))
|
|
if invite is None:
|
|
raise NotFoundError("این لینک دعوت معتبر نیست.")
|
|
if invite.accepted_at is not None:
|
|
raise ConflictError("این دعوتنامه قبلاً پذیرافته شده است.")
|
|
if invite.is_expired():
|
|
raise ValidationError("این دعوتنامه منقضی شده است؛ از مدیر بخواهید دوباره دعوت کند.")
|
|
if user is not None and invite.email.strip().lower() != user.email.strip().lower():
|
|
raise PermissionDeniedError(
|
|
"این دعوتنامه برای ایمیل دیگری صادر شده است؛ با همان ایمیل وارد شوید.",
|
|
details={"invite_email": invite.email},
|
|
)
|
|
|
|
organization = await OrganizationRepository(session).get(invite.organization_id)
|
|
if organization is None:
|
|
raise NotFoundError("سازمان این دعوتنامه دیگر وجود ندارد.")
|
|
return invite, organization
|
|
|
|
|
|
@router.get("/{token}", response_model=InvitePreview)
|
|
async def preview_invite(
|
|
token: str,
|
|
user: User | None = Depends(get_optional_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> InvitePreview:
|
|
"""Show the invitee what they are about to join, without joining yet.
|
|
|
|
Works without a session on purpose: a brand-new invitee must be able to
|
|
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)
|
|
membership = (
|
|
await MembershipRepository(session).get(organization.id, user.id)
|
|
if user is not None
|
|
else None
|
|
)
|
|
return InvitePreview(
|
|
organization_id=organization.id,
|
|
organization_name=organization.name,
|
|
email=invite.email,
|
|
role=invite.role.value,
|
|
expires_at=invite.expires_at,
|
|
already_member=membership is not None,
|
|
has_account=existing_user is not None,
|
|
)
|
|
|
|
|
|
@router.post("/accept", response_model=AcceptInviteResult, status_code=status.HTTP_200_OK)
|
|
async def accept_invite(
|
|
payload: AcceptInviteRequest,
|
|
request: Request,
|
|
user: User | None = Depends(get_optional_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> AcceptInviteResult:
|
|
"""Redeem an invite: create the account if needed, join, and burn the token.
|
|
|
|
Two redemption paths, one outcome:
|
|
|
|
- **Signed in** — the account already exists and has proven ownership of
|
|
the invited email; no password is needed.
|
|
- **Anonymous** — the invite itself is the capability: a *new* account is
|
|
created for the invited email with the supplied password and joins the
|
|
organization. If an account for that email already exists, the caller
|
|
must authenticate first — a link can never become a password reset.
|
|
|
|
Idempotent by construction — accepting twice is a no-op the second time,
|
|
so a double-click or a retried request cannot create two memberships.
|
|
"""
|
|
invite, organization = await _load_pending_invite(payload.token, user, session)
|
|
users = UserRepository(session)
|
|
memberships = MembershipRepository(session)
|
|
|
|
created_account = False
|
|
if user is None:
|
|
existing = await users.get_by_email(invite.email)
|
|
if existing is not None:
|
|
raise PermissionDeniedError(
|
|
"برای این ایمیل قبلاً حساب ساخته شده است؛ وارد شوید و سپس دعوت را بپذیرید."
|
|
)
|
|
if not payload.password:
|
|
raise ValidationError("برای ساخت حساب جدید، تعیین رمز عبور الزامی است.")
|
|
user = await users.create(
|
|
User(
|
|
email=invite.email,
|
|
password_hash=hash_password(payload.password),
|
|
name="",
|
|
platform_role=PlatformRole.USER,
|
|
)
|
|
)
|
|
created_account = True
|
|
|
|
if await memberships.get(organization.id, user.id) is None:
|
|
await memberships.add(
|
|
Membership(
|
|
organization_id=organization.id,
|
|
user_id=user.id,
|
|
role=invite.role,
|
|
status=MembershipStatus.ACTIVE,
|
|
)
|
|
)
|
|
ip, ua = client_meta(request)
|
|
await record_audit(
|
|
session,
|
|
action=AuditAction.MEMBER_JOIN,
|
|
organization_id=organization.id,
|
|
actor_user_id=user.id,
|
|
target_type="invite",
|
|
target_id=invite.id,
|
|
metadata={"email": invite.email, "role": invite.role.value},
|
|
ip=ip,
|
|
user_agent=ua,
|
|
)
|
|
if created_account:
|
|
# Account creation is audited separately from the join: one is a
|
|
# platform identity event, the other a tenant event.
|
|
ip, ua = client_meta(request)
|
|
await record_audit(
|
|
session,
|
|
action=AuditAction.SIGNUP,
|
|
actor_user_id=user.id,
|
|
target_type="user",
|
|
target_id=user.id,
|
|
metadata={"via": "invite", "organization_id": organization.id},
|
|
ip=ip,
|
|
user_agent=ua,
|
|
)
|
|
|
|
# Burn the token regardless: whether the membership was just created or
|
|
# already existed, this invite must not be redeemable a second time.
|
|
await InviteRepository(session).mark_accepted(invite.id, datetime.now(UTC))
|
|
return AcceptInviteResult(
|
|
organization_id=organization.id,
|
|
organization_name=organization.name,
|
|
role=invite.role.value,
|
|
)
|