رفع فلو دعوت عضو: لینک کلیکپذیر و صفحهٔ پذیرش دعوت
لینک دعوت هم کلیکپذیر نبود و هم کار نمیکرد؛ چهار نقص همزمان:
- `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:
@@ -39,6 +39,7 @@ const AUDIT_LABELS: Record<string, string> = {
|
||||
"organization.create": "ساخت سازمان",
|
||||
"organization.delete": "حذف سازمان",
|
||||
"member.invite": "دعوت عضو",
|
||||
"member.join": "پیوستن عضو",
|
||||
"member.role_change": "تغییر نقش",
|
||||
"member.remove": "حذف عضو",
|
||||
"employee.create": "ساخت کارمند",
|
||||
@@ -63,6 +64,20 @@ const AUDIT_LABELS: Record<string, string> = {
|
||||
|
||||
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<string | 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 [pendingRemove, setPendingRemove] = React.useState<Member | null>(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() {
|
||||
<p className="text-xs text-slate-400">
|
||||
دعوت برای <span className="ltr-inline">{lastInvite.email}</span> ساخته شد.
|
||||
</p>
|
||||
<p className="ltr-inline mt-1 break-all text-xs text-accent">
|
||||
{lastInvite.invite_url}
|
||||
</p>
|
||||
<a
|
||||
href={absoluteInviteUrl(lastInvite.invite_url)}
|
||||
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>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user