رفع فلو دعوت عضو: لینک کلیکپذیر و صفحهٔ پذیرش دعوت
لینک دعوت هم کلیکپذیر نبود و هم کار نمیکرد؛ چهار نقص همزمان:
- `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:
@@ -18,6 +18,8 @@ class AuditAction:
|
|||||||
ORG_CREATE = "organization.create"
|
ORG_CREATE = "organization.create"
|
||||||
ORG_DELETE = "organization.delete"
|
ORG_DELETE = "organization.delete"
|
||||||
MEMBER_INVITE = "member.invite"
|
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_ROLE_CHANGE = "member.role_change"
|
||||||
MEMBER_REMOVE = "member.remove"
|
MEMBER_REMOVE = "member.remove"
|
||||||
EMPLOYEE_CREATE = "employee.create"
|
EMPLOYEE_CREATE = "employee.create"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ the domain layer never sees SQLAlchemy.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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:
|
def _user_to_domain(row: UserRow) -> User:
|
||||||
return User(
|
return User(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
@@ -67,8 +81,8 @@ def _invite_to_domain(row: InviteRow) -> Invite:
|
|||||||
role=Role(row.role),
|
role=Role(row.role),
|
||||||
token_hash=row.token_hash,
|
token_hash=row.token_hash,
|
||||||
invited_by_user_id=row.invited_by_user_id,
|
invited_by_user_id=row.invited_by_user_id,
|
||||||
expires_at=row.expires_at,
|
expires_at=_aware(row.expires_at),
|
||||||
accepted_at=row.accepted_at,
|
accepted_at=_aware(row.accepted_at) if row.accepted_at else None,
|
||||||
created_at=row.created_at,
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.application.services.audit_service import record_audit
|
from app.application.services.audit_service import record_audit
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
|
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
|
||||||
from app.core.security import generate_token, hash_token
|
from app.core.security import generate_token, hash_token
|
||||||
@@ -243,7 +244,11 @@ async def create_invite(
|
|||||||
role=invite.role.value,
|
role=invite.role.value,
|
||||||
expires_at=invite.expires_at,
|
expires_at=invite.expires_at,
|
||||||
invite_token=raw_token,
|
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}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.interfaces.http.routers import (
|
|||||||
auth,
|
auth,
|
||||||
billing,
|
billing,
|
||||||
health,
|
health,
|
||||||
|
invites,
|
||||||
knowledge,
|
knowledge,
|
||||||
organizations,
|
organizations,
|
||||||
projects,
|
projects,
|
||||||
@@ -115,6 +116,7 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
application.include_router(health.router)
|
application.include_router(health.router)
|
||||||
application.include_router(auth.router, prefix="/api")
|
application.include_router(auth.router, prefix="/api")
|
||||||
|
application.include_router(invites.router, prefix="/api")
|
||||||
application.include_router(organizations.router, prefix="/api")
|
application.include_router(organizations.router, prefix="/api")
|
||||||
application.include_router(projects.router, prefix="/api")
|
application.include_router(projects.router, prefix="/api")
|
||||||
application.include_router(workforce.router, prefix="/api")
|
application.include_router(workforce.router, prefix="/api")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -39,6 +39,7 @@ const AUDIT_LABELS: Record<string, string> = {
|
|||||||
"organization.create": "ساخت سازمان",
|
"organization.create": "ساخت سازمان",
|
||||||
"organization.delete": "حذف سازمان",
|
"organization.delete": "حذف سازمان",
|
||||||
"member.invite": "دعوت عضو",
|
"member.invite": "دعوت عضو",
|
||||||
|
"member.join": "پیوستن عضو",
|
||||||
"member.role_change": "تغییر نقش",
|
"member.role_change": "تغییر نقش",
|
||||||
"member.remove": "حذف عضو",
|
"member.remove": "حذف عضو",
|
||||||
"employee.create": "ساخت کارمند",
|
"employee.create": "ساخت کارمند",
|
||||||
@@ -63,6 +64,20 @@ const AUDIT_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
type Tab = "members" | "audit";
|
type Tab = "members" | "audit";
|
||||||
|
|
||||||
|
/** Matches ``INVITE_TTL_DAYS`` in the API so the hint cannot drift. */
|
||||||
|
const INVITE_TTL_DAYS = 14;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The API returns an absolute invite URL. Older invites (created before the API
|
||||||
|
* started building one) carry a bare path, so fall back to the current origin —
|
||||||
|
* that keeps the anchor clickable either way.
|
||||||
|
*/
|
||||||
|
function absoluteInviteUrl(url: string): string {
|
||||||
|
if (/^https?:\/\//i.test(url)) return url;
|
||||||
|
if (typeof window === "undefined") return url;
|
||||||
|
return `${window.location.origin}${url.startsWith("/") ? url : `/${url}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { activeOrganization } = useSession();
|
const { activeOrganization } = useSession();
|
||||||
const orgId = activeOrganization?.id ?? null;
|
const orgId = activeOrganization?.id ?? null;
|
||||||
@@ -79,6 +94,7 @@ export default function SettingsPage() {
|
|||||||
const [inviting, setInviting] = React.useState(false);
|
const [inviting, setInviting] = React.useState(false);
|
||||||
const [inviteError, setInviteError] = React.useState<string | null>(null);
|
const [inviteError, setInviteError] = React.useState<string | null>(null);
|
||||||
const [lastInvite, setLastInvite] = React.useState<Invite | null>(null);
|
const [lastInvite, setLastInvite] = React.useState<Invite | null>(null);
|
||||||
|
const [copied, setCopied] = React.useState(false);
|
||||||
|
|
||||||
const [busyMemberId, setBusyMemberId] = React.useState<string | null>(null);
|
const [busyMemberId, setBusyMemberId] = React.useState<string | null>(null);
|
||||||
const [pendingRemove, setPendingRemove] = React.useState<Member | null>(null);
|
const [pendingRemove, setPendingRemove] = React.useState<Member | null>(null);
|
||||||
@@ -124,6 +140,7 @@ export default function SettingsPage() {
|
|||||||
if (!orgId) return;
|
if (!orgId) return;
|
||||||
setInviteError(null);
|
setInviteError(null);
|
||||||
setLastInvite(null);
|
setLastInvite(null);
|
||||||
|
setCopied(false);
|
||||||
setInviting(true);
|
setInviting(true);
|
||||||
try {
|
try {
|
||||||
const invite = await api.createInvite(orgId, { email: email.trim(), role: inviteRole });
|
const invite = await api.createInvite(orgId, { email: email.trim(), role: inviteRole });
|
||||||
@@ -136,6 +153,19 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const copyInviteLink = async (url: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// Clipboard can be blocked (insecure context, denied permission). The
|
||||||
|
// link is rendered as a real anchor above, so the user can still copy it
|
||||||
|
// by hand — no need to surface an error for this.
|
||||||
|
setCopied(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onChangeRole = async (member: Member, role: Role) => {
|
const onChangeRole = async (member: Member, role: Role) => {
|
||||||
if (!orgId) return;
|
if (!orgId) return;
|
||||||
setBusyMemberId(member.id);
|
setBusyMemberId(member.id);
|
||||||
@@ -319,9 +349,24 @@ export default function SettingsPage() {
|
|||||||
<p className="text-xs text-slate-400">
|
<p className="text-xs text-slate-400">
|
||||||
دعوت برای <span className="ltr-inline">{lastInvite.email}</span> ساخته شد.
|
دعوت برای <span className="ltr-inline">{lastInvite.email}</span> ساخته شد.
|
||||||
</p>
|
</p>
|
||||||
<p className="ltr-inline mt-1 break-all text-xs text-accent">
|
<a
|
||||||
{lastInvite.invite_url}
|
href={absoluteInviteUrl(lastInvite.invite_url)}
|
||||||
</p>
|
className="ltr-inline mt-1 block break-all text-xs text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{absoluteInviteUrl(lastInvite.invite_url)}
|
||||||
|
</a>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void copyInviteLink(absoluteInviteUrl(lastInvite.invite_url))}
|
||||||
|
>
|
||||||
|
{copied ? "کپی شد" : "کپی لینک"}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
لینک یکبارمصرف است و تا {INVITE_TTL_DAYS} روز اعتبار دارد.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invite redemption screen.
|
||||||
|
*
|
||||||
|
* Lives outside the ``(app)`` group on purpose: the invitee is not a member of
|
||||||
|
* anything yet, so the authenticated shell would bounce them to /login before
|
||||||
|
* they could accept. Here we render a standalone card, and only ask for a
|
||||||
|
* session when there is actually an invite to redeem.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import * as React from "react";
|
||||||
|
import type { InvitePreview } from "@agy/contracts";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardHint,
|
||||||
|
CardTitle,
|
||||||
|
ErrorState,
|
||||||
|
LoadingState,
|
||||||
|
} from "@agy/ui";
|
||||||
|
import { ApiError, api } from "@/lib/api";
|
||||||
|
import { useSession } from "@/lib/session";
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
owner: "مالک",
|
||||||
|
admin: "مدیر",
|
||||||
|
manager: "سرپرست",
|
||||||
|
member: "عضو",
|
||||||
|
viewer: "بیننده",
|
||||||
|
};
|
||||||
|
|
||||||
|
function Centered({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-5">
|
||||||
|
<div className="w-full max-w-sm">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AcceptInviteCard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useSearchParams();
|
||||||
|
const token = params.get("token") ?? "";
|
||||||
|
const { ready, user, refresh, setActiveOrganization } = useSession();
|
||||||
|
|
||||||
|
const [preview, setPreview] = React.useState<InvitePreview | null>(null);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [accepting, setAccepting] = React.useState(false);
|
||||||
|
|
||||||
|
// The invitee may not have an account yet; after they sign in or sign up we
|
||||||
|
// send them straight back here so the token is not lost.
|
||||||
|
const nextPath = `/accept-invite?token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!ready) return;
|
||||||
|
if (!token || !user) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
api
|
||||||
|
.previewInvite(token)
|
||||||
|
.then((result) => {
|
||||||
|
if (!cancelled) setPreview(result);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "دریافت اطلاعات دعوتنامه ناموفق بود.");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [ready, token, user]);
|
||||||
|
|
||||||
|
const onAccept = async () => {
|
||||||
|
setError(null);
|
||||||
|
setAccepting(true);
|
||||||
|
try {
|
||||||
|
const result = await api.acceptInvite(token);
|
||||||
|
// Land the invitee inside the organization they just joined rather than
|
||||||
|
// whichever tenant happened to be active before.
|
||||||
|
setActiveOrganization(result.organization_id);
|
||||||
|
await refresh();
|
||||||
|
router.replace("/dashboard");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "پذیرش دعوتنامه ناموفق بود.");
|
||||||
|
} finally {
|
||||||
|
setAccepting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<Centered>
|
||||||
|
<Card className="anim-rise">
|
||||||
|
<LoadingState label="در حال آمادهسازی نشست…" />
|
||||||
|
</Card>
|
||||||
|
</Centered>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<Centered>
|
||||||
|
<Card className="anim-rise">
|
||||||
|
<CardTitle>لینک دعوت ناقص است</CardTitle>
|
||||||
|
<CardHint>
|
||||||
|
توکن دعوت در آدرس وجود ندارد. لینک کامل را از فرستنده دوباره بگیرید.
|
||||||
|
</CardHint>
|
||||||
|
<div className="mt-5">
|
||||||
|
<Link href="/dashboard" className="text-xs text-accent hover:underline">
|
||||||
|
بازگشت به داشبورد
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Centered>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<Centered>
|
||||||
|
<Card className="anim-rise">
|
||||||
|
<CardTitle>دعوت به یک سازمان</CardTitle>
|
||||||
|
<CardHint>
|
||||||
|
برای پذیرش این دعوت باید وارد شوید. اگر هنوز حساب ندارید، با همان ایمیلی که
|
||||||
|
دعوتنامه برایش ارسال شده ثبتنام کنید.
|
||||||
|
</CardHint>
|
||||||
|
<div className="mt-5 flex gap-2">
|
||||||
|
<Link href={`/login?next=${encodeURIComponent(nextPath)}`} className="flex-1">
|
||||||
|
<Button className="w-full">ورود</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href={`/signup?next=${encodeURIComponent(nextPath)}`} className="flex-1">
|
||||||
|
<Button variant="secondary" className="w-full">
|
||||||
|
ثبتنام
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Centered>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Centered>
|
||||||
|
<Card className="anim-rise">
|
||||||
|
<LoadingState label="در حال بررسی دعوتنامه…" />
|
||||||
|
</Card>
|
||||||
|
</Centered>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !preview) {
|
||||||
|
return (
|
||||||
|
<Centered>
|
||||||
|
<Card className="anim-rise">
|
||||||
|
<ErrorState
|
||||||
|
title="دعوتنامه قابل استفاده نیست"
|
||||||
|
message={error ?? "اطلاعات دعوتنامه در دسترس نیست."}
|
||||||
|
/>
|
||||||
|
<div className="mt-5">
|
||||||
|
<Link href="/dashboard" className="text-xs text-accent hover:underline">
|
||||||
|
بازگشت به داشبورد
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Centered>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Centered>
|
||||||
|
<Card className="anim-rise">
|
||||||
|
<CardTitle>دعوت به {preview.organization_name}</CardTitle>
|
||||||
|
<CardHint>
|
||||||
|
برای <span className="ltr-inline">{preview.email}</span> با نقش{" "}
|
||||||
|
{ROLE_LABELS[preview.role] ?? preview.role} دعوت شدهاید.
|
||||||
|
</CardHint>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
<Badge tone="info">{ROLE_LABELS[preview.role] ?? preview.role}</Badge>
|
||||||
|
{preview.already_member && <Badge tone="neutral">شما از قبل عضو هستید</Badge>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<Button className="w-full" loading={accepting} onClick={onAccept}>
|
||||||
|
{preview.already_member ? "ورود به سازمان" : "پذیرش دعوت"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-4 text-xs text-slate-400">
|
||||||
|
با حساب دیگری وارد شدهاید؟{" "}
|
||||||
|
<Link
|
||||||
|
href={`/login?next=${encodeURIComponent(nextPath)}`}
|
||||||
|
className="text-accent hover:underline"
|
||||||
|
>
|
||||||
|
تعویض حساب
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</Centered>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AcceptInvitePage() {
|
||||||
|
// `useSearchParams` forces the route to be client-rendered; Next requires a
|
||||||
|
// Suspense boundary around it so the static shell can still be emitted.
|
||||||
|
return (
|
||||||
|
<React.Suspense
|
||||||
|
fallback={
|
||||||
|
<Centered>
|
||||||
|
<Card>
|
||||||
|
<LoadingState label="در حال بارگذاری…" />
|
||||||
|
</Card>
|
||||||
|
</Centered>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AcceptInviteCard />
|
||||||
|
</React.Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,17 @@ import { Button, Card, CardHint, CardTitle, ErrorState, Field } from "@agy/ui";
|
|||||||
import { ApiError } from "@/lib/api";
|
import { ApiError } from "@/lib/api";
|
||||||
import { useSession } from "@/lib/session";
|
import { useSession } from "@/lib/session";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where to land after authenticating. Only same-origin paths are honoured, so a
|
||||||
|
* crafted `?next=https://evil.example` cannot turn the login screen into an
|
||||||
|
* open redirect.
|
||||||
|
*/
|
||||||
|
function nextDestination(fallback: string): string {
|
||||||
|
if (typeof window === "undefined") return fallback;
|
||||||
|
const next = new URLSearchParams(window.location.search).get("next");
|
||||||
|
return next && next.startsWith("/") && !next.startsWith("//") ? next : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { login, user, ready } = useSession();
|
const { login, user, ready } = useSession();
|
||||||
@@ -14,9 +25,16 @@ export default function LoginPage() {
|
|||||||
const [password, setPassword] = React.useState("");
|
const [password, setPassword] = React.useState("");
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [submitting, setSubmitting] = React.useState(false);
|
const [submitting, setSubmitting] = React.useState(false);
|
||||||
|
// Read once on mount so the "no account?" link carries the invite token along.
|
||||||
|
const [nextPath, setNextPath] = React.useState<string | null>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (ready && user) router.replace("/dashboard");
|
const next = new URLSearchParams(window.location.search).get("next");
|
||||||
|
setNextPath(next && next.startsWith("/") && !next.startsWith("//") ? next : null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (ready && user) router.replace(nextDestination("/dashboard"));
|
||||||
}, [ready, user, router]);
|
}, [ready, user, router]);
|
||||||
|
|
||||||
const onSubmit = async (event: React.FormEvent) => {
|
const onSubmit = async (event: React.FormEvent) => {
|
||||||
@@ -25,7 +43,7 @@ export default function LoginPage() {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
router.replace("/dashboard");
|
router.replace(nextDestination("/dashboard"));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : "ورود ناموفق بود.");
|
setError(err instanceof ApiError ? err.message : "ورود ناموفق بود.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -68,7 +86,10 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<p className="mt-4 text-xs text-slate-400">
|
<p className="mt-4 text-xs text-slate-400">
|
||||||
حساب ندارید؟{" "}
|
حساب ندارید؟{" "}
|
||||||
<Link href="/signup" className="text-accent hover:underline">
|
<Link
|
||||||
|
href={nextPath ? `/signup?next=${encodeURIComponent(nextPath)}` : "/signup"}
|
||||||
|
className="text-accent hover:underline"
|
||||||
|
>
|
||||||
ثبتنام کنید
|
ثبتنام کنید
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ import { Button, Card, CardHint, CardTitle, ErrorState, Field } from "@agy/ui";
|
|||||||
import { ApiError } from "@/lib/api";
|
import { ApiError } from "@/lib/api";
|
||||||
import { useSession } from "@/lib/session";
|
import { useSession } from "@/lib/session";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where to land after signing up. Only same-origin paths are honoured, so a
|
||||||
|
* crafted `?next=https://evil.example` cannot turn the form into an open
|
||||||
|
* redirect.
|
||||||
|
*/
|
||||||
|
function nextDestination(fallback: string): string {
|
||||||
|
if (typeof window === "undefined") return fallback;
|
||||||
|
const next = new URLSearchParams(window.location.search).get("next");
|
||||||
|
return next && next.startsWith("/") && !next.startsWith("//") ? next : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { signup } = useSession();
|
const { signup } = useSession();
|
||||||
@@ -15,6 +26,13 @@ export default function SignupPage() {
|
|||||||
const [password, setPassword] = React.useState("");
|
const [password, setPassword] = React.useState("");
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [submitting, setSubmitting] = React.useState(false);
|
const [submitting, setSubmitting] = React.useState(false);
|
||||||
|
// Read once on mount so the "already registered?" link carries the invite token.
|
||||||
|
const [nextPath, setNextPath] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const next = new URLSearchParams(window.location.search).get("next");
|
||||||
|
setNextPath(next && next.startsWith("/") && !next.startsWith("//") ? next : null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onSubmit = async (event: React.FormEvent) => {
|
const onSubmit = async (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -22,7 +40,7 @@ export default function SignupPage() {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await signup(email, password, name);
|
await signup(email, password, name);
|
||||||
router.replace("/onboarding");
|
router.replace(nextDestination("/onboarding"));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : "ثبتنام ناموفق بود.");
|
setError(err instanceof ApiError ? err.message : "ثبتنام ناموفق بود.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -73,7 +91,10 @@ export default function SignupPage() {
|
|||||||
|
|
||||||
<p className="mt-4 text-xs text-slate-400">
|
<p className="mt-4 text-xs text-slate-400">
|
||||||
قبلاً ثبتنام کردهاید؟{" "}
|
قبلاً ثبتنام کردهاید؟{" "}
|
||||||
<Link href="/login" className="text-accent hover:underline">
|
<Link
|
||||||
|
href={nextPath ? `/login?next=${encodeURIComponent(nextPath)}` : "/login"}
|
||||||
|
className="text-accent hover:underline"
|
||||||
|
>
|
||||||
ورود
|
ورود
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -134,6 +134,17 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body,
|
body,
|
||||||
}),
|
}),
|
||||||
|
// Redeeming an invite is intentionally *not* org-scoped: the invitee is not a
|
||||||
|
// member yet, so the token is what authorizes the call.
|
||||||
|
previewInvite: (token: string) =>
|
||||||
|
apiRequest<import("@agy/contracts").InvitePreview>(
|
||||||
|
`/api/invites/${encodeURIComponent(token)}`,
|
||||||
|
),
|
||||||
|
acceptInvite: (token: string) =>
|
||||||
|
apiRequest<import("@agy/contracts").AcceptInviteResult>("/api/invites/accept", {
|
||||||
|
method: "POST",
|
||||||
|
body: { token },
|
||||||
|
}),
|
||||||
updateMember: (
|
updateMember: (
|
||||||
orgId: string,
|
orgId: string,
|
||||||
memberId: string,
|
memberId: string,
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* The accept-invite screen is the only place an invited user can turn a link
|
||||||
|
* into a membership, so its states matter: missing token, signed out, preview
|
||||||
|
* failure, and the happy path.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => {
|
||||||
|
class FakeApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly code: string,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
FakeApiError,
|
||||||
|
session: {
|
||||||
|
ready: true,
|
||||||
|
user: null as { id: string; email: string } | null,
|
||||||
|
refresh: vi.fn(async () => {}),
|
||||||
|
setActiveOrganization: vi.fn(),
|
||||||
|
},
|
||||||
|
replace: vi.fn(),
|
||||||
|
previewInvite: vi.fn(),
|
||||||
|
acceptInvite: vi.fn(),
|
||||||
|
token: "tok-123",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useSearchParams: () => new URLSearchParams(`token=${mocks.token}`),
|
||||||
|
useRouter: () => ({ replace: mocks.replace }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/link", () => ({
|
||||||
|
default: ({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<a href={href} className={className}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api", () => ({
|
||||||
|
ApiError: mocks.FakeApiError,
|
||||||
|
api: {
|
||||||
|
previewInvite: mocks.previewInvite,
|
||||||
|
acceptInvite: mocks.acceptInvite,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AcceptInvitePage from "@/app/accept-invite/page";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.token = "tok-123";
|
||||||
|
mocks.session.ready = true;
|
||||||
|
mocks.session.user = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("accept-invite", () => {
|
||||||
|
it("asks a signed-out visitor to authenticate and keeps the token in the link", () => {
|
||||||
|
render(<AcceptInvitePage />);
|
||||||
|
|
||||||
|
expect(screen.getByText("دعوت به یک سازمان")).toBeInTheDocument();
|
||||||
|
const login = screen.getByRole("link", { name: "ورود" });
|
||||||
|
// Losing the token here would strand the invitee on the dashboard.
|
||||||
|
expect(decodeURIComponent(login.getAttribute("href") ?? "")).toBe(
|
||||||
|
"/login?next=/accept-invite?token=tok-123",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the organization and role to the signed-in invitee", async () => {
|
||||||
|
mocks.session.user = { id: "u1", email: "newbie@example.com" };
|
||||||
|
mocks.previewInvite.mockResolvedValue({
|
||||||
|
organization_id: "org-1",
|
||||||
|
organization_name: "Acme",
|
||||||
|
email: "newbie@example.com",
|
||||||
|
role: "manager",
|
||||||
|
expires_at: "2026-01-01T00:00:00Z",
|
||||||
|
already_member: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AcceptInvitePage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("دعوت به Acme")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "پذیرش دعوت" })).toBeInTheDocument();
|
||||||
|
expect(mocks.previewInvite).toHaveBeenCalledWith("tok-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces the server's reason when the invite is unusable", async () => {
|
||||||
|
mocks.session.user = { id: "u1", email: "newbie@example.com" };
|
||||||
|
mocks.previewInvite.mockRejectedValue(
|
||||||
|
new mocks.FakeApiError(
|
||||||
|
403,
|
||||||
|
"permission_denied",
|
||||||
|
"این دعوتنامه برای ایمیل دیگری صادر شده است.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<AcceptInvitePage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("دعوتنامه قابل استفاده نیست")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/برای ایمیل دیگری/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing token instead of calling the API", () => {
|
||||||
|
mocks.token = "";
|
||||||
|
render(<AcceptInvitePage />);
|
||||||
|
|
||||||
|
expect(screen.getByText("لینک دعوت ناقص است")).toBeInTheDocument();
|
||||||
|
expect(mocks.previewInvite).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activates the joined organization and redirects after accepting", async () => {
|
||||||
|
mocks.session.user = { id: "u1", email: "newbie@example.com" };
|
||||||
|
mocks.previewInvite.mockResolvedValue({
|
||||||
|
organization_id: "org-1",
|
||||||
|
organization_name: "Acme",
|
||||||
|
email: "newbie@example.com",
|
||||||
|
role: "member",
|
||||||
|
expires_at: "2026-01-01T00:00:00Z",
|
||||||
|
already_member: false,
|
||||||
|
});
|
||||||
|
mocks.acceptInvite.mockResolvedValue({
|
||||||
|
organization_id: "org-1",
|
||||||
|
organization_name: "Acme",
|
||||||
|
role: "member",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AcceptInvitePage />);
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "پذیرش دعوت" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.acceptInvite).toHaveBeenCalledWith("tok-123"));
|
||||||
|
// Landing in the new tenant, not whichever one was active before.
|
||||||
|
await waitFor(() => expect(mocks.session.setActiveOrganization).toHaveBeenCalledWith("org-1"));
|
||||||
|
await waitFor(() => expect(mocks.replace).toHaveBeenCalledWith("/dashboard"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -180,6 +180,23 @@ export interface Invite {
|
|||||||
invite_url: string;
|
invite_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the accept-invite screen shows before the invitee commits. */
|
||||||
|
export interface InvitePreview {
|
||||||
|
organization_id: string;
|
||||||
|
organization_name: string;
|
||||||
|
email: string;
|
||||||
|
role: Role;
|
||||||
|
expires_at: string;
|
||||||
|
/** True when the signed-in user is already a member of that organization. */
|
||||||
|
already_member: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcceptInviteResult {
|
||||||
|
organization_id: string;
|
||||||
|
organization_name: string;
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TokenResponse {
|
export interface TokenResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user