TorbatYar/frontend/app/admin/tenants/[id]/page.tsx
Mortezakoohjani 800b0ba2c5 Deploy TorbatYar for torbatyar.ir with nginx multi-tenant routing.
Wire production domain, CORS for tenant subdomains, celery volume mounts, and nginx reverse proxy configs for apex, API, identity, auth, and wildcard tenants.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 21:43:33 +03:30

493 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { FormEvent, useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import {
api,
ApiError,
type Tenant,
type TenantDomain,
type Subscription,
type Plan,
} from "@/lib/api";
import { getStoredToken } from "@/lib/auth";
import { identityApi, type AdminUser, type TenantMember } from "@/lib/identity";
import {
AdminPageHeader,
Banner,
Button,
Field,
SelectField,
StatusBadge,
TextField,
cardClass,
inputClass,
} from "@/components/admin/controls";
const TENANT_STATUSES = [
"draft",
"pending_activation",
"active",
"suspended",
"archived",
"inactive",
].map((s) => ({ value: s, label: s }));
const SUBSCRIPTION_STATUSES = [
"trialing",
"active",
"past_due",
"canceled",
"expired",
].map((s) => ({ value: s, label: s }));
export default function TenantDetailPage() {
const params = useParams<{ id: string }>();
const tenantId = params.id;
const [tenant, setTenant] = useState<Tenant | null>(null);
const [domains, setDomains] = useState<TenantDomain[]>([]);
const [subscription, setSubscription] = useState<Subscription | null>(null);
const [members, setMembers] = useState<TenantMember[]>([]);
const [plans, setPlans] = useState<Plan[]>([]);
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(async () => {
setLoading(true);
setError("");
const token = getStoredToken();
try {
const t = await api.platformTenants.get(tenantId);
setTenant(t);
} catch (e) {
setError(e instanceof ApiError ? e.message : "دریافت Tenant ناموفق بود");
setLoading(false);
return;
}
const [d, sub, plansPage, mem, usersRes] = await Promise.allSettled([
api.domains.listForTenant(tenantId),
api.subscriptions.get(tenantId),
api.plans.list(1, 100),
token ? identityApi.listMembers(token, tenantId) : Promise.resolve([]),
token ? identityApi.listUsers(token, { limit: 100 }) : Promise.resolve([]),
]);
if (d.status === "fulfilled") setDomains(d.value);
setSubscription(sub.status === "fulfilled" ? sub.value : null);
if (plansPage.status === "fulfilled") setPlans(plansPage.value.items);
if (mem.status === "fulfilled") setMembers(mem.value);
if (usersRes.status === "fulfilled") setUsers(usersRes.value);
setLoading(false);
}, [tenantId]);
useEffect(() => {
void load();
}, [load]);
if (loading) {
return <p className="text-sm text-gray-500">در حال بارگذاری...</p>;
}
if (!tenant) {
return (
<div className="space-y-4">
<Banner variant="error">{error || "Tenant یافت نشد"}</Banner>
<Link href="/admin/tenants" className="text-sm text-primary hover:underline">
بازگشت به لیست
</Link>
</div>
);
}
return (
<div className="space-y-6">
<div>
<Link href="/admin/tenants" className="text-sm text-primary hover:underline">
بازگشت به لیست
</Link>
</div>
<AdminPageHeader
title={tenant.name}
subtitle={tenant.slug}
action={<StatusBadge status={tenant.status} />}
/>
{error && <Banner variant="error">{error}</Banner>}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<TenantEditCard tenant={tenant} onUpdated={setTenant} onError={setError} />
<DomainsCard tenantId={tenantId} domains={domains} onChanged={load} />
<SubscriptionCard
tenantId={tenantId}
subscription={subscription}
plans={plans}
onChanged={load}
/>
<MembersCard tenantId={tenantId} members={members} users={users} onChanged={load} />
</div>
</div>
);
}
function TenantEditCard({
tenant,
onUpdated,
onError,
}: {
tenant: Tenant;
onUpdated: (t: Tenant) => void;
onError: (m: string) => void;
}) {
const [name, setName] = useState(tenant.name);
const [status, setStatus] = useState(tenant.status);
const [ownerUserId, setOwnerUserId] = useState(tenant.owner_user_id || "");
const [saving, setSaving] = useState(false);
const [success, setSuccess] = useState("");
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setSuccess("");
onError("");
setSaving(true);
try {
const updated = await api.platformTenants.update(tenant.id, {
name,
status,
owner_user_id: ownerUserId.trim() || null,
});
onUpdated(updated);
setSuccess("تغییرات ذخیره شد.");
} catch (err) {
onError(err instanceof ApiError ? err.message : "ذخیره ناموفق بود");
} finally {
setSaving(false);
}
};
return (
<form onSubmit={handleSubmit} className={`space-y-4 ${cardClass}`}>
<h2 className="text-lg font-bold text-secondary">اطلاعات پایه</h2>
<TextField label="نام" required value={name} onValue={setName} disabled={saving} />
<SelectField
label="وضعیت"
value={status}
onValue={setStatus}
options={TENANT_STATUSES}
disabled={saving}
/>
<TextField
label="شناسه کاربر مالک"
dir="ltr"
className="font-mono"
value={ownerUserId}
onValue={setOwnerUserId}
disabled={saving}
placeholder="UUID"
/>
{success && <Banner variant="success">{success}</Banner>}
<Button type="submit" loading={saving} loadingText="در حال ذخیره...">
ذخیره تغییرات
</Button>
</form>
);
}
function DomainsCard({
tenantId,
domains,
onChanged,
}: {
tenantId: string;
domains: TenantDomain[];
onChanged: () => void;
}) {
const [domain, setDomain] = useState("");
const [type, setType] = useState<"subdomain" | "custom_domain">("custom_domain");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const handleAdd = async (e: FormEvent) => {
e.preventDefault();
setError("");
if (!domain.trim()) return;
setSaving(true);
try {
await api.domains.create(tenantId, { domain: domain.trim(), domain_type: type });
setDomain("");
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "افزودن دامنه ناموفق بود");
} finally {
setSaving(false);
}
};
return (
<div className={`space-y-4 ${cardClass}`}>
<h2 className="text-lg font-bold text-secondary">دامنهها</h2>
{domains.length === 0 ? (
<p className="text-sm text-gray-500">دامنهای ثبت نشده است.</p>
) : (
<ul className="space-y-2">
{domains.map((d) => (
<li
key={d.id}
className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2 text-sm"
>
<span dir="ltr" className="font-mono text-gray-700">
{d.domain}
</span>
<span className="flex items-center gap-2">
<span className="text-xs text-gray-400">{d.domain_type}</span>
<StatusBadge status={d.is_verified ? "active" : "pending_activation"} />
</span>
</li>
))}
</ul>
)}
{error && <Banner variant="error">{error}</Banner>}
<form onSubmit={handleAdd} className="space-y-3 border-t border-gray-100 pt-4">
<TextField
label="دامنه جدید"
dir="ltr"
className="font-mono"
value={domain}
onValue={setDomain}
disabled={saving}
placeholder="shop.example.com"
/>
<SelectField
label="نوع"
value={type}
onValue={(v) => setType(v as "subdomain" | "custom_domain")}
options={[
{ value: "custom_domain", label: "دامنه اختصاصی" },
{ value: "subdomain", label: "زیردامنه" },
]}
disabled={saving}
/>
<Button type="submit" size="sm" loading={saving} loadingText="در حال افزودن...">
افزودن دامنه
</Button>
</form>
</div>
);
}
function SubscriptionCard({
tenantId,
subscription,
plans,
onChanged,
}: {
tenantId: string;
subscription: Subscription | null;
plans: Plan[];
onChanged: () => void;
}) {
const [planId, setPlanId] = useState(plans[0]?.id || "");
const [status, setStatus] = useState("active");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!planId && plans[0]) setPlanId(plans[0].id);
}, [plans, planId]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
if (!planId) {
setError("ابتدا یک پلن انتخاب کنید.");
return;
}
setSaving(true);
try {
await api.subscriptions.create(tenantId, {
plan_id: planId,
status: status as Subscription["status"],
});
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "ثبت اشتراک ناموفق بود");
} finally {
setSaving(false);
}
};
const planName = subscription
? plans.find((p) => p.id === subscription.plan_id)?.name || subscription.plan_id
: null;
return (
<div className={`space-y-4 ${cardClass}`}>
<h2 className="text-lg font-bold text-secondary">اشتراک</h2>
{subscription ? (
<div className="rounded-xl bg-gray-50 p-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-gray-500">پلن</span>
<span className="font-semibold text-secondary">{planName}</span>
</div>
<div className="mt-2 flex items-center justify-between">
<span className="text-gray-500">وضعیت</span>
<StatusBadge status={subscription.status} />
</div>
</div>
) : (
<p className="text-sm text-gray-500">اشتراک فعالی ثبت نشده است.</p>
)}
{error && <Banner variant="error">{error}</Banner>}
<form onSubmit={handleSubmit} className="space-y-3 border-t border-gray-100 pt-4">
<p className="text-sm font-medium text-secondary">
{subscription ? "تغییر/ثبت اشتراک جدید" : "ثبت اشتراک"}
</p>
{plans.length === 0 ? (
<p className="text-xs text-amber-600">
ابتدا از بخش «پلنها» یک پلن بسازید.
</p>
) : (
<>
<SelectField
label="پلن"
value={planId}
onValue={setPlanId}
options={plans.map((p) => ({ value: p.id, label: `${p.name} (${p.code})` }))}
disabled={saving}
/>
<SelectField
label="وضعیت"
value={status}
onValue={setStatus}
options={SUBSCRIPTION_STATUSES}
disabled={saving}
/>
<Button type="submit" size="sm" loading={saving} loadingText="در حال ثبت...">
ثبت اشتراک
</Button>
</>
)}
</form>
</div>
);
}
function MembersCard({
tenantId,
members,
users,
onChanged,
}: {
tenantId: string;
members: TenantMember[];
users: AdminUser[];
onChanged: () => void;
}) {
const [userId, setUserId] = useState("");
const [role, setRole] = useState<"tenant_admin" | "tenant_member">("tenant_member");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const userLabel = (id: string) => {
const u = users.find((x) => x.id === id);
return u ? u.display_name || u.username || u.mobile || u.email : id;
};
const handleAdd = async (e: FormEvent) => {
e.preventDefault();
setError("");
const token = getStoredToken();
if (!token) {
setError("توکن یافت نشد.");
return;
}
if (!userId) {
setError("یک کاربر انتخاب کنید.");
return;
}
setSaving(true);
try {
await identityApi.addMember(token, tenantId, { user_id: userId, role });
setUserId("");
onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : "افزودن عضو ناموفق بود");
} finally {
setSaving(false);
}
};
return (
<div className={`space-y-4 ${cardClass}`}>
<h2 className="text-lg font-bold text-secondary">اعضا</h2>
{members.length === 0 ? (
<p className="text-sm text-gray-500">عضوی ثبت نشده است.</p>
) : (
<ul className="space-y-2">
{members.map((m) => (
<li
key={m.id}
className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2 text-sm"
>
<span className="text-gray-700">{userLabel(m.user_id)}</span>
<span className="flex items-center gap-2">
<span className="text-xs text-gray-400">
{m.role === "tenant_admin" ? "مدیر" : "عضو"}
</span>
<StatusBadge status={m.is_active ? "active" : "inactive"} />
</span>
</li>
))}
</ul>
)}
{error && <Banner variant="error">{error}</Banner>}
<form onSubmit={handleAdd} className="space-y-3 border-t border-gray-100 pt-4">
<p className="text-sm font-medium text-secondary">افزودن عضو</p>
{users.length === 0 ? (
<p className="text-xs text-amber-600">
کاربری برای افزودن یافت نشد. ابتدا از بخش «کاربران» کاربر بسازید.
</p>
) : (
<>
<Field label="کاربر">
<select
className={inputClass}
value={userId}
onChange={(e) => setUserId(e.target.value)}
disabled={saving}
>
<option value=""> انتخاب کاربر </option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.display_name || u.username || u.mobile || u.email}
</option>
))}
</select>
</Field>
<SelectField
label="نقش"
value={role}
onValue={(v) => setRole(v as "tenant_admin" | "tenant_member")}
options={[
{ value: "tenant_member", label: "عضو" },
{ value: "tenant_admin", label: "مدیر" },
]}
disabled={saving}
/>
<Button type="submit" size="sm" loading={saving} loadingText="در حال افزودن...">
افزودن عضو
</Button>
</>
)}
</form>
</div>
);
}