نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
"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.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";
|
||||
|
||||
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 [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);
|
||||
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 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="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>
|
||||
<p className="ltr-inline mt-1 break-all text-xs text-accent">
|
||||
{lastInvite.invite_url}
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user