feat: add custom domain support with SSL, DNS verification, and billing
Users can assign a custom domain to their app with automatic SSL via cert-manager. Includes DNS verification flow (CNAME check), Persian instructions, admin-configurable pricing via PlatformSetting, and integration into the deploy wizard cost calculation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -83,6 +83,10 @@ export default function AppDetailPage() {
|
||||
newCost: { hourly: number };
|
||||
} | null>(null);
|
||||
|
||||
// ── Custom Domain ──────────────────────────────────
|
||||
const [showDomainSetup, setShowDomainSetup] = useState(false);
|
||||
const [customDomainInput, setCustomDomainInput] = useState('');
|
||||
|
||||
const { data: app, isLoading } = useQuery<Application>({
|
||||
queryKey: ['application', appId],
|
||||
queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data),
|
||||
@@ -226,6 +230,60 @@ export default function AppDetailPage() {
|
||||
const needsRenewal = app?.lifecycleStatus === 'suspended' || app?.lifecycleStatus === 'pending_deletion';
|
||||
const isExpiringSoon = app?.planExpiresAt && new Date(app.planExpiresAt) <= new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
// ─── Custom Domain ──────────────────────────────────
|
||||
const { data: domainInfo, refetch: refetchDomainInfo } = useQuery<{
|
||||
customDomain: string | null;
|
||||
customDomainStatus: string;
|
||||
platformDomain: string;
|
||||
fullPlatformUrl: string;
|
||||
cnameTarget: string;
|
||||
instructions: string[];
|
||||
}>({
|
||||
queryKey: ['domain-info', appId],
|
||||
queryFn: () => api.get(`/applications/${appId}/domain`).then((r) => r.data),
|
||||
enabled: showDomainSetup || (!!app && (app.customDomainStatus === 'pending_dns' || app.customDomainStatus === 'verified')),
|
||||
});
|
||||
|
||||
const { data: domainPriceData } = useQuery<{ monthlyPrice: number }>({
|
||||
queryKey: ['custom-domain-price'],
|
||||
queryFn: () => api.get('/billing/settings/custom-domain-price').then((r) => r.data),
|
||||
});
|
||||
|
||||
const setDomainMutation = useMutation({
|
||||
mutationFn: (domain: string) => api.post(`/applications/${appId}/domain`, { domain }),
|
||||
onSuccess: () => {
|
||||
toast.success('دامنه تنظیم شد. لطفاً رکورد DNS را اضافه کنید.');
|
||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||
refetchDomainInfo();
|
||||
setCustomDomainInput('');
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در تنظیم دامنه'),
|
||||
});
|
||||
|
||||
const verifyDnsMutation = useMutation({
|
||||
mutationFn: () => api.post(`/applications/${appId}/domain/verify`),
|
||||
onSuccess: (res) => {
|
||||
if (res.data.verified) {
|
||||
toast.success('دامنه با موفقیت تأیید شد!');
|
||||
} else {
|
||||
toast.warning(res.data.message || 'DNS هنوز آماده نیست. لطفاً بعداً تلاش کنید.');
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||
refetchDomainInfo();
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در تأیید DNS'),
|
||||
});
|
||||
|
||||
const removeDomainMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/applications/${appId}/domain`),
|
||||
onSuccess: () => {
|
||||
toast.success('دامنه اختصاصی حذف شد');
|
||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||
refetchDomainInfo();
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در حذف دامنه'),
|
||||
});
|
||||
|
||||
// ─── Snapshots ──────────────────────────────────────
|
||||
const { data: snapshots = [], isLoading: snapshotsLoading } = useQuery<AppSnapshot[]>({
|
||||
queryKey: ['snapshots', appId],
|
||||
@@ -679,7 +737,7 @@ export default function AppDetailPage() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{app.runtime}{app.runtimeVersion ? ` v${app.runtimeVersion}` : ''}{app.phpVersion ? ` — PHP ${app.phpVersion}` : ''} · {app.subdomain}.apps.cloudhost.local
|
||||
{app.runtime}{app.runtimeVersion ? ` v${app.runtimeVersion}` : ''}{app.phpVersion ? ` — PHP ${app.phpVersion}` : ''} · {app.customDomain && app.customDomainStatus === 'verified' ? app.customDomain : `${app.subdomain}.${domainInfo?.platformDomain || 'apps.cloudhost.ir'}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1206,6 +1264,168 @@ export default function AppDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Domain */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Globe className="w-5 h-5" /> دامنه
|
||||
</h2>
|
||||
{!showDomainSetup && (!app.customDomain || app.customDomainStatus === 'none') && (
|
||||
<button
|
||||
onClick={() => setShowDomainSetup(true)}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
افزودن دامنه اختصاصی
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Platform domain (always shown) */}
|
||||
<div className="bg-gray-50 rounded-xl p-4 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">دامنه پلتفرم</p>
|
||||
<p className="text-sm font-mono font-medium text-gray-800">
|
||||
{app.subdomain}.{domainInfo?.platformDomain || 'apps.cloudhost.ir'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="badge badge-green text-xs">فعال</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom domain - verified */}
|
||||
{app.customDomain && app.customDomainStatus === 'verified' && (
|
||||
<div className="bg-emerald-50 rounded-xl p-4 mb-4 border border-emerald-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-emerald-600 mb-1">دامنه اختصاصی</p>
|
||||
<p className="text-sm font-mono font-medium text-emerald-800">{app.customDomain}</p>
|
||||
<p className="text-xs text-emerald-500 mt-1">
|
||||
<CheckCircle className="w-3 h-3 inline" /> SSL فعال — تأیید شده در{' '}
|
||||
{app.customDomainVerifiedAt ? new Date(app.customDomainVerifiedAt).toLocaleString('fa-IR') : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'حذف دامنه اختصاصی',
|
||||
message: `آیا مطمئن هستید که میخواهید دامنه "${app.customDomain}" را حذف کنید؟ وبسایت فقط از طریق دامنه پلتفرم قابل دسترسی خواهد بود.`,
|
||||
confirmText: 'حذف',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) removeDomainMutation.mutate();
|
||||
}}
|
||||
disabled={removeDomainMutation.isPending}
|
||||
className="text-sm px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 border border-red-200 transition-colors"
|
||||
>
|
||||
{removeDomainMutation.isPending ? 'در حال حذف...' : 'حذف دامنه'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom domain - pending DNS */}
|
||||
{app.customDomain && app.customDomainStatus === 'pending_dns' && (
|
||||
<div className="bg-amber-50 rounded-xl p-4 mb-4 border border-amber-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p className="text-xs text-amber-600 mb-1">دامنه اختصاصی — در انتظار تأیید DNS</p>
|
||||
<p className="text-sm font-mono font-medium text-amber-800">{app.customDomain}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => verifyDnsMutation.mutate()}
|
||||
disabled={verifyDnsMutation.isPending}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
{verifyDnsMutation.isPending ? 'در حال بررسی...' : 'تأیید DNS'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeDomainMutation.mutate()}
|
||||
disabled={removeDomainMutation.isPending}
|
||||
className="text-sm px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 border border-red-200 transition-colors"
|
||||
>
|
||||
لغو
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DNS Instructions */}
|
||||
{domainInfo?.instructions && (
|
||||
<div className="bg-white rounded-lg p-4 border border-amber-100">
|
||||
<h4 className="text-sm font-semibold text-gray-800 mb-3">راهنمای تنظیم DNS</h4>
|
||||
<div className="space-y-2 text-sm text-gray-600" dir="rtl">
|
||||
{domainInfo.instructions.map((step, i) => (
|
||||
<p key={i} className={step.startsWith(' ') ? 'pr-4 text-xs font-mono bg-gray-50 rounded px-2 py-1' : ''}>
|
||||
{step}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 bg-blue-50 rounded-lg p-3 border border-blue-100">
|
||||
<p className="text-xs text-blue-700 font-medium mb-1">CNAME Target:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono text-blue-900 bg-blue-100 px-2 py-1 rounded flex-1">
|
||||
{domainInfo.fullPlatformUrl}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(domainInfo.fullPlatformUrl);
|
||||
toast.success('کپی شد!');
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-800 p-1"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Domain setup form */}
|
||||
{showDomainSetup && (!app.customDomain || app.customDomainStatus === 'none') && (
|
||||
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200">
|
||||
<h4 className="text-sm font-semibold text-gray-800 mb-3">تنظیم دامنه اختصاصی</h4>
|
||||
{domainPriceData && domainPriceData.monthlyPrice > 0 && (
|
||||
<div className="bg-blue-50 rounded-lg p-3 mb-4 border border-blue-100">
|
||||
<p className="text-sm text-blue-700">
|
||||
<CreditCard className="w-4 h-4 inline ml-1" />
|
||||
هزینه دامنه اختصاصی: <strong>{domainPriceData.monthlyPrice.toLocaleString('fa-IR')} تومان / ماهانه</strong>
|
||||
</p>
|
||||
<p className="text-xs text-blue-500 mt-1">
|
||||
این هزینه در محاسبه کلی هزینهها در نظر گرفته میشود.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2" dir="ltr">
|
||||
<input
|
||||
type="text"
|
||||
value={customDomainInput}
|
||||
onChange={(e) => setCustomDomainInput(e.target.value)}
|
||||
placeholder="example.com or www.example.com"
|
||||
className="input-field flex-1 font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (customDomainInput.trim()) setDomainMutation.mutate(customDomainInput.trim());
|
||||
}}
|
||||
disabled={!customDomainInput.trim() || setDomainMutation.isPending}
|
||||
className="btn-primary text-sm disabled:opacity-50"
|
||||
>
|
||||
{setDomainMutation.isPending ? 'در حال ثبت...' : 'ثبت دامنه'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowDomainSetup(false); setCustomDomainInput(''); }}
|
||||
className="btn-secondary text-sm"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Database Info & Dump Upload */}
|
||||
{app.databaseType !== 'none' && (
|
||||
<div className="card">
|
||||
|
||||
Reference in New Issue
Block a user