Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { ServiceDomainSettings } from '@qovery/domains/service-settings/feature'
import { useDocumentTitle } from '@qovery/shared/util-hooks'

export const Route = createFileRoute(
'/_authenticated/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/settings/domain'
Expand All @@ -7,5 +9,7 @@ export const Route = createFileRoute(
})

function RouteComponent() {
return <div className="px-10 py-7">Domain</div>
useDocumentTitle('Domain - Service settings')

return <ServiceDomainSettings />
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import { queries } from '@qovery/state/util-queries'
export interface UseCustomDomainsProps {
serviceId: string
serviceType: ServiceType
suspense?: boolean
}

export function useCustomDomains({ serviceId, serviceType }: UseCustomDomainsProps) {
export function useCustomDomains({ serviceId, serviceType, suspense = false }: UseCustomDomainsProps) {
return useQuery({
...queries.services.customDomains({
serviceId,
Expand All @@ -20,6 +21,7 @@ export function useCustomDomains({ serviceId, serviceType }: UseCustomDomainsPro
enabled: match(serviceType)
.with('APPLICATION', 'CONTAINER', 'HELM', () => true)
.otherwise(() => false),
suspense,
})
}

Expand Down
1 change: 1 addition & 0 deletions libs/domains/service-settings/feature/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './lib/service-danger-zone-settings/service-danger-zone-settings'
export * from './lib/application-container-port-settings/application-container-port-settings/application-container-port-settings'
export * from './lib/application-container-healthchecks-settings/application-container-healthchecks-settings/application-container-healthchecks-settings'
export * from './lib/application-container-storage-settings/application-container-storage-settings/application-container-storage-settings'
export * from './lib/service-domain-settings/service-domain-settings/service-domain-settings'
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type ProbeType, type ServicePort } from 'qovery-typescript-axios'
import { PortListRows } from '@qovery/domains/services/feature'
import { SettingsHeading } from '@qovery/shared/console-shared'
import { BlockContent, Button, EmptyState, Heading, Icon } from '@qovery/shared/ui'
import { BlockContent, Button, EmptyState, Icon } from '@qovery/shared/ui'
import { isMatchingPortHealthCheck } from '../is-matching-port-healthcheck'

export interface ApplicationContainerPortSettingsListProps {
Expand All @@ -23,7 +23,7 @@ export function ApplicationContainerPortSettingsList({
}: ApplicationContainerPortSettingsListProps) {
return (
<>
<div className="mb-10 flex justify-between">
<div className="flex justify-between">
<SettingsHeading
title="Ports"
description="Declare TCP/UDP ports used by your application. Declared ports are accessible from other applications within the same environment. You can also expose them on the internet by making them public. Declared ports are also used to check the liveness/readiness of your application."
Expand Down Expand Up @@ -65,7 +65,7 @@ export function ApplicationContainerPortSettingsList({
/>
</BlockContent>
) : (
<EmptyState title="No ports are set" description="Define a custom port for your application" />
<EmptyState icon="plug" title="No ports are set" description="Define a custom port for your application" />
)}
</div>
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { type Application } from '@qovery/domains/services/data-access'
import { applicationFactoryMock } from '@qovery/shared/factories'
import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests'
import { ServiceDomainCrudModal, type ServiceDomainCrudModalProps } from './service-domain-crud-modal'

const mockMutateCreateCustomDomain = jest.fn().mockResolvedValue(undefined)
const mockMutateEditCustomDomain = jest.fn().mockResolvedValue(undefined)
const mockEnableAlertClickOutside = jest.fn()

jest.mock('@qovery/domains/custom-domains/feature', () => ({
useCreateCustomDomain: () => ({
mutateAsync: mockMutateCreateCustomDomain,
isLoading: false,
}),
useEditCustomDomain: () => ({
mutateAsync: mockMutateEditCustomDomain,
isLoading: false,
}),
}))

jest.mock('@qovery/domains/services/feature', () => ({
useLinks: () => ({
data: [
{
url: 'https://default.qovery.example',
is_qovery_domain: true,
is_default: true,
},
],
}),
}))

jest.mock('@qovery/shared/ui', () => ({
...jest.requireActual('@qovery/shared/ui'),
useModal: () => ({
enableAlertClickOutside: mockEnableAlertClickOutside,
}),
}))

const service = applicationFactoryMock(1)[0] as Application

const props: ServiceDomainCrudModalProps = {
organizationId: 'org-1',
projectId: 'project-1',
service,
onClose: jest.fn(),
}

describe('ServiceDomainCrudModal', () => {
beforeEach(() => {
jest.clearAllMocks()
})

it('renders successfully and shows the CNAME target', () => {
renderWithProviders(<ServiceDomainCrudModal {...props} />)

expect(screen.getByText('CNAME configuration')).toBeInTheDocument()
expect(screen.getByText('default.qovery.example')).toBeInTheDocument()
})

it('creates a custom domain', async () => {
const { userEvent } = renderWithProviders(<ServiceDomainCrudModal {...props} />)

await userEvent.type(screen.getByLabelText('Domain'), 'example.com')
await userEvent.click(screen.getByRole('button', { name: 'Create' }))

await waitFor(() => {
expect(mockMutateCreateCustomDomain).toHaveBeenCalledWith({
serviceId: service.id,
serviceType: 'APPLICATION',
payload: {
domain: 'example.com',
generate_certificate: true,
use_cdn: false,
},
})
})
expect(props.onClose).toHaveBeenCalledTimes(1)
})

it('edits a custom domain', async () => {
const { userEvent } = renderWithProviders(
<ServiceDomainCrudModal
{...props}
customDomain={{
id: '1',
domain: 'example.com',
status: 'VALIDATION_PENDING',
validation_domain: 'validation.example.com',
updated_at: '2020-01-01T00:00:00Z',
created_at: '2020-01-01T00:00:00Z',
generate_certificate: false,
use_cdn: false,
}}
/>
)

await userEvent.clear(screen.getByLabelText('Domain'))
await userEvent.type(screen.getByLabelText('Domain'), 'edited-example.com')
await userEvent.click(screen.getByRole('button', { name: 'Confirm' }))

await waitFor(() => {
expect(mockMutateEditCustomDomain).toHaveBeenCalledWith({
serviceId: service.id,
serviceType: 'APPLICATION',
customDomainId: '1',
payload: {
domain: 'edited-example.com',
generate_certificate: false,
use_cdn: false,
},
})
})
})

it('disables certificate generation when CDN is enabled', async () => {
const { userEvent } = renderWithProviders(<ServiceDomainCrudModal {...props} />)

await userEvent.type(screen.getByLabelText('Domain'), 'example.com')
await userEvent.click(screen.getByText('Domain behind a CDN or DNS proxy (e.g. Cloudflare, CloudFront, Route 53)'))
await userEvent.click(screen.getByRole('button', { name: 'Create' }))

await waitFor(() => {
expect(mockMutateCreateCustomDomain).toHaveBeenCalledWith({
serviceId: service.id,
serviceType: 'APPLICATION',
payload: {
domain: 'example.com',
generate_certificate: false,
use_cdn: true,
},
})
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { type CustomDomain, type CustomDomainRequest } from 'qovery-typescript-axios'
import { useEffect, useMemo } from 'react'
import { Controller, FormProvider, useForm } from 'react-hook-form'
import { useCreateCustomDomain, useEditCustomDomain } from '@qovery/domains/custom-domains/feature'
import { type Application, type Container, type Helm } from '@qovery/domains/services/data-access'
import { useLinks } from '@qovery/domains/services/feature'
import { ExternalLink, InputText, InputToggle, ModalCrud, useModal } from '@qovery/shared/ui'

export interface ServiceDomainCrudModalProps {
organizationId: string
projectId: string
service: Application | Container | Helm
customDomain?: CustomDomain
onClose: () => void
}

export function ServiceDomainCrudModal({
organizationId,
projectId,
service,
customDomain,
onClose,
}: ServiceDomainCrudModalProps) {
const methods = useForm<CustomDomainRequest>({
defaultValues: {
domain: customDomain?.domain ?? '',
generate_certificate: customDomain?.generate_certificate ?? true,
use_cdn: customDomain?.use_cdn ?? false,
},
mode: 'onChange',
})

const { enableAlertClickOutside } = useModal()
const { data: links = [] } = useLinks({
serviceId: service.id,
serviceType: service.serviceType,
})
const { mutateAsync: createCustomDomain, isLoading: isLoadingCreateCustomDomain } = useCreateCustomDomain({
organizationId,
projectId,
environmentId: service.environment.id,
})
const { mutateAsync: editCustomDomain, isLoading: isLoadingEditCustomDomain } = useEditCustomDomain({
organizationId,
projectId,
environmentId: service.environment.id,
})

useEffect(() => {
enableAlertClickOutside(methods.formState.isDirty)
}, [methods.formState.isDirty, enableAlertClickOutside])

const { control, watch, setValue } = methods
const defaultLink = useMemo(() => {
const defaultLinkItem = links.find((link) => link.is_qovery_domain && link.is_default)
return defaultLinkItem?.url?.replace('https://', '') || ''
}, [links])
const watchDomain = watch('domain')
const showWildcardCname = Boolean(watchDomain) && !watchDomain.includes('*')
const cnameTarget = customDomain?.validation_domain || defaultLink

const onSubmit = methods.handleSubmit(async (data) => {
try {
if (customDomain) {
await editCustomDomain({
serviceId: service.id,
serviceType: service.serviceType,
customDomainId: customDomain.id,
payload: data,
})
} else {
await createCustomDomain({
serviceId: service.id,
serviceType: service.serviceType,
payload: data,
})
}

onClose()
} catch (error) {
console.error(error)
}
})

return (
<FormProvider {...methods}>
<ModalCrud
title={customDomain ? `Domain: ${customDomain.domain}` : 'Set custom DNS name'}
description="DNS configuration"
onSubmit={onSubmit}
onClose={onClose}
loading={isLoadingCreateCustomDomain || isLoadingEditCustomDomain}
isEdit={Boolean(customDomain)}
howItWorks={
<>
<ol className="ml-4 list-decimal space-y-2 text-neutral-subtle">
<li>
Configure two CNAME records in your DNS provider pointing to the Qovery domain shown below. Qovery will
manage TLS/SSL certificate creation and renewal.
</li>
<li>
If your service exposes more than one public port, you can assign a dedicated subdomain to each port via
the port settings.
</li>
<li>
If a CDN or DNS proxy already manages the certificate, disable certificate generation for this domain.
</li>
</ol>
<ExternalLink className="mt-3" href="https://www.qovery.com/docs/configuration/application#custom-domains">
Documentation
</ExternalLink>
</>
}
>
<Controller
name="domain"
control={control}
rules={{
required: 'Please enter a domain',
}}
render={({ field, fieldState: { error } }) => (
<InputText
className="mb-3"
name={field.name}
onChange={field.onChange}
value={field.value}
label="Domain"
error={error?.message}
autoFocus
/>
)}
/>

{cnameTarget ? (
<div className="overflow-hidden rounded-md border border-neutral">
<div className="border-b border-neutral bg-surface-neutral-subtle px-4 py-2 text-xs font-medium text-neutral">
CNAME configuration
</div>
<div className="space-y-3 bg-surface-neutral px-4 pb-3 pt-2 font-code text-xs">
<div>
<span className="block text-brand-9">{watchDomain || '<your-domain>'} CNAME</span>
<span className="block text-brand-12">{cnameTarget}</span>
</div>
{showWildcardCname ? (
<div>
<span className="block text-brand-9">*.{watchDomain} CNAME</span>
<span className="block text-brand-12">{cnameTarget}</span>
</div>
) : null}
</div>
</div>
) : null}

<Controller
name="use_cdn"
control={control}
render={({ field }) => (
<InputToggle
className="mt-6"
value={field.value}
onChange={(value) => {
if (value) {
setValue('generate_certificate', false, { shouldDirty: true, shouldValidate: true })
}
field.onChange(value)
}}
title="Domain behind a CDN or DNS proxy (e.g. Cloudflare, CloudFront, Route 53)"
description="Check this if the traffic on this domain is managed by a CDN or DNS proxy."
forceAlignTop
small
/>
)}
/>

<Controller
name="generate_certificate"
control={control}
render={({ field }) => (
<InputToggle
className="mt-6"
value={field.value}
onChange={field.onChange}
title="Generate certificate"
description="Qovery will generate and manage the certificate for this domain."
forceAlignTop
small
/>
)}
/>
</ModalCrud>
</FormProvider>
)
}

export default ServiceDomainCrudModal
Loading
Loading