لینک دعوت هم کلیکپذیر نبود و هم کار نمیکرد؛ چهار نقص همزمان:
- `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 تمیز.
234 lines
7.0 KiB
TypeScript
234 lines
7.0 KiB
TypeScript
"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>
|
|
);
|
|
}
|