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:
@@ -21,7 +21,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
||||
import { ApplicationsService } from './applications.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 { Roles } from '../common/decorators/roles.decorator';
|
||||
import { UserRole, DatabaseType } from '../common/enums';
|
||||
@@ -267,6 +267,12 @@ export class ApplicationsController {
|
||||
|
||||
// ── 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')
|
||||
@ApiOperation({ summary: 'Get custom domain info and DNS setup instructions' })
|
||||
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[]> {
|
||||
const resolver = new dns.promises.Resolver();
|
||||
resolver.setServers(['8.8.8.8', '1.1.1.1']);
|
||||
|
||||
@@ -269,6 +269,19 @@ export class SetCustomDomainDto {
|
||||
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 {
|
||||
@ApiPropertyOptional({ example: '100m' })
|
||||
@IsOptional()
|
||||
|
||||
@@ -737,7 +737,11 @@ 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.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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import api from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { toast } from 'react-toastify';
|
||||
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'];
|
||||
|
||||
@@ -92,6 +92,8 @@ export default function DeployPage() {
|
||||
// ── Custom Domain ──────────────────────────────
|
||||
const [enableCustomDomain, setEnableCustomDomain] = useState(false);
|
||||
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 }>({
|
||||
queryKey: ['custom-domain-price'],
|
||||
@@ -99,6 +101,25 @@ export default function DeployPage() {
|
||||
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
|
||||
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],
|
||||
@@ -413,7 +434,6 @@ export default function DeployPage() {
|
||||
const canNext = () => {
|
||||
if (step === 0) {
|
||||
if (form.name.length < 2) return false;
|
||||
// WordPress: migrate or public_html mode requires wp-content file
|
||||
if (form.runtime === 'wordpress') {
|
||||
if ((wpMode === 'migrate' || wpMode === 'public_html') && !wpContentFile) return false;
|
||||
} else {
|
||||
@@ -425,6 +445,49 @@ export default function DeployPage() {
|
||||
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 (
|
||||
<div className="max-w-2xl mx-auto space-y-8 animate-fade-in">
|
||||
<div>
|
||||
@@ -1477,42 +1540,149 @@ export default function DeployPage() {
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
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'
|
||||
}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEnableCustomDomain(!enableCustomDomain)}
|
||||
onClick={() => {
|
||||
if (enableCustomDomain) {
|
||||
setEnableCustomDomain(false);
|
||||
setDnsVerified(false);
|
||||
setDnsCheckResult(null);
|
||||
} else {
|
||||
setEnableCustomDomain(true);
|
||||
}
|
||||
}}
|
||||
className="w-full text-left"
|
||||
>
|
||||
<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'}`}>
|
||||
<Globe className={`w-6 h-6 ${enableCustomDomain ? 'text-purple-500' : 'text-gray-400'}`} />
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
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 className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-gray-900">Custom Domain</p>
|
||||
<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 && (
|
||||
<span className="text-purple-600 font-medium"> — {domainPriceData.monthlyPrice.toLocaleString('en-US')} Toman/mo</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{enableCustomDomain && (
|
||||
<div className="mt-3 pt-3 border-t border-purple-200 space-y-2">
|
||||
<label className="block text-xs text-gray-600">Domain Address</label>
|
||||
{enableCustomDomain && !dnsVerified && (
|
||||
<div className="mt-3 pt-3 border-t border-purple-200 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Domain Address</label>
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
className="input-field w-full font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-purple-600">
|
||||
After deployment, configure your DNS records. Instructions will be shown on the app detail page.
|
||||
</div>
|
||||
|
||||
{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 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 5–30 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>
|
||||
@@ -2086,11 +2256,14 @@ export default function DeployPage() {
|
||||
</button>
|
||||
{step < steps.length - 1 ? (
|
||||
<button
|
||||
onClick={() => setStep(step + 1)}
|
||||
disabled={!canNext()}
|
||||
onClick={handleNext}
|
||||
disabled={!canNext() || checkingDnsOnNext}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
Next →
|
||||
{checkingDnsOnNext
|
||||
? <><Loader2 className="w-4 h-4 inline animate-spin" /> Checking DNS...</>
|
||||
: 'Next →'
|
||||
}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -2250,6 +2423,39 @@ export default function DeployPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user