رفع فلو دعوت عضو: لینک کلیکپذیر و صفحهٔ پذیرش دعوت
لینک دعوت هم کلیکپذیر نبود و هم کار نمیکرد؛ چهار نقص همزمان:
- `invite_url` نسبی بود (`/accept-invite?token=…`) و host نداشت → حالا از
`settings.app_url` آدرس مطلق ساخته میشود.
- صفحهٔ `/accept-invite` در اپ Next وجود نداشت → ۴۰۴.
- هیچ اندپوینت پذیرش دعوت در بکاند نبود؛ `InviteRepository.mark_accepted`
نوشته شده بود ولی هرگز صدا زده نمیشد، پس توکن مصرف نمیشد.
- در تنظیمات، لینک بهصورت متن ساده در `<p>` رندر میشد.
بکاند:
- روتر `app/interfaces/http/routers/invites.py`:
`GET /api/invites/{token}` (پیشنمایش) و `POST /api/invites/accept`.
عمداً زیر `/organizations/{org_id}` نیست: دعوتشونده هنوز عضو نیست و
`get_org_context` ردش میکند، پس خود توکن مجوز است. accept ایدمپوتنت است.
- `AuditAction.MEMBER_JOIN = "member.join"`.
- رفع باگ نهفته: `Invite.is_expired()` بین `expires_at` خواندهشده از SQLite
(naive) و `utcnow()` (aware) خطای مقایسه میداد. طبق قرارداد موجود پروژه
(`_aware` در `adapters/agy.py`)، در `repositories/identity.py` تابع `_aware`
اضافه و روی `expires_at`/`accepted_at` اعمال شد.
فرانت:
- صفحهٔ `app/accept-invite/page.tsx` با پیشنمایش سازمان/نقش و پذیرش؛ پس از
پذیرش `setActiveOrganization` میکند تا در همان سازمان لند کند.
- قراردادهای `InvitePreview`/`AcceptInviteResult` و کلاینت
`previewInvite`/`acceptInvite`.
- `login`/`signup` پارامتر `?next=` را میفهمند (فقط same-origin) تا توکن دعوت
در مسیر احراز هویت گم نشود.
- `settings`: لینک به `<a>` واقعی + دکمهٔ «کپی لینک».
تست: `tests/test_invites.py` (۶) و `src/test/accept-invite.test.tsx` (۵).
بکاند ۲۵۲ سبز + ruff/mypy تمیز؛ فرانت ۶۱ سبز + tsc/eslint تمیز.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"""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, matched to the signed-in user's
|
||||
email, and single-use — see ``_load_pending_invite``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from pydantic import BaseModel
|
||||
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_token
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.identity.models import (
|
||||
Invite,
|
||||
Membership,
|
||||
MembershipStatus,
|
||||
Organization,
|
||||
User,
|
||||
)
|
||||
from app.infrastructure.repositories.identity import (
|
||||
InviteRepository,
|
||||
MembershipRepository,
|
||||
OrganizationRepository,
|
||||
)
|
||||
from app.interfaces.http.deps import client_meta, get_current_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
|
||||
|
||||
|
||||
class AcceptInviteRequest(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
class AcceptInviteResult(BaseModel):
|
||||
organization_id: str
|
||||
organization_name: str
|
||||
role: str
|
||||
|
||||
|
||||
async def _load_pending_invite(
|
||||
token: str, user: User, 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 three real failure modes (already used, expired, wrong
|
||||
account) each need a different response from the person holding the link.
|
||||
"""
|
||||
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 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 = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InvitePreview:
|
||||
"""Show the invitee what they are about to join, without joining yet."""
|
||||
invite, organization = await _load_pending_invite(token, user, session)
|
||||
existing = await MembershipRepository(session).get(organization.id, user.id)
|
||||
return InvitePreview(
|
||||
organization_id=organization.id,
|
||||
organization_name=organization.name,
|
||||
email=invite.email,
|
||||
role=invite.role.value,
|
||||
expires_at=invite.expires_at,
|
||||
already_member=existing 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 = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AcceptInviteResult:
|
||||
"""Redeem an invite: create the membership and burn the token.
|
||||
|
||||
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)
|
||||
|
||||
memberships = MembershipRepository(session)
|
||||
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,
|
||||
)
|
||||
|
||||
# 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,
|
||||
)
|
||||
@@ -14,6 +14,7 @@ from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
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.security import generate_token, hash_token
|
||||
@@ -243,7 +244,11 @@ async def create_invite(
|
||||
role=invite.role.value,
|
||||
expires_at=invite.expires_at,
|
||||
invite_token=raw_token,
|
||||
invite_url=f"/accept-invite?token={raw_token}",
|
||||
# Absolute, built from APP_URL. A bare path is useless to the person
|
||||
# holding the link: pasted on its own it resolves against whatever
|
||||
# origin they happen to be on, and copy/pasted into a message it is not
|
||||
# clickable at all.
|
||||
invite_url=f"{settings.app_url.rstrip('/')}/accept-invite?token={raw_token}",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user