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>
493 lines
15 KiB
TypeScript
493 lines
15 KiB
TypeScript
"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>
|
||
);
|
||
}
|