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:
keyhan
2026-05-14 00:36:29 +03:30
parent d87b50c6a4
commit 435cf92817
18 changed files with 1082 additions and 89 deletions
+221 -1
View File
@@ -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">