feat(deploy): add DNS verification with modal before proceeding to next step

Add standalone DNS check endpoint and verify DNS on Next click in the
deploy wizard (step 2). Show error modal with CNAME instructions when
DNS is not configured instead of disabling the Next button. Also make
the custom domain clickable in the app detail header.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-14 01:20:57 +03:30
parent e8be7fbf03
commit 583c4c4c90
5 changed files with 320 additions and 29 deletions
@@ -21,7 +21,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
import { ApplicationsService } from './applications.service'; import { ApplicationsService } from './applications.service';
import { DomainService } from './domain.service'; import { DomainService } from './domain.service';
import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto, SetCustomDomainDto } from './dto/application.dto'; import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto, SetCustomDomainDto, CheckDnsDto } from './dto/application.dto';
import { RolesGuard } from '../common/guards/roles.guard'; import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator'; import { Roles } from '../common/decorators/roles.decorator';
import { UserRole, DatabaseType } from '../common/enums'; import { UserRole, DatabaseType } from '../common/enums';
@@ -267,6 +267,12 @@ export class ApplicationsController {
// ── Custom Domain ───────────────────────────────────────────── // ── Custom Domain ─────────────────────────────────────────────
@Post('domain/check-dns')
@ApiOperation({ summary: 'Standalone DNS check (no app needed — for deploy wizard)' })
async checkDnsStandalone(@Body() dto: CheckDnsDto) {
return this.domainService.checkDnsStandalone(dto.domain, dto.appName);
}
@Get(':id/domain') @Get(':id/domain')
@ApiOperation({ summary: 'Get custom domain info and DNS setup instructions' }) @ApiOperation({ summary: 'Get custom domain info and DNS setup instructions' })
async getDomainInfo(@Param('id') id: string, @Request() req: any) { async getDomainInfo(@Param('id') id: string, @Request() req: any) {
@@ -167,6 +167,68 @@ export class DomainService {
}; };
} }
async checkDnsStandalone(
domain: string,
appName: string,
): Promise<{
verified: boolean;
message: string;
cnameTarget: string;
instructions: string[];
}> {
const platformDomain =
this.configService.get('platform.domain') || 'apps.cloudhost.ir';
const cnameTarget = await this.getPlatformCnameTarget();
const fullPlatformUrl = `${appName}.${platformDomain}`;
const instructions = [
`1. Log in to your domain registrar (e.g. Cloudflare, Namecheap, GoDaddy)`,
`2. Go to DNS management for your domain`,
`3. Add a CNAME record:`,
` - Name/Host: @ or www (depending on your domain)`,
` - Type: CNAME`,
` - Value/Target: ${fullPlatformUrl}`,
`4. If using a root domain (without www), some registrars support CNAME flattening (e.g. Cloudflare). Otherwise use www.`,
`5. Wait 5-30 minutes for DNS propagation (may take up to 48 hours)`,
`6. Click the "Verify DNS" button`,
];
try {
const resolved = await this.resolveDomain(domain);
const isValid = resolved.some(
(r) =>
r === cnameTarget ||
r === fullPlatformUrl ||
r.endsWith(`.${cnameTarget}`),
);
if (isValid) {
return {
verified: true,
message:
'DNS verification successful! Your domain points to our servers.',
cnameTarget: fullPlatformUrl,
instructions,
};
}
return {
verified: false,
message: `DNS records do not point to ${fullPlatformUrl}. Please check your CNAME record and try again.`,
cnameTarget: fullPlatformUrl,
instructions,
};
} catch (err: any) {
return {
verified: false,
message: `Could not resolve DNS for ${domain}. Make sure the CNAME record is set and DNS has propagated (may take up to 48 hours).`,
cnameTarget: fullPlatformUrl,
instructions,
};
}
}
private async resolveDomain(domain: string): Promise<string[]> { private async resolveDomain(domain: string): Promise<string[]> {
const resolver = new dns.promises.Resolver(); const resolver = new dns.promises.Resolver();
resolver.setServers(['8.8.8.8', '1.1.1.1']); resolver.setServers(['8.8.8.8', '1.1.1.1']);
@@ -269,6 +269,19 @@ export class SetCustomDomainDto {
domain: string; domain: string;
} }
export class CheckDnsDto {
@ApiProperty({ example: 'www.example.com', description: 'The domain to check DNS for' })
@IsString()
@Matches(/^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, {
message: 'Invalid domain format (e.g. example.com or www.example.com)',
})
domain: string;
@ApiProperty({ example: 'my-app', description: 'The application name (used to compute CNAME target)' })
@IsString()
appName: string;
}
export class ScaleResourcesDto { export class ScaleResourcesDto {
@ApiPropertyOptional({ example: '100m' }) @ApiPropertyOptional({ example: '100m' })
@IsOptional() @IsOptional()
@@ -737,7 +737,11 @@ export default function AppDetailPage() {
</span> </span>
</div> </div>
<p className="text-sm text-gray-500 truncate"> <p className="text-sm text-gray-500 truncate">
{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'}`} {app.runtime}{app.runtimeVersion ? ` v${app.runtimeVersion}` : ''}{app.phpVersion ? ` — PHP ${app.phpVersion}` : ''} ·{' '}
{app.customDomain && app.customDomainStatus === 'verified'
? <a href={`https://${app.customDomain}`} target="_blank" rel="noopener noreferrer" className="text-emerald-600 hover:underline">{app.customDomain}</a>
: <span>{app.subdomain}.{domainInfo?.platformDomain || 'apps.cloudhost.ir'}</span>
}
</p> </p>
</div> </div>
</div> </div>
+223 -17
View File
@@ -7,7 +7,7 @@ import api from '@/lib/api';
import { useAuthStore } from '@/lib/store'; import { useAuthStore } from '@/lib/store';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown, BillingCycle } from '@/types'; import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown, BillingCycle } from '@/types';
import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign, Wallet, CreditCard, Loader2, Globe } from 'lucide-react'; import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign, Wallet, CreditCard, Loader2, Globe, Copy, AlertCircle, ShieldCheck } from 'lucide-react';
const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review']; const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review'];
@@ -92,6 +92,8 @@ export default function DeployPage() {
// ── Custom Domain ────────────────────────────── // ── Custom Domain ──────────────────────────────
const [enableCustomDomain, setEnableCustomDomain] = useState(false); const [enableCustomDomain, setEnableCustomDomain] = useState(false);
const [customDomainInput, setCustomDomainInput] = useState(''); const [customDomainInput, setCustomDomainInput] = useState('');
const [dnsVerified, setDnsVerified] = useState(false);
const [dnsCheckResult, setDnsCheckResult] = useState<{ verified: boolean; message: string; cnameTarget?: string; instructions?: string[] } | null>(null);
const { data: domainPriceData } = useQuery<{ monthlyPrice: number }>({ const { data: domainPriceData } = useQuery<{ monthlyPrice: number }>({
queryKey: ['custom-domain-price'], queryKey: ['custom-domain-price'],
@@ -99,6 +101,25 @@ export default function DeployPage() {
enabled: step >= 2, enabled: step >= 2,
}); });
const dnsCheckMutation = useMutation({
mutationFn: () => api.post('/applications/domain/check-dns', {
domain: customDomainInput.toLowerCase().trim(),
appName: `${form.name}-${(user?.id || '').split('-')[0]}`,
}).then((r) => r.data),
onSuccess: (data: { verified: boolean; message: string; cnameTarget: string; instructions: string[] }) => {
setDnsCheckResult(data);
if (data.verified) {
setDnsVerified(true);
toast.success('DNS verified! You can proceed.');
} else {
toast.warning(data.message);
}
},
onError: () => {
toast.error('Failed to check DNS. Please try again.');
},
});
// Cost calculation for the review step // Cost calculation for the review step
const { data: costData, isLoading: costLoading } = useQuery<CostBreakdown>({ const { data: costData, isLoading: costLoading } = useQuery<CostBreakdown>({
queryKey: ['deploy-cost', form.runtime, form.databaseType, form.cpuLimit, form.memoryLimit, form.replicas, form.dbStorageSize, form.appStorageSize, form.enableRedis, form.enableRabbitmq, form.enableElasticsearch, enableCustomDomain], queryKey: ['deploy-cost', form.runtime, form.databaseType, form.cpuLimit, form.memoryLimit, form.replicas, form.dbStorageSize, form.appStorageSize, form.enableRedis, form.enableRabbitmq, form.enableElasticsearch, enableCustomDomain],
@@ -413,7 +434,6 @@ export default function DeployPage() {
const canNext = () => { const canNext = () => {
if (step === 0) { if (step === 0) {
if (form.name.length < 2) return false; if (form.name.length < 2) return false;
// WordPress: migrate or public_html mode requires wp-content file
if (form.runtime === 'wordpress') { if (form.runtime === 'wordpress') {
if ((wpMode === 'migrate' || wpMode === 'public_html') && !wpContentFile) return false; if ((wpMode === 'migrate' || wpMode === 'public_html') && !wpContentFile) return false;
} else { } else {
@@ -425,6 +445,49 @@ export default function DeployPage() {
return true; return true;
}; };
const [showDnsModal, setShowDnsModal] = useState(false);
const [dnsModalMessage, setDnsModalMessage] = useState('');
const [checkingDnsOnNext, setCheckingDnsOnNext] = useState(false);
const handleNext = async () => {
if (step === 1 && enableCustomDomain && customDomainInput.trim()) {
if (dnsVerified) {
setStep(step + 1);
return;
}
const domainRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
if (!domainRegex.test(customDomainInput.trim())) {
setDnsModalMessage('Invalid domain format. Please use a format like example.com or www.example.com');
setShowDnsModal(true);
return;
}
setCheckingDnsOnNext(true);
try {
const { data } = await api.post('/applications/domain/check-dns', {
domain: customDomainInput.toLowerCase().trim(),
appName: `${form.name}-${(user?.id || '').split('-')[0]}`,
});
if (data.verified) {
setDnsVerified(true);
setDnsCheckResult(data);
toast.success('DNS verified!');
setStep(step + 1);
} else {
setDnsCheckResult(data);
setDnsModalMessage(data.message || 'DNS records are not configured correctly. Please set up the CNAME record and wait for propagation before proceeding.');
setShowDnsModal(true);
}
} catch {
setDnsModalMessage('Failed to verify DNS. Please check your domain settings and try again.');
setShowDnsModal(true);
} finally {
setCheckingDnsOnNext(false);
}
return;
}
setStep(step + 1);
};
return ( return (
<div className="max-w-2xl mx-auto space-y-8 animate-fade-in"> <div className="max-w-2xl mx-auto space-y-8 animate-fade-in">
<div> <div>
@@ -1477,42 +1540,149 @@ export default function DeployPage() {
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<div className={`p-4 rounded-xl border-2 text-left transition-all ${ <div className={`p-4 rounded-xl border-2 text-left transition-all ${
enableCustomDomain enableCustomDomain
? 'border-purple-400 bg-purple-50 shadow-sm' ? dnsVerified
? 'border-emerald-400 bg-emerald-50 shadow-sm'
: 'border-purple-400 bg-purple-50 shadow-sm'
: 'border-gray-200 bg-white hover:border-gray-300' : 'border-gray-200 bg-white hover:border-gray-300'
}`}> }`}>
<button <button
type="button" type="button"
onClick={() => setEnableCustomDomain(!enableCustomDomain)} onClick={() => {
if (enableCustomDomain) {
setEnableCustomDomain(false);
setDnsVerified(false);
setDnsCheckResult(null);
} else {
setEnableCustomDomain(true);
}
}}
className="w-full text-left" className="w-full text-left"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${enableCustomDomain ? 'bg-purple-100' : 'bg-gray-100'}`}> <div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
<Globe className={`w-6 h-6 ${enableCustomDomain ? 'text-purple-500' : 'text-gray-400'}`} /> enableCustomDomain
? dnsVerified ? 'bg-emerald-100' : 'bg-purple-100'
: 'bg-gray-100'
}`}>
{dnsVerified
? <ShieldCheck className="w-6 h-6 text-emerald-500" />
: <Globe className={`w-6 h-6 ${enableCustomDomain ? 'text-purple-500' : 'text-gray-400'}`} />
}
</div> </div>
<div> <div className="flex-1 min-w-0">
<p className="font-semibold text-gray-900">Custom Domain</p> <p className="font-semibold text-gray-900">Custom Domain</p>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
Use your own domain with free SSL {dnsVerified
? <span className="text-emerald-600 font-medium">DNS Verified {customDomainInput}</span>
: <>Use your own domain with free SSL
{domainPriceData && domainPriceData.monthlyPrice > 0 && ( {domainPriceData && domainPriceData.monthlyPrice > 0 && (
<span className="text-purple-600 font-medium"> {domainPriceData.monthlyPrice.toLocaleString('en-US')} Toman/mo</span> <span className="text-purple-600 font-medium"> {domainPriceData.monthlyPrice.toLocaleString('en-US')} Toman/mo</span>
)} )}
</>
}
</p> </p>
</div> </div>
</div> </div>
</button> </button>
{enableCustomDomain && ( {enableCustomDomain && !dnsVerified && (
<div className="mt-3 pt-3 border-t border-purple-200 space-y-2"> <div className="mt-3 pt-3 border-t border-purple-200 space-y-3">
<label className="block text-xs text-gray-600">Domain Address</label> <div>
<label className="block text-xs font-medium text-gray-600 mb-1">Domain Address</label>
<input <input
type="text" type="text"
value={customDomainInput} value={customDomainInput}
onChange={(e) => setCustomDomainInput(e.target.value)} onChange={(e) => {
setCustomDomainInput(e.target.value);
setDnsVerified(false);
setDnsCheckResult(null);
}}
placeholder="example.com or www.example.com" placeholder="example.com or www.example.com"
className="input-field w-full font-mono text-sm" className="input-field w-full font-mono text-sm"
/> />
<p className="text-xs text-purple-600"> </div>
After deployment, configure your DNS records. Instructions will be shown on the app detail page.
{customDomainInput.trim() && /^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(customDomainInput.trim()) && (
<>
{/* DNS Instructions */}
<div className="bg-white rounded-lg p-4 border border-purple-100">
<h4 className="text-sm font-semibold text-gray-800 mb-3 flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-amber-500" />
DNS Setup Required
</h4>
<div className="space-y-2 text-sm text-gray-600">
<p>1. Log in to your domain registrar (e.g. Cloudflare, Namecheap, GoDaddy)</p>
<p>2. Go to DNS management for your domain</p>
<p>3. Add a <strong>CNAME</strong> record:</p>
<div className="pl-4 space-y-1">
<p className="text-xs font-mono bg-gray-50 rounded px-2 py-1">
Name/Host: <strong>{customDomainInput.startsWith('www.') ? 'www' : '@'}</strong>
</p> </p>
<p className="text-xs font-mono bg-gray-50 rounded px-2 py-1">
Type: <strong>CNAME</strong>
</p>
<p className="text-xs font-mono bg-gray-50 rounded px-2 py-1">
Value: <strong>{form.name}-{(user?.id || '').split('-')[0]}.apps.cloudhost.ir</strong>
</p>
</div>
<p>4. If using a root domain (without www), use a registrar that supports CNAME flattening (e.g. Cloudflare).</p>
<p>5. Wait 530 minutes for DNS propagation</p>
</div>
{/* CNAME target copy box */}
<div className="mt-3 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 truncate">
{form.name}-{(user?.id || '').split('-')[0]}.apps.cloudhost.ir
</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(`${form.name}-${(user?.id || '').split('-')[0]}.apps.cloudhost.ir`);
toast.success('Copied!');
}}
className="text-blue-600 hover:text-blue-800 p-1 shrink-0"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* DNS check result */}
{dnsCheckResult && !dnsCheckResult.verified && (
<div className="bg-amber-50 rounded-lg p-3 border border-amber-200">
<p className="text-sm text-amber-800 flex items-center gap-2">
<XCircle className="w-4 h-4 shrink-0" />
{dnsCheckResult.message}
</p>
</div>
)}
{/* Verify button */}
<button
type="button"
onClick={() => dnsCheckMutation.mutate()}
disabled={dnsCheckMutation.isPending}
className="w-full py-2.5 rounded-lg font-medium text-sm bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
>
{dnsCheckMutation.isPending
? <><Loader2 className="w-4 h-4 animate-spin" /> Checking DNS...</>
: <><RefreshCw className="w-4 h-4" /> Verify DNS Records</>
}
</button>
<p className="text-xs text-gray-500">
You must verify DNS before proceeding. SSL will be provisioned automatically after deployment.
</p>
</>
)}
{customDomainInput.trim() && !/^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(customDomainInput.trim()) && (
<p className="text-xs text-red-500">
Invalid domain format. Use a format like example.com or www.example.com
</p>
)}
</div> </div>
)} )}
</div> </div>
@@ -2086,11 +2256,14 @@ export default function DeployPage() {
</button> </button>
{step < steps.length - 1 ? ( {step < steps.length - 1 ? (
<button <button
onClick={() => setStep(step + 1)} onClick={handleNext}
disabled={!canNext()} disabled={!canNext() || checkingDnsOnNext}
className="btn-primary disabled:opacity-50" className="btn-primary disabled:opacity-50"
> >
Next {checkingDnsOnNext
? <><Loader2 className="w-4 h-4 inline animate-spin" /> Checking DNS...</>
: 'Next →'
}
</button> </button>
) : ( ) : (
<button <button
@@ -2250,6 +2423,39 @@ export default function DeployPage() {
</div> </div>
</div> </div>
)} )}
{/* DNS Verification Error Modal */}
{showDnsModal && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm animate-modal-backdrop"
onClick={() => setShowDnsModal(false)}
/>
<div className="relative bg-white rounded-2xl shadow-2xl max-w-md w-full animate-modal-enter">
<div className="p-6 pb-0">
<div className="w-12 h-12 rounded-xl bg-amber-100 flex items-center justify-center mb-4">
<AlertCircle className="w-6 h-6 text-amber-600" />
</div>
<h3 className="text-lg font-bold text-gray-900 mb-2">DNS Not Verified</h3>
<p className="text-sm text-gray-500 leading-relaxed">{dnsModalMessage}</p>
<div className="mt-4 bg-gray-50 rounded-lg p-3 border border-gray-200">
<p className="text-xs font-medium text-gray-600 mb-1">Required CNAME record:</p>
<code className="text-xs font-mono text-gray-800">
{customDomainInput} {form.name}-{(user?.id || '').split('-')[0]}.apps.cloudhost.ir
</code>
</div>
</div>
<div className="flex items-center justify-end gap-3 p-6">
<button
onClick={() => setShowDnsModal(false)}
className="btn-primary"
>
OK
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }