رفع فلو دعوت عضو: لینک کلیک‌پذیر و صفحهٔ پذیرش دعوت

لینک دعوت هم کلیک‌پذیر نبود و هم کار نمی‌کرد؛ چهار نقص هم‌زمان:

- `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:
mjalmoudy94
2026-10-13 17:47:26 +03:30
parent da5a2a5d1a
commit e56f105db3
13 changed files with 815 additions and 12 deletions
+2
View File
@@ -18,6 +18,8 @@ class AuditAction:
ORG_CREATE = "organization.create"
ORG_DELETE = "organization.delete"
MEMBER_INVITE = "member.invite"
#: An invited user redeemed their token and joined the organization.
MEMBER_JOIN = "member.join"
MEMBER_ROLE_CHANGE = "member.role_change"
MEMBER_REMOVE = "member.remove"
EMPLOYEE_CREATE = "employee.create"
@@ -6,7 +6,7 @@ the domain layer never sees SQLAlchemy.
from __future__ import annotations
from datetime import datetime
from datetime import UTC, datetime
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -27,6 +27,20 @@ from app.infrastructure.db.models import (
)
def _aware(value: datetime) -> datetime:
"""Re-attach UTC to a timestamp read from the database.
SQLite drops ``tzinfo`` on round-trip while Postgres keeps it, so comparing
a freshly computed aware timestamp (``Invite.is_expired`` against
``utcnow()``) against one read back raises ``TypeError`` on one backend and
works on the other. Normalising at the read boundary keeps every consumer —
domain rules and API output alike — in a single frame.
"""
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value
def _user_to_domain(row: UserRow) -> User:
return User(
id=row.id,
@@ -67,8 +81,8 @@ def _invite_to_domain(row: InviteRow) -> Invite:
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,
expires_at=_aware(row.expires_at),
accepted_at=_aware(row.accepted_at) if row.accepted_at else None,
created_at=row.created_at,
)
@@ -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}",
)
+2
View File
@@ -21,6 +21,7 @@ from app.interfaces.http.routers import (
auth,
billing,
health,
invites,
knowledge,
organizations,
projects,
@@ -115,6 +116,7 @@ def create_app() -> FastAPI:
application.include_router(health.router)
application.include_router(auth.router, prefix="/api")
application.include_router(invites.router, prefix="/api")
application.include_router(organizations.router, prefix="/api")
application.include_router(projects.router, prefix="/api")
application.include_router(workforce.router, prefix="/api")
+122
View File
@@ -0,0 +1,122 @@
"""Invite redemption: the link an admin hands out must actually work.
Covers the full loop — create the invite, preview it as the invitee, redeem it,
and the three ways redemption can legitimately fail. Also pins the absolute
invite URL, since a bare path is not clickable for the person receiving it.
"""
from __future__ import annotations
from httpx import AsyncClient
from tests.conftest import auth_header, signup
async def _owner_with_org(client: AsyncClient, name: str = "Acme") -> tuple[str, str]:
"""``(token, org_id)`` for a freshly signed-up owner."""
body = await signup(client, "owner@example.com", name="Owner")
headers = auth_header(body["access_token"])
org = await client.post("/api/organizations", json={"name": name}, headers=headers)
assert org.status_code == 201, org.text
return body["access_token"], org.json()["id"]
async def _invite(client: AsyncClient, token: str, org_id: str, email: str, role: str) -> dict:
response = await client.post(
f"/api/organizations/{org_id}/invites",
json={"email": email, "role": role},
headers=auth_header(token),
)
assert response.status_code == 201, response.text
return response.json()
async def test_invite_url_is_absolute_and_points_at_the_accept_screen(client: AsyncClient):
token, org_id = await _owner_with_org(client)
invite = await _invite(client, token, org_id, "newbie@example.com", "member")
# Relative would resolve against whatever origin the link is pasted on.
assert invite["invite_url"].startswith("http")
assert invite["invite_url"].endswith(f"/accept-invite?token={invite['invite_token']}")
async def test_invitee_can_preview_then_accept(client: AsyncClient):
owner_token, org_id = await _owner_with_org(client)
invite = await _invite(client, owner_token, org_id, "newbie@example.com", "manager")
raw = invite["invite_token"]
invitee = await signup(client, "newbie@example.com", name="Newbie")
headers = auth_header(invitee["access_token"])
preview = await client.get(f"/api/invites/{raw}", headers=headers)
assert preview.status_code == 200, preview.text
assert preview.json()["organization_name"] == "Acme"
assert preview.json()["role"] == "manager"
assert preview.json()["already_member"] is False
accepted = await client.post("/api/invites/accept", json={"token": raw}, headers=headers)
assert accepted.status_code == 200, accepted.text
assert accepted.json()["organization_id"] == org_id
me = await client.get("/api/auth/me", headers=headers)
joined = [o for o in me.json()["organizations"] if o["id"] == org_id]
assert joined and joined[0]["role"] == "manager"
async def test_invite_token_is_single_use(client: AsyncClient):
owner_token, org_id = await _owner_with_org(client)
raw = (await _invite(client, owner_token, org_id, "newbie@example.com", "member"))[
"invite_token"
]
invitee = await signup(client, "newbie@example.com")
headers = auth_header(invitee["access_token"])
first = await client.post("/api/invites/accept", json={"token": raw}, headers=headers)
assert first.status_code == 200
second = await client.post("/api/invites/accept", json={"token": raw}, headers=headers)
assert second.status_code == 409
assert second.json()["error"]["code"] == "conflict"
async def test_invite_cannot_be_redeemed_by_a_different_account(client: AsyncClient):
owner_token, org_id = await _owner_with_org(client)
raw = (await _invite(client, owner_token, org_id, "newbie@example.com", "member"))[
"invite_token"
]
intruder = await signup(client, "intruder@example.com")
headers = auth_header(intruder["access_token"])
response = await client.get(f"/api/invites/{raw}", headers=headers)
assert response.status_code == 403
assert response.json()["error"]["details"]["invite_email"] == "newbie@example.com"
# The token is not burned by the failed attempt — the real invitee can
# still use it.
invitee = await signup(client, "newbie@example.com")
ok = await client.post(
"/api/invites/accept",
json={"token": raw},
headers=auth_header(invitee["access_token"]),
)
assert ok.status_code == 200
async def test_unknown_invite_token_is_not_found(client: AsyncClient):
user = await signup(client, "nobody@example.com")
response = await client.get(
"/api/invites/definitely-not-a-real-token",
headers=auth_header(user["access_token"]),
)
assert response.status_code == 404
async def test_accepting_an_invite_requires_authentication(client: AsyncClient):
owner_token, org_id = await _owner_with_org(client)
raw = (await _invite(client, owner_token, org_id, "newbie@example.com", "member"))[
"invite_token"
]
response = await client.post("/api/invites/accept", json={"token": raw})
assert response.status_code == 401