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