From 583c4c4c909ee637bbc53a9d7e2b6217995ea9ae Mon Sep 17 00:00:00 2001 From: keyhan Date: Thu, 14 May 2026 01:20:57 +0330 Subject: [PATCH] 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 --- .../applications/applications.controller.ts | 8 +- backend/src/applications/domain.service.ts | 62 +++++ .../src/applications/dto/application.dto.ts | 13 + frontend/src/app/dashboard/apps/[id]/page.tsx | 6 +- frontend/src/app/dashboard/deploy/page.tsx | 260 ++++++++++++++++-- 5 files changed, 320 insertions(+), 29 deletions(-) diff --git a/backend/src/applications/applications.controller.ts b/backend/src/applications/applications.controller.ts index e20bc97..4007cf8 100644 --- a/backend/src/applications/applications.controller.ts +++ b/backend/src/applications/applications.controller.ts @@ -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) { diff --git a/backend/src/applications/domain.service.ts b/backend/src/applications/domain.service.ts index 9f96028..fdbcc13 100644 --- a/backend/src/applications/domain.service.ts +++ b/backend/src/applications/domain.service.ts @@ -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 { const resolver = new dns.promises.Resolver(); resolver.setServers(['8.8.8.8', '1.1.1.1']); diff --git a/backend/src/applications/dto/application.dto.ts b/backend/src/applications/dto/application.dto.ts index 65b664b..1c78fbd 100644 --- a/backend/src/applications/dto/application.dto.ts +++ b/backend/src/applications/dto/application.dto.ts @@ -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() diff --git a/frontend/src/app/dashboard/apps/[id]/page.tsx b/frontend/src/app/dashboard/apps/[id]/page.tsx index f82c02a..9452bd6 100644 --- a/frontend/src/app/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/dashboard/apps/[id]/page.tsx @@ -737,7 +737,11 @@ export default function AppDetailPage() {

- {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' + ? {app.customDomain} + : {app.subdomain}.{domainInfo?.platformDomain || 'apps.cloudhost.ir'} + }

diff --git a/frontend/src/app/dashboard/deploy/page.tsx b/frontend/src/app/dashboard/deploy/page.tsx index 7220fa1..abec13e 100644 --- a/frontend/src/app/dashboard/deploy/page.tsx +++ b/frontend/src/app/dashboard/deploy/page.tsx @@ -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({ 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 (
@@ -1477,42 +1540,149 @@ export default function DeployPage() {
- {enableCustomDomain && ( -
- - setCustomDomainInput(e.target.value)} - placeholder="example.com or www.example.com" - className="input-field w-full font-mono text-sm" - /> -

- After deployment, configure your DNS records. Instructions will be shown on the app detail page. -

+ {enableCustomDomain && !dnsVerified && ( +
+
+ + { + setCustomDomainInput(e.target.value); + setDnsVerified(false); + setDnsCheckResult(null); + }} + placeholder="example.com or www.example.com" + className="input-field w-full font-mono text-sm" + /> +
+ + {customDomainInput.trim() && /^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(customDomainInput.trim()) && ( + <> + {/* DNS Instructions */} +
+

+ + DNS Setup Required +

+
+

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: {customDomainInput.startsWith('www.') ? 'www' : '@'} +

+

+ Type: CNAME +

+

+ Value: {form.name}-{(user?.id || '').split('-')[0]}.apps.cloudhost.ir +

+
+

4. If using a root domain (without www), use a registrar that supports CNAME flattening (e.g. Cloudflare).

+

5. Wait 5–30 minutes for DNS propagation

+
+ + {/* CNAME target copy box */} +
+

CNAME Target:

+
+ + {form.name}-{(user?.id || '').split('-')[0]}.apps.cloudhost.ir + + +
+
+
+ + {/* DNS check result */} + {dnsCheckResult && !dnsCheckResult.verified && ( +
+

+ + {dnsCheckResult.message} +

+
+ )} + + {/* Verify button */} + + +

+ You must verify DNS before proceeding. SSL will be provisioned automatically after deployment. +

+ + )} + + {customDomainInput.trim() && !/^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/.test(customDomainInput.trim()) && ( +

+ Invalid domain format. Use a format like example.com or www.example.com +

+ )}
)}
@@ -2086,11 +2256,14 @@ export default function DeployPage() { {step < steps.length - 1 ? ( ) : (
)} + + {/* DNS Verification Error Modal */} + {showDnsModal && ( +
+
setShowDnsModal(false)} + /> +
+
+
+ +
+

DNS Not Verified

+

{dnsModalMessage}

+
+

Required CNAME record:

+ + {customDomainInput} → {form.name}-{(user?.id || '').split('-')[0]}.apps.cloudhost.ir + +
+
+
+ +
+
+
+ )}
); }