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>
114 lines
3.5 KiB
TypeScript
114 lines
3.5 KiB
TypeScript
"use client";
|
||
|
||
import { FormEvent, useState } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import { api, ApiError } from "@/lib/api";
|
||
import { AdminPageHeader, Banner, Button, TextField, cardClass } from "@/components/admin/controls";
|
||
|
||
function slugify(value: string): string {
|
||
return value
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/\s+/g, "-")
|
||
.replace(/[^a-z0-9-]/g, "")
|
||
.replace(/-+/g, "-")
|
||
.replace(/^-|-$/g, "");
|
||
}
|
||
|
||
export default function NewTenantPage() {
|
||
const router = useRouter();
|
||
const [name, setName] = useState("");
|
||
const [slug, setSlug] = useState("");
|
||
const [ownerUserId, setOwnerUserId] = useState("");
|
||
const [customDomain, setCustomDomain] = useState("");
|
||
const [slugTouched, setSlugTouched] = useState(false);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
|
||
const handleNameChange = (value: string) => {
|
||
setName(value);
|
||
if (!slugTouched) setSlug(slugify(value));
|
||
};
|
||
|
||
const handleSubmit = async (e: FormEvent) => {
|
||
e.preventDefault();
|
||
setError("");
|
||
setLoading(true);
|
||
let keepLoading = false;
|
||
try {
|
||
await api.platformTenants.create({
|
||
name,
|
||
slug,
|
||
owner_user_id: ownerUserId.trim() || null,
|
||
custom_domain: customDomain.trim() || null,
|
||
});
|
||
keepLoading = true;
|
||
router.push("/admin/tenants");
|
||
} catch (err) {
|
||
setError(err instanceof ApiError ? err.message : "ایجاد Tenant ناموفق بود");
|
||
} finally {
|
||
if (!keepLoading) setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<Link href="/admin/tenants" className="text-sm text-primary hover:underline">
|
||
← بازگشت به لیست
|
||
</Link>
|
||
</div>
|
||
<AdminPageHeader title="ایجاد Tenant" subtitle="ساخت workspace جدید در پلتفرم" />
|
||
|
||
{error && <Banner variant="error">{error}</Banner>}
|
||
|
||
<form onSubmit={handleSubmit} className={`mx-auto max-w-lg space-y-4 ${cardClass}`}>
|
||
<TextField
|
||
label="نام Tenant"
|
||
required
|
||
value={name}
|
||
onValue={handleNameChange}
|
||
disabled={loading}
|
||
placeholder="فروشگاه نمونه"
|
||
/>
|
||
<TextField
|
||
label="Slug"
|
||
required
|
||
dir="ltr"
|
||
className="font-mono"
|
||
value={slug}
|
||
onValue={(v) => {
|
||
setSlugTouched(true);
|
||
setSlug(v);
|
||
}}
|
||
disabled={loading}
|
||
placeholder="sample-shop"
|
||
/>
|
||
<TextField
|
||
label="شناسه کاربر مالک (اختیاری)"
|
||
hint="در صورت خالی بودن، بدون مالک ایجاد میشود."
|
||
dir="ltr"
|
||
className="font-mono"
|
||
value={ownerUserId}
|
||
onValue={setOwnerUserId}
|
||
disabled={loading}
|
||
placeholder="UUID کاربر"
|
||
/>
|
||
<TextField
|
||
label="دامنه اختصاصی (اختیاری)"
|
||
dir="ltr"
|
||
className="font-mono"
|
||
value={customDomain}
|
||
onValue={setCustomDomain}
|
||
disabled={loading}
|
||
placeholder="shop.example.com"
|
||
/>
|
||
<Button type="submit" className="w-full" loading={loading} loadingText="در حال ایجاد...">
|
||
ایجاد
|
||
</Button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|