From e56f105db3dc2cfdccdce9feab47533f70781225 Mon Sep 17 00:00:00 2001 From: mjalmoudy94 Date: Tue, 13 Oct 2026 17:47:26 +0330 Subject: [PATCH] =?UTF-8?q?=D8=B1=D9=81=D8=B9=20=D9=81=D9=84=D9=88=20?= =?UTF-8?q?=D8=AF=D8=B9=D9=88=D8=AA=20=D8=B9=D8=B6=D9=88:=20=D9=84=DB=8C?= =?UTF-8?q?=D9=86=DA=A9=20=DA=A9=D9=84=DB=8C=DA=A9=E2=80=8C=D9=BE=D8=B0?= =?UTF-8?q?=DB=8C=D8=B1=20=D9=88=20=D8=B5=D9=81=D8=AD=D9=87=D9=94=20=D9=BE?= =?UTF-8?q?=D8=B0=DB=8C=D8=B1=D8=B4=20=D8=AF=D8=B9=D9=88=D8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit لینک دعوت هم کلیک‌پذیر نبود و هم کار نمی‌کرد؛ چهار نقص هم‌زمان: - `invite_url` نسبی بود (`/accept-invite?token=…`) و host نداشت → حالا از `settings.app_url` آدرس مطلق ساخته می‌شود. - صفحهٔ `/accept-invite` در اپ Next وجود نداشت → ۴۰۴. - هیچ اندپوینت پذیرش دعوت در بک‌اند نبود؛ `InviteRepository.mark_accepted` نوشته شده بود ولی هرگز صدا زده نمی‌شد، پس توکن مصرف نمی‌شد. - در تنظیمات، لینک به‌صورت متن ساده در `

` رندر می‌شد. بک‌اند: - روتر `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`: لینک به `` واقعی + دکمهٔ «کپی لینک». تست: `tests/test_invites.py` (۶) و `src/test/accept-invite.test.tsx` (۵). بک‌اند ۲۵۲ سبز + ruff/mypy تمیز؛ فرانت ۶۱ سبز + tsc/eslint تمیز. --- apps/api/app/domain/audit/models.py | 2 + .../infrastructure/repositories/identity.py | 20 +- .../app/interfaces/http/routers/invites.py | 157 ++++++++++++ .../interfaces/http/routers/organizations.py | 7 +- apps/api/app/main.py | 2 + apps/api/tests/test_invites.py | 122 +++++++++ apps/web/src/app/(app)/settings/page.tsx | 51 +++- apps/web/src/app/accept-invite/page.tsx | 233 ++++++++++++++++++ apps/web/src/app/login/page.tsx | 27 +- apps/web/src/app/signup/page.tsx | 25 +- apps/web/src/lib/api.ts | 11 + apps/web/src/test/accept-invite.test.tsx | 153 ++++++++++++ packages/contracts/src/index.ts | 17 ++ 13 files changed, 815 insertions(+), 12 deletions(-) create mode 100644 apps/api/app/interfaces/http/routers/invites.py create mode 100644 apps/api/tests/test_invites.py create mode 100644 apps/web/src/app/accept-invite/page.tsx create mode 100644 apps/web/src/test/accept-invite.test.tsx diff --git a/apps/api/app/domain/audit/models.py b/apps/api/app/domain/audit/models.py index a0c140f..41ceba8 100644 --- a/apps/api/app/domain/audit/models.py +++ b/apps/api/app/domain/audit/models.py @@ -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" diff --git a/apps/api/app/infrastructure/repositories/identity.py b/apps/api/app/infrastructure/repositories/identity.py index b8ab950..a1d9599 100644 --- a/apps/api/app/infrastructure/repositories/identity.py +++ b/apps/api/app/infrastructure/repositories/identity.py @@ -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, ) diff --git a/apps/api/app/interfaces/http/routers/invites.py b/apps/api/app/interfaces/http/routers/invites.py new file mode 100644 index 0000000..335738e --- /dev/null +++ b/apps/api/app/interfaces/http/routers/invites.py @@ -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, + ) diff --git a/apps/api/app/interfaces/http/routers/organizations.py b/apps/api/app/interfaces/http/routers/organizations.py index 3132f1c..1f9338a 100644 --- a/apps/api/app/interfaces/http/routers/organizations.py +++ b/apps/api/app/interfaces/http/routers/organizations.py @@ -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}", ) diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 94fe661..24671c5 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -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") diff --git a/apps/api/tests/test_invites.py b/apps/api/tests/test_invites.py new file mode 100644 index 0000000..022a8ac --- /dev/null +++ b/apps/api/tests/test_invites.py @@ -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 diff --git a/apps/web/src/app/(app)/settings/page.tsx b/apps/web/src/app/(app)/settings/page.tsx index 4777055..b94e28b 100644 --- a/apps/web/src/app/(app)/settings/page.tsx +++ b/apps/web/src/app/(app)/settings/page.tsx @@ -39,6 +39,7 @@ const AUDIT_LABELS: Record = { "organization.create": "ساخت سازمان", "organization.delete": "حذف سازمان", "member.invite": "دعوت عضو", + "member.join": "پیوستن عضو", "member.role_change": "تغییر نقش", "member.remove": "حذف عضو", "employee.create": "ساخت کارمند", @@ -63,6 +64,20 @@ const AUDIT_LABELS: Record = { 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() { const { activeOrganization } = useSession(); const orgId = activeOrganization?.id ?? null; @@ -79,6 +94,7 @@ export default function SettingsPage() { const [inviting, setInviting] = React.useState(false); const [inviteError, setInviteError] = React.useState(null); const [lastInvite, setLastInvite] = React.useState(null); + const [copied, setCopied] = React.useState(false); const [busyMemberId, setBusyMemberId] = React.useState(null); const [pendingRemove, setPendingRemove] = React.useState(null); @@ -124,6 +140,7 @@ export default function SettingsPage() { if (!orgId) return; setInviteError(null); setLastInvite(null); + setCopied(false); setInviting(true); try { 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) => { if (!orgId) return; setBusyMemberId(member.id); @@ -319,9 +349,24 @@ export default function SettingsPage() {

دعوت برای {lastInvite.email} ساخته شد.

-

- {lastInvite.invite_url} -

+
+ {absoluteInviteUrl(lastInvite.invite_url)} + +
+ + + لینک یک‌بارمصرف است و تا {INVITE_TTL_DAYS} روز اعتبار دارد. + +
)} diff --git a/apps/web/src/app/accept-invite/page.tsx b/apps/web/src/app/accept-invite/page.tsx new file mode 100644 index 0000000..3469702 --- /dev/null +++ b/apps/web/src/app/accept-invite/page.tsx @@ -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 = { + owner: "مالک", + admin: "مدیر", + manager: "سرپرست", + member: "عضو", + viewer: "بیننده", +}; + +function Centered({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +function AcceptInviteCard() { + const router = useRouter(); + const params = useSearchParams(); + const token = params.get("token") ?? ""; + const { ready, user, refresh, setActiveOrganization } = useSession(); + + const [preview, setPreview] = React.useState(null); + const [error, setError] = React.useState(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 ( + + + + + + ); + } + + if (!token) { + return ( + + + لینک دعوت ناقص است + + توکن دعوت در آدرس وجود ندارد. لینک کامل را از فرستنده دوباره بگیرید. + +
+ + بازگشت به داشبورد + +
+
+
+ ); + } + + if (!user) { + return ( + + + دعوت به یک سازمان + + برای پذیرش این دعوت باید وارد شوید. اگر هنوز حساب ندارید، با همان ایمیلی که + دعوت‌نامه برایش ارسال شده ثبت‌نام کنید. + +
+ + + + + + +
+
+
+ ); + } + + if (loading) { + return ( + + + + + + ); + } + + if (error || !preview) { + return ( + + + +
+ + بازگشت به داشبورد + +
+
+
+ ); + } + + return ( + + + دعوت به {preview.organization_name} + + برای {preview.email} با نقش{" "} + {ROLE_LABELS[preview.role] ?? preview.role} دعوت شده‌اید. + + +
+ {ROLE_LABELS[preview.role] ?? preview.role} + {preview.already_member && شما از قبل عضو هستید} +
+ +
+ +
+ +

+ با حساب دیگری وارد شده‌اید؟{" "} + + تعویض حساب + +

+
+
+ ); +} + +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 ( + + + + + + } + > + + + ); +} diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx index 76d95d4..3bc305b 100644 --- a/apps/web/src/app/login/page.tsx +++ b/apps/web/src/app/login/page.tsx @@ -7,6 +7,17 @@ import { Button, Card, CardHint, CardTitle, ErrorState, Field } from "@agy/ui"; import { ApiError } from "@/lib/api"; 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() { const router = useRouter(); const { login, user, ready } = useSession(); @@ -14,9 +25,16 @@ export default function LoginPage() { const [password, setPassword] = React.useState(""); const [error, setError] = React.useState(null); 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(null); 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]); const onSubmit = async (event: React.FormEvent) => { @@ -25,7 +43,7 @@ export default function LoginPage() { setSubmitting(true); try { await login(email, password); - router.replace("/dashboard"); + router.replace(nextDestination("/dashboard")); } catch (err) { setError(err instanceof ApiError ? err.message : "ورود ناموفق بود."); } finally { @@ -68,7 +86,10 @@ export default function LoginPage() {

حساب ندارید؟{" "} - + ثبت‌نام کنید

diff --git a/apps/web/src/app/signup/page.tsx b/apps/web/src/app/signup/page.tsx index 7d1cd9b..97a01c1 100644 --- a/apps/web/src/app/signup/page.tsx +++ b/apps/web/src/app/signup/page.tsx @@ -7,6 +7,17 @@ import { Button, Card, CardHint, CardTitle, ErrorState, Field } from "@agy/ui"; import { ApiError } from "@/lib/api"; 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() { const router = useRouter(); const { signup } = useSession(); @@ -15,6 +26,13 @@ export default function SignupPage() { const [password, setPassword] = React.useState(""); const [error, setError] = React.useState(null); const [submitting, setSubmitting] = React.useState(false); + // Read once on mount so the "already registered?" link carries the invite token. + const [nextPath, setNextPath] = React.useState(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) => { event.preventDefault(); @@ -22,7 +40,7 @@ export default function SignupPage() { setSubmitting(true); try { await signup(email, password, name); - router.replace("/onboarding"); + router.replace(nextDestination("/onboarding")); } catch (err) { setError(err instanceof ApiError ? err.message : "ثبت‌نام ناموفق بود."); } finally { @@ -73,7 +91,10 @@ export default function SignupPage() {

قبلاً ثبت‌نام کرده‌اید؟{" "} - + ورود

diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 56ee745..9a3a215 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -134,6 +134,17 @@ export const api = { method: "POST", 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( + `/api/invites/${encodeURIComponent(token)}`, + ), + acceptInvite: (token: string) => + apiRequest("/api/invites/accept", { + method: "POST", + body: { token }, + }), updateMember: ( orgId: string, memberId: string, diff --git a/apps/web/src/test/accept-invite.test.tsx b/apps/web/src/test/accept-invite.test.tsx new file mode 100644 index 0000000..fce5e37 --- /dev/null +++ b/apps/web/src/test/accept-invite.test.tsx @@ -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; + }) => ( + + {children} + + ), +})); + +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(); + + 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(); + + 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(); + + expect(await screen.findByText("دعوت‌نامه قابل استفاده نیست")).toBeInTheDocument(); + expect(screen.getByText(/برای ایمیل دیگری/)).toBeInTheDocument(); + }); + + it("reports a missing token instead of calling the API", () => { + mocks.token = ""; + render(); + + 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(); + 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")); + }); +}); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index eca6513..262973d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -180,6 +180,23 @@ export interface Invite { 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 { access_token: string; token_type: string;