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

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

- `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
+233
View File
@@ -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>
);
}