Files
smart-org/apps/web/src/app/(app)/settings/page.tsx
T
mjalmoudy94 e56f105db3 رفع فلو دعوت عضو: لینک کلیک‌پذیر و صفحهٔ پذیرش دعوت
لینک دعوت هم کلیک‌پذیر نبود و هم کار نمی‌کرد؛ چهار نقص هم‌زمان:

- `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 تمیز.
2026-10-13 17:47:26 +03:30

425 lines
16 KiB
TypeScript

"use client";
import * as React from "react";
import type { AuditEvent, Invite, Member, Role } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
ConfirmDialog,
EmptyState,
ErrorState,
Field,
LoadingState,
Select,
Timeline,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import {
CAN_MANAGE_MEMBERS,
CAN_VIEW_AUDIT,
roleHasCapability,
} from "@/lib/permissions";
import { useSession } from "@/lib/session";
const ROLE_LABELS: Record<Role, string> = {
owner: "مالک",
admin: "مدیر",
manager: "سرپرست",
member: "عضو",
viewer: "بیننده",
};
const AUDIT_LABELS: Record<string, string> = {
"auth.login": "ورود به حساب",
"auth.logout": "خروج از حساب",
"auth.signup": "ثبت‌نام",
"organization.create": "ساخت سازمان",
"organization.delete": "حذف سازمان",
"member.invite": "دعوت عضو",
"member.join": "پیوستن عضو",
"member.role_change": "تغییر نقش",
"member.remove": "حذف عضو",
"employee.create": "ساخت کارمند",
"employee.update": "ویرایش کارمند",
"repo.create": "ساخت مخزن",
"repo.delete": "حذف مخزن",
"domain.create": "ساخت دامنه",
"domain.delete": "حذف دامنه",
"sandbox.command": "اجرای دستور سندباکس",
"sandbox.destroy": "نابودی سندباکس",
"approval.decision": "تصمیم تأیید",
"billing.credit": "تغییر اعتبار",
"billing.payment": "پرداخت",
"provider.change": "تغییر ارائه‌دهنده",
"model.change": "تغییر مدل",
"routing.change": "تغییر مسیریابی",
"project.create": "ساخت پروژه",
"project.delete": "حذف پروژه",
"task.create": "ساخت کار",
"run.cancel": "لغو اجرا",
};
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;
const [tab, setTab] = React.useState<Tab>("members");
const [members, setMembers] = React.useState<Member[]>([]);
const [audit, setAudit] = React.useState<AuditEvent[]>([]);
const [loading, setLoading] = React.useState(true);
const [auditLoading, setAuditLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [email, setEmail] = React.useState("");
const [inviteRole, setInviteRole] = React.useState<Role>("member");
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);
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
setMembers(await api.listMembers(orgId));
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت اعضا ناموفق بود.");
} finally {
setLoading(false);
}
}, [orgId]);
const loadAudit = React.useCallback(async () => {
if (!orgId) return;
setAuditLoading(true);
try {
setAudit(await api.listAudit(orgId));
} catch (err) {
setError(err instanceof ApiError ? err.message : "دریافت رویدادهای حسابرسی ناموفق بود.");
} finally {
setAuditLoading(false);
}
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
React.useEffect(() => {
if (tab === "audit") void loadAudit();
}, [tab, loadAudit]);
const onInvite = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId) return;
setInviteError(null);
setLastInvite(null);
setCopied(false);
setInviting(true);
try {
const invite = await api.createInvite(orgId, { email: email.trim(), role: inviteRole });
setLastInvite(invite);
setEmail("");
} catch (err) {
setInviteError(err instanceof ApiError ? err.message : "ارسال دعوت ناموفق بود.");
} finally {
setInviting(false);
}
};
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);
setError(null);
try {
const updated = await api.updateMember(orgId, member.id, { role });
setMembers((current) => current.map((m) => (m.id === updated.id ? updated : m)));
} catch (err) {
setError(err instanceof ApiError ? err.message : "تغییر نقش ناموفق بود.");
} finally {
setBusyMemberId(null);
}
};
const onConfirmRemove = async () => {
if (!orgId || !pendingRemove) return;
setBusyMemberId(pendingRemove.id);
try {
await api.removeMember(orgId, pendingRemove.id);
setMembers((current) => current.filter((m) => m.id !== pendingRemove.id));
setPendingRemove(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "حذف عضو ناموفق بود.");
setPendingRemove(null);
} finally {
setBusyMemberId(null);
}
};
if (!activeOrganization) {
return (
<EmptyState
title="سازمانی انتخاب نشده است"
description="برای مشاهدهٔ تنظیمات، ابتدا یک سازمان بسازید."
/>
);
}
const canManageMembers = roleHasCapability(activeOrganization.role, CAN_MANAGE_MEMBERS);
const canViewAudit = roleHasCapability(activeOrganization.role, CAN_VIEW_AUDIT);
return (
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">تنظیمات سازمان</h1>
<p className="text-sm text-slate-400">{activeOrganization.name}</p>
</header>
<div className="flex gap-1 border-b border-surface-border">
{(
[
["members", "اعضا و دعوت"],
["audit", "رویدادهای حسابرسی"],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => setTab(value)}
className={
"border-b-2 px-3 py-2 text-sm transition-colors " +
(tab === value
? "border-accent text-slate-100"
: "border-transparent text-slate-400 hover:text-slate-200")
}
>
{label}
</button>
))}
</div>
{error && <ErrorState message={error} onRetry={load} />}
{tab === "members" ? (
<>
<Card>
<CardTitle>اعضای سازمان</CardTitle>
<CardHint>
نقش‌ها به ترتیب قدرت: مالک، مدیر، سرپرست، عضو، بیننده. آخرین مالک قابل حذف نیست.
</CardHint>
<div className="mt-4">
{loading ? (
<LoadingState label="در حال دریافت اعضا…" />
) : members.length === 0 ? (
<EmptyState title="عضوی یافت نشد" />
) : (
<ul className="flex flex-col divide-y divide-surface-border">
{members.map((member) => (
<li key={member.id} className="flex flex-wrap items-center justify-between gap-3 py-3">
<div className="flex flex-col">
<span className="text-sm text-slate-200">
{member.name || member.email}
</span>
<span className="ltr-inline text-xs text-slate-500">{member.email}</span>
</div>
<div className="flex items-center gap-2">
<Badge tone={member.status === "active" ? "success" : "warning"}>
{member.status}
</Badge>
{canManageMembers ? (
<>
<div className="w-36">
<Select
label=""
value={member.role}
disabled={busyMemberId === member.id}
onChange={(e) => void onChangeRole(member, e.target.value as Role)}
options={Object.entries(ROLE_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</div>
<Button
size="sm"
variant="danger"
disabled={member.role === "owner"}
title={
member.role === "owner"
? "مالک سازمان حذف نمی‌شود."
: undefined
}
onClick={() => setPendingRemove(member)}
>
حذف
</Button>
</>
) : (
<Badge tone="neutral">{ROLE_LABELS[member.role] ?? member.role}</Badge>
)}
</div>
</li>
))}
</ul>
)}
</div>
</Card>
{canManageMembers && (
<Card>
<CardTitle>دعوت عضو جدید</CardTitle>
<CardHint>
یک لینک دعوت یک‌بارمصرف ساخته می‌شود. لینک را خودتان برای فرد بفرستید — سامانه
ایمیل ارسال نمی‌کند.
</CardHint>
<form onSubmit={onInvite} className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="flex-1">
<Field
label="ایمیل"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
className="ltr-inline"
/>
</div>
<div className="w-full sm:w-40">
<Select
label="نقش"
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as Role)}
options={Object.entries(ROLE_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</div>
<Button type="submit" loading={inviting}>
ساخت دعوت
</Button>
</form>
{inviteError && (
<div className="mt-3">
<ErrorState title="دعوت ناموفق" message={inviteError} />
</div>
)}
{lastInvite && (
<div className="mt-3 rounded-lg border border-surface-border bg-surface-overlay p-3">
<p className="text-xs text-slate-400">
دعوت برای <span className="ltr-inline">{lastInvite.email}</span> ساخته شد.
</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>
)}
</>
) : (
<Card>
<CardTitle>رویدادهای حسابرسی</CardTitle>
<CardHint>
این فهرست فقط-افزودنی است. هر عملیات حساس — ورود، تغییر نقش، ساخت کارمند، تغییر
اعتبار و تصمیم تأیید — اینجا ثبت می‌شود.
</CardHint>
<div className="mt-4">
{!canViewAudit ? (
<p className="text-xs text-slate-500">
برای دیدن رویدادهای حسابرسی به سطح دسترسی مدیر یا بالاتر نیاز دارید.
</p>
) : auditLoading ? (
<LoadingState label="در حال دریافت رویدادها…" />
) : audit.length === 0 ? (
<p className="text-xs text-slate-500">رویدادی ثبت نشده است.</p>
) : (
<Timeline
items={audit.slice(0, 60).map((event) => ({
id: event.id,
title: AUDIT_LABELS[event.action] ?? event.action,
meta: `${event.target_type || "—"} · ${new Date(event.created_at).toLocaleString("fa-IR")}`,
tone:
event.action.includes("delete") || event.action.includes("remove")
? ("danger" as const)
: event.action.includes("create") || event.action.includes("payment")
? ("success" as const)
: ("info" as const),
}))}
/>
)}
</div>
</Card>
)}
<ConfirmDialog
open={pendingRemove !== null}
title="حذف عضو"
description={
pendingRemove
? `دسترسی «${pendingRemove.name || pendingRemove.email}» به این سازمان قطع می‌شود.`
: undefined
}
confirmLabel={busyMemberId ? "در حال حذف…" : "حذف کن"}
onConfirm={onConfirmRemove}
onCancel={() => setPendingRemove(null)}
/>
</div>
);
}