diff --git a/public/locales/en/remnawave.json b/public/locales/en/remnawave.json index 4bfd1ca16..faa7f341a 100644 --- a/public/locales/en/remnawave.json +++ b/public/locales/en/remnawave.json @@ -27,6 +27,7 @@ "response-rules": "Response Rules", "remnawave-settings": "Remnawave Settings", "node-plugins": "Plugins", + "certificates": "Certificates", "tb-reports": "Torrent Blocker Reports", "sessions-explorer": "Sessions Explorer", "http-stats": "HTTP Stats" diff --git a/public/locales/fa/remnawave.json b/public/locales/fa/remnawave.json index 574b50454..b9477fc81 100644 --- a/public/locales/fa/remnawave.json +++ b/public/locales/fa/remnawave.json @@ -27,6 +27,7 @@ "response-rules": "قوانین پاسخ", "remnawave-settings": "تنظیمات Remnawave", "node-plugins": "Plugins", + "certificates": "گواهیها", "tb-reports": "Torrent Blocker Reports", "sessions-explorer": "Sessions Explorer", "http-stats": "HTTP Stats" diff --git a/public/locales/ru/remnawave.json b/public/locales/ru/remnawave.json index 763a29d9e..72a2564fe 100644 --- a/public/locales/ru/remnawave.json +++ b/public/locales/ru/remnawave.json @@ -27,6 +27,7 @@ "response-rules": "Правила ответов", "remnawave-settings": "Настройки Remnawave", "node-plugins": "Плагины", + "certificates": "Сертификаты", "tb-reports": "Torrent Blocker Reports", "sessions-explorer": "Обозреватель сессий", "http-stats": "HTTP Статистика" diff --git a/public/locales/zh/remnawave.json b/public/locales/zh/remnawave.json index cf9fb48de..2f9c0cf09 100644 --- a/public/locales/zh/remnawave.json +++ b/public/locales/zh/remnawave.json @@ -27,6 +27,7 @@ "response-rules": "请求订阅的应用", "remnawave-settings": "Remnawave 设置", "node-plugins": "插件", + "certificates": "证书", "tb-reports": "Torrent 屏蔽报告", "sessions-explorer": "会话管理器", "http-stats": "HTTP 状态" diff --git a/src/app/layouts/dashboard/main-layout/menu-sections/desktop-menu-sections.ts b/src/app/layouts/dashboard/main-layout/menu-sections/desktop-menu-sections.ts index 1a097fd50..f3f60d959 100644 --- a/src/app/layouts/dashboard/main-layout/menu-sections/desktop-menu-sections.ts +++ b/src/app/layouts/dashboard/main-layout/menu-sections/desktop-menu-sections.ts @@ -4,6 +4,7 @@ import { HiChartPie, HiServer } from 'react-icons/hi' import { PiArrowsInCardinalFill, PiChartLine, PiListChecks, PiUsers } from 'react-icons/pi' import { TbApi, + TbCertificate, TbCirclesRelation, TbCreditCard, TbDeviceAnalytics, @@ -84,6 +85,12 @@ export const useDesktopMenuSections = (): MenuItem[] => { icon: TbPackage, id: 'node-plugins' }, + { + name: t('constants.certificates'), + href: ROUTES.DASHBOARD.MANAGEMENT.ACME, + icon: TbCertificate, + id: 'acme-certificates' + }, { name: t('constants.nodes-statistics'), href: ROUTES.DASHBOARD.MANAGEMENT.NODES_STATS, diff --git a/src/app/layouts/dashboard/main-layout/menu-sections/mobile-menu-sections.ts b/src/app/layouts/dashboard/main-layout/menu-sections/mobile-menu-sections.ts index f0c8de980..c45882072 100644 --- a/src/app/layouts/dashboard/main-layout/menu-sections/mobile-menu-sections.ts +++ b/src/app/layouts/dashboard/main-layout/menu-sections/mobile-menu-sections.ts @@ -11,6 +11,7 @@ import { } from 'react-icons/pi' import { TbApi, + TbCertificate, TbCirclesRelation, TbCreditCard, TbDeviceAnalytics, @@ -98,6 +99,12 @@ export const useMobileMenuSections = (): MenuItem[] => { icon: TbPackage, id: 'node-plugins' }, + { + name: t('constants.certificates'), + href: ROUTES.DASHBOARD.MANAGEMENT.ACME, + icon: TbCertificate, + id: 'acme-certificates' + }, { name: t('constants.nodes-statistics'), diff --git a/src/app/router/router.tsx b/src/app/router/router.tsx index 1b43de797..2357a5fdc 100644 --- a/src/app/router/router.tsx +++ b/src/app/router/router.tsx @@ -1,5 +1,6 @@ import { LoginPage } from '@pages/auth/login' import { Oauth2CallbackPage } from '@pages/auth/oauth2-callback/oauth2-callback.page' +import { AcmePageConnector } from '@pages/dashboard/acme/ui/connectors/acme-page.connector' import { ConfigProfilesPageConnector } from '@pages/dashboard/config-profiles/connectors' import { ConfigProfileByUuidPageConnector } from '@pages/dashboard/config-profiles/connectors/config-profile-by-uuid.page.connector' import { InfraBillingPageConnector } from '@pages/dashboard/crm/infra-billing/connectors/infra-billing.page.connector' @@ -115,6 +116,11 @@ const router = createBrowserRouter( path={ROUTES.DASHBOARD.MANAGEMENT.REMNAWAVE_SETTINGS} /> + } + path={ROUTES.DASHBOARD.MANAGEMENT.ACME} + /> + } index /> diff --git a/src/pages/dashboard/acme/ui/components/acme-page.component.tsx b/src/pages/dashboard/acme/ui/components/acme-page.component.tsx new file mode 100644 index 000000000..0929eb3ac --- /dev/null +++ b/src/pages/dashboard/acme/ui/components/acme-page.component.tsx @@ -0,0 +1,61 @@ +import { Tabs } from '@mantine/core' +import { GetNodesCommand } from '@remnawave/backend-contract' +import { AcmeCertificatesListWidget } from '@widgets/dashboard/acme/certificates-list/certificates-list.widget' +import { AcmeCredentialsGridWidget } from '@widgets/dashboard/acme/credentials-grid/credentials-grid.widget' +import { motion } from 'motion/react' +import { useTranslation } from 'react-i18next' +import { TbCertificate, TbKey } from 'react-icons/tb' +import { z } from 'zod' + +import { AcmeCertificateSchema, AcmeCredentialSchema } from '@shared/api/contracts/acme.contract' +import { Page, PageHeaderShared } from '@shared/ui' + +interface Props { + certificates: z.infer[] + credentials: z.infer[] + nodes: GetNodesCommand.Response['response'] +} + +export const AcmePageComponent = (props: Props) => { + const { certificates, credentials, nodes } = props + + const { t } = useTranslation() + + return ( + + } + title={t('constants.certificates')} + /> + + + + + } value="certificates"> + Certificates + + } value="credentials"> + Credentials + + + + + + + + + + + + + + ) +} diff --git a/src/pages/dashboard/acme/ui/connectors/acme-page.connector.tsx b/src/pages/dashboard/acme/ui/connectors/acme-page.connector.tsx new file mode 100644 index 000000000..59f007fb1 --- /dev/null +++ b/src/pages/dashboard/acme/ui/connectors/acme-page.connector.tsx @@ -0,0 +1,29 @@ +import { useGetAcmeCertificates, useGetAcmeCredentials, useGetNodes } from '@shared/api/hooks' +import { LoadingScreen } from '@shared/ui' + +import { AcmePageComponent } from '../components/acme-page.component' + +export function AcmePageConnector() { + const { data: credentials, isLoading: isCredentialsLoading } = useGetAcmeCredentials({}) + const { data: certificates, isLoading: isCertificatesLoading } = useGetAcmeCertificates({}) + const { data: nodes, isLoading: isNodesLoading } = useGetNodes() + + if ( + isCredentialsLoading || + isCertificatesLoading || + isNodesLoading || + !credentials || + !certificates || + !nodes + ) { + return + } + + return ( + + ) +} diff --git a/src/shared/api/contracts/acme.contract.ts b/src/shared/api/contracts/acme.contract.ts new file mode 100644 index 000000000..994744b1c --- /dev/null +++ b/src/shared/api/contracts/acme.contract.ts @@ -0,0 +1,564 @@ +import { z } from 'zod' + +/** + * Contract for the ACME endpoints. + * + * It lives here instead of in @remnawave/backend-contract because the panel + * fork adds these endpoints and the published package does not know about them. + * Keep it in sync with libs/contract/{models,commands}/acme in the backend fork; + * when the feature goes upstream, this file is deleted and the package takes + * over. + */ + +const ROOT = '/api/acme' + +export const ACME_PROVIDER = { + CLOUDFLARE: 'CLOUDFLARE', + CUSTOM: 'CUSTOM', + DESEC: 'DESEC', + DIGITALOCEAN: 'DIGITALOCEAN', + GANDI: 'GANDI', + HETZNER: 'HETZNER', + MANUAL: 'MANUAL', + PORKBUN: 'PORKBUN', + POWERDNS: 'POWERDNS', + VULTR: 'VULTR' +} as const + +export type TAcmeProvider = (typeof ACME_PROVIDER)[keyof typeof ACME_PROVIDER] + +export const ACME_PROVIDER_VALUES = Object.values(ACME_PROVIDER) as [ + TAcmeProvider, + ...TAcmeProvider[] +] + +export interface IAcmeProviderField { + description?: string + key: string + label: string + placeholder?: string + required: boolean + secret: boolean +} + +export interface IAcmeProviderInfo { + description?: string + fields: IAcmeProviderField[] + label: string + provider: TAcmeProvider +} + +/** Mirrors ACME_PROVIDER_REGISTRY in the backend fork - keep in sync. */ +export const ACME_PROVIDER_REGISTRY: IAcmeProviderInfo[] = [ + { + fields: [ + { + description: 'Needs Zone:Read and DNS:Edit', + key: 'apiToken', + label: 'API token', + placeholder: 'Cloudflare API token', + required: true, + secret: true + } + ], + label: 'Cloudflare', + provider: ACME_PROVIDER.CLOUDFLARE + }, + { + fields: [ + { + key: 'apiToken', + label: 'API token', + placeholder: 'deSEC token', + required: true, + secret: true + } + ], + label: 'deSEC', + provider: ACME_PROVIDER.DESEC + }, + { + fields: [ + { + description: 'Needs domain read and write', + key: 'apiToken', + label: 'API token', + placeholder: 'DigitalOcean personal access token', + required: true, + secret: true + } + ], + label: 'DigitalOcean', + provider: ACME_PROVIDER.DIGITALOCEAN + }, + { + fields: [ + { + description: 'Needs "Manage domain name technical configurations"', + key: 'apiToken', + label: 'Personal access token', + placeholder: 'Gandi PAT', + required: true, + secret: true + } + ], + label: 'Gandi LiveDNS', + provider: ACME_PROVIDER.GANDI + }, + { + fields: [ + { + key: 'apiToken', + label: 'API token', + placeholder: 'dns.hetzner.com API token', + required: true, + secret: true + } + ], + label: 'Hetzner DNS', + provider: ACME_PROVIDER.HETZNER + }, + { + fields: [ + { key: 'apiKey', label: 'API key', placeholder: 'pk1_…', required: true, secret: true }, + { + key: 'secretApiKey', + label: 'Secret API key', + placeholder: 'sk1_…', + required: true, + secret: true + } + ], + label: 'Porkbun', + provider: ACME_PROVIDER.PORKBUN + }, + { + fields: [ + { + key: 'baseUrl', + label: 'API URL', + placeholder: 'http://powerdns:8081', + required: true, + secret: false + }, + { key: 'apiKey', label: 'API key', required: true, secret: true }, + { + description: 'Leave empty for the default server', + key: 'serverId', + label: 'Server ID', + placeholder: 'localhost', + required: false, + secret: false + } + ], + label: 'PowerDNS', + provider: ACME_PROVIDER.POWERDNS + }, + { + fields: [ + { + key: 'apiToken', + label: 'API key', + placeholder: 'Vultr API key', + required: true, + secret: true + } + ], + label: 'Vultr', + provider: ACME_PROVIDER.VULTR + }, + { + description: + 'A DNS broker speaking the simple HTTP protocol from the documentation. Keeps the real DNS credential outside the panel.', + fields: [ + { + key: 'baseUrl', + label: 'URL', + placeholder: 'http://dns-broker:8080', + required: true, + secret: false + }, + { + key: 'token', + label: 'Token', + placeholder: 'Client token', + required: true, + secret: true + } + ], + label: 'Custom (HTTP API)', + provider: ACME_PROVIDER.CUSTOM + }, + { + description: + 'Nothing is published automatically. Pairs with dns-persist-01, where one record is added by hand; it cannot answer dns-01.', + fields: [], + label: 'Manual', + provider: ACME_PROVIDER.MANUAL + } +] + +export const ACME_CHALLENGE_TYPE = { + DNS_01: 'DNS_01', + DNS_PERSIST_01: 'DNS_PERSIST_01' +} as const + +export type TAcmeChallengeType = (typeof ACME_CHALLENGE_TYPE)[keyof typeof ACME_CHALLENGE_TYPE] + +export const ACME_KEY_TYPES = ['ECDSA_P256', 'ECDSA_P384', 'RSA_2048', 'RSA_4096'] as const + +export const ACME_CERTIFICATE_SOURCE = { + ACME: 'ACME', + IMPORTED: 'IMPORTED' +} as const + +export type TAcmeCertificateSource = + (typeof ACME_CERTIFICATE_SOURCE)[keyof typeof ACME_CERTIFICATE_SOURCE] + +export const ACME_CERTIFICATE_STATUS = { + ACTIVE: 'ACTIVE', + AWAITING_DNS: 'AWAITING_DNS', + ERROR: 'ERROR', + ISSUING: 'ISSUING', + PENDING: 'PENDING' +} as const + +export type TAcmeCertificateStatus = + (typeof ACME_CERTIFICATE_STATUS)[keyof typeof ACME_CERTIFICATE_STATUS] + +export const ACME_DIRECTORY = { + BUYPASS: 'https://api.buypass.com/acme/directory', + BUYPASS_STAGING: 'https://api.test4.buypass.no/acme/directory', + GOOGLE: 'https://dv.acme-v02.api.pki.goog/directory', + GOOGLE_STAGING: 'https://dv.acme-v02.test-api.pki.goog/directory', + LETSENCRYPT: 'https://acme-v02.api.letsencrypt.org/directory', + LETSENCRYPT_STAGING: 'https://acme-staging-v02.api.letsencrypt.org/directory', + ZEROSSL: 'https://acme.zerossl.com/v2/DV90' +} as const + +/** + * Presets offered in the certificate form. Staging endpoints are first-class + * here: they are where a new name should be rehearsed, and — until Let's Encrypt + * enables dns-persist-01 in production — the only place that challenge works. + */ +export const ACME_DIRECTORY_PRESETS = [ + { isStaging: true, name: "Let's Encrypt (staging)", url: ACME_DIRECTORY.LETSENCRYPT_STAGING }, + { isStaging: false, name: "Let's Encrypt", url: ACME_DIRECTORY.LETSENCRYPT }, + { isStaging: true, name: 'Buypass Go (staging)', url: ACME_DIRECTORY.BUYPASS_STAGING }, + { isStaging: false, name: 'Buypass Go', url: ACME_DIRECTORY.BUYPASS }, + { + isStaging: true, + name: 'Google Trust Services (staging)', + url: ACME_DIRECTORY.GOOGLE_STAGING + }, + { isStaging: false, name: 'Google Trust Services', url: ACME_DIRECTORY.GOOGLE }, + { isStaging: false, name: 'ZeroSSL', url: ACME_DIRECTORY.ZEROSSL } +] as const + +const dateFromString = z.iso.datetime().transform((value) => new Date(value)) + +export const AcmeCredentialSchema = z.object({ + certificatesCount: z.number().int(), + config: z.record(z.string(), z.string()), + createdAt: dateFromString, + hasSecret: z.boolean(), + name: z.string(), + provider: z.enum(ACME_PROVIDER_VALUES), + updatedAt: dateFromString, + uuid: z.uuid() +}) + +export const AcmeCertificateNodeSchema = z.object({ + inboundTags: z.array(z.string()), + nodeName: z.nullable(z.string()), + nodeUuid: z.uuid() +}) + +export const AcmeCertificateSchema = z.object({ + challengeType: z.enum([ACME_CHALLENGE_TYPE.DNS_01, ACME_CHALLENGE_TYPE.DNS_PERSIST_01]), + createdAt: dateFromString, + credentialName: z.nullable(z.string()), + credentialUuid: z.nullable(z.uuid()), + directoryUrl: z.nullable(z.string()), + domains: z.array(z.string()), + eabKid: z.nullable(z.string()), + email: z.nullable(z.string()), + expiresAt: z.nullable(dateFromString), + failCount: z.number().int(), + fingerprint: z.nullable(z.string()), + isEnabled: z.boolean(), + issuedAt: z.nullable(dateFromString), + keyType: z.enum(ACME_KEY_TYPES), + lastError: z.nullable(z.string()), + name: z.string(), + nextRetryAt: z.nullable(dateFromString), + nodes: z.array(AcmeCertificateNodeSchema), + renewBeforeDays: z.number().int(), + source: z.enum([ACME_CERTIFICATE_SOURCE.ACME, ACME_CERTIFICATE_SOURCE.IMPORTED]), + status: z.enum([ + ACME_CERTIFICATE_STATUS.PENDING, + ACME_CERTIFICATE_STATUS.AWAITING_DNS, + ACME_CERTIFICATE_STATUS.ISSUING, + ACME_CERTIFICATE_STATUS.ACTIVE, + ACME_CERTIFICATE_STATUS.ERROR + ]), + updatedAt: dateFromString, + uuid: z.uuid() +}) + +export const AcmeEventSchema = z.object({ + certificateUuid: z.nullable(z.uuid()), + createdAt: dateFromString, + id: z.number().int(), + level: z.enum(['INFO', 'ERROR']), + message: z.string() +}) + +export const AcmePersistRecordSchema = z.object({ + canPublish: z.boolean(), + isPublished: z.boolean(), + name: z.string(), + value: z.string() +}) + +export const AcmeCredentialTestSchema = z.object({ + allow: z.array(z.string()), + isOk: z.boolean(), + message: z.string(), + zones: z.array(z.string()) +}) + +const uuidParam = z.object({ uuid: z.uuid() }) + +export namespace GetAcmeCredentialsCommand { + export const TSQ_url = `${ROOT}/credentials` + + export const ResponseSchema = z.object({ + response: z.object({ + credentials: z.array(AcmeCredentialSchema), + total: z.number() + }) + }) + + export type Response = z.infer +} + +export namespace CreateAcmeCredentialCommand { + export const TSQ_url = `${ROOT}/credentials` + export const endpointDetails = { REQUEST_METHOD: 'post' } as const + + export const RequestBodySchema = z.object({ + config: z.optional(z.record(z.string(), z.string())), + name: z.string().min(2).max(40), + provider: z.enum(ACME_PROVIDER_VALUES) + }) + + export const ResponseSchema = z.object({ response: AcmeCredentialSchema }) + + export type RequestBody = z.infer + export type Response = z.infer +} + +export namespace UpdateAcmeCredentialCommand { + export const TSQ_url = `${ROOT}/credentials` + export const endpointDetails = { REQUEST_METHOD: 'patch' } as const + + export const RequestBodySchema = z.object({ + config: z.optional(z.record(z.string(), z.string())), + name: z.optional(z.string().min(2).max(40)), + uuid: z.uuid() + }) + + export const ResponseSchema = z.object({ response: AcmeCredentialSchema }) + + export type RequestBody = z.infer + export type Response = z.infer +} + +export namespace DeleteAcmeCredentialCommand { + export const TSQ_url = `${ROOT}/credentials/:uuid` + export const endpointDetails = { REQUEST_METHOD: 'delete' } as const + + export const RequestParamSchema = uuidParam + export const ResponseSchema = z.object({ + response: z.object({ isDeleted: z.boolean() }) + }) + + export type Response = z.infer +} + +export namespace TestAcmeCredentialCommand { + export const TSQ_url = `${ROOT}/credentials/:uuid/test` + export const endpointDetails = { REQUEST_METHOD: 'post' } as const + + export const RequestParamSchema = uuidParam + export const ResponseSchema = z.object({ response: AcmeCredentialTestSchema }) + + export type Response = z.infer +} + +export namespace GetAcmeCertificatesCommand { + export const TSQ_url = `${ROOT}/certificates` + + export const ResponseSchema = z.object({ + response: z.object({ + certificates: z.array(AcmeCertificateSchema), + total: z.number() + }) + }) + + export type Response = z.infer +} + +export namespace CreateAcmeCertificateCommand { + export const TSQ_url = `${ROOT}/certificates` + export const endpointDetails = { REQUEST_METHOD: 'post' } as const + + export const RequestBodySchema = z.object({ + challengeType: z.optional( + z.enum([ACME_CHALLENGE_TYPE.DNS_01, ACME_CHALLENGE_TYPE.DNS_PERSIST_01]) + ), + credentialUuid: z.uuid(), + directoryUrl: z.optional(z.url()), + domains: z.array(z.string()).min(1), + eabHmacKey: z.optional(z.string().min(1)), + eabKid: z.optional(z.string().min(1)), + email: z.email(), + isEnabled: z.optional(z.boolean()), + keyType: z.optional(z.enum(ACME_KEY_TYPES)), + name: z.string().min(2).max(40), + nodes: z.optional( + z.array( + z.object({ + inboundTags: z.array(z.string()), + nodeUuid: z.uuid() + }) + ) + ), + renewBeforeDays: z.optional(z.number().int().min(1).max(85)) + }) + + export const ResponseSchema = z.object({ response: AcmeCertificateSchema }) + + export type RequestBody = z.infer + export type Response = z.infer +} + +export namespace UpdateAcmeCertificateCommand { + export const TSQ_url = `${ROOT}/certificates` + export const endpointDetails = { REQUEST_METHOD: 'patch' } as const + + export const RequestBodySchema = + CreateAcmeCertificateCommand.RequestBodySchema.partial().extend({ + uuid: z.uuid() + }) + + export const ResponseSchema = z.object({ response: AcmeCertificateSchema }) + + export type RequestBody = z.infer + export type Response = z.infer +} + +export namespace DeleteAcmeCertificateCommand { + export const TSQ_url = `${ROOT}/certificates/:uuid` + export const endpointDetails = { REQUEST_METHOD: 'delete' } as const + + export const RequestParamSchema = uuidParam + export const ResponseSchema = z.object({ + response: z.object({ isDeleted: z.boolean() }) + }) + + export type Response = z.infer +} + +export namespace IssueAcmeCertificateCommand { + export const TSQ_url = `${ROOT}/certificates/:uuid/issue` + export const endpointDetails = { REQUEST_METHOD: 'post' } as const + + export const RequestParamSchema = uuidParam + export const ResponseSchema = z.object({ + response: z.object({ isQueued: z.boolean() }) + }) + + export type Response = z.infer +} + +/** + * PEM as text on both sides: a file picked in the browser is read into the same + * field, so uploading a file and pasting a certificate hit one endpoint. + */ +const pemMaterial = { + fullchainPem: z.string().min(1), + privateKeyPem: z.string().min(1) +} + +export namespace ImportAcmeCertificateCommand { + export const TSQ_url = `${ROOT}/certificates/import` + export const endpointDetails = { REQUEST_METHOD: 'post' } as const + + export const RequestBodySchema = z.object({ + ...pemMaterial, + isEnabled: z.optional(z.boolean()), + name: z.string().min(2).max(40), + nodes: z.optional( + z.array( + z.object({ + inboundTags: z.array(z.string()), + nodeUuid: z.uuid() + }) + ) + ) + }) + + export const ResponseSchema = z.object({ response: AcmeCertificateSchema }) + + export type RequestBody = z.infer + export type Response = z.infer +} + +export namespace ReimportAcmeCertificateCommand { + export const TSQ_url = `${ROOT}/certificates/:uuid/import` + export const endpointDetails = { REQUEST_METHOD: 'post' } as const + + export const RequestParamSchema = uuidParam + export const RequestBodySchema = z.object(pemMaterial) + export const ResponseSchema = z.object({ response: AcmeCertificateSchema }) + + export type RequestBody = z.infer + export type Response = z.infer +} + +export namespace GetAcmeCertificateEventsCommand { + export const TSQ_url = `${ROOT}/certificates/:uuid/events` + + export const RequestParamSchema = uuidParam + export const ResponseSchema = z.object({ + response: z.object({ + events: z.array(AcmeEventSchema), + total: z.number() + }) + }) + + export type RequestParam = z.infer + export type Response = z.infer +} + +export namespace GetAcmePersistRecordCommand { + export const TSQ_url = `${ROOT}/certificates/:uuid/persist-record` + + export const RequestParamSchema = uuidParam + export const ResponseSchema = z.object({ response: AcmePersistRecordSchema }) + + export type RequestParam = z.infer + export type Response = z.infer +} + +export namespace PublishAcmePersistRecordCommand { + export const TSQ_url = `${ROOT}/certificates/:uuid/persist-record/publish` + export const endpointDetails = { REQUEST_METHOD: 'post' } as const + + export const RequestParamSchema = uuidParam + export const ResponseSchema = z.object({ response: AcmePersistRecordSchema }) + + export type Response = z.infer +} diff --git a/src/shared/api/hooks/acme/acme.mutation.hooks.ts b/src/shared/api/hooks/acme/acme.mutation.hooks.ts new file mode 100644 index 000000000..f7f9f3b96 --- /dev/null +++ b/src/shared/api/hooks/acme/acme.mutation.hooks.ts @@ -0,0 +1,152 @@ +import { notifications } from '@mantine/notifications' + +import { + CreateAcmeCertificateCommand, + CreateAcmeCredentialCommand, + DeleteAcmeCertificateCommand, + DeleteAcmeCredentialCommand, + ImportAcmeCertificateCommand, + IssueAcmeCertificateCommand, + PublishAcmePersistRecordCommand, + ReimportAcmeCertificateCommand, + TestAcmeCredentialCommand, + UpdateAcmeCertificateCommand, + UpdateAcmeCredentialCommand +} from '@shared/api/contracts/acme.contract' + +import { createMutationHook } from '../../tsq-helpers' + +const notifyError = (title: string) => (error: unknown) => { + notifications.show({ + color: 'red', + message: error instanceof Error ? error.message : 'Request failed with unknown error.', + title + }) +} + +const notifySuccess = (message: string) => () => { + notifications.show({ color: 'teal', message, title: 'Success' }) +} + +export const useCreateAcmeCredential = createMutationHook({ + bodySchema: CreateAcmeCredentialCommand.RequestBodySchema, + endpoint: CreateAcmeCredentialCommand.TSQ_url, + requestMethod: CreateAcmeCredentialCommand.endpointDetails.REQUEST_METHOD, + responseSchema: CreateAcmeCredentialCommand.ResponseSchema, + rMutationParams: { + onError: notifyError('Create ACME credential'), + onSuccess: notifySuccess('Credential created successfully') + } +}) + +export const useUpdateAcmeCredential = createMutationHook({ + bodySchema: UpdateAcmeCredentialCommand.RequestBodySchema, + endpoint: UpdateAcmeCredentialCommand.TSQ_url, + requestMethod: UpdateAcmeCredentialCommand.endpointDetails.REQUEST_METHOD, + responseSchema: UpdateAcmeCredentialCommand.ResponseSchema, + rMutationParams: { + onError: notifyError('Update ACME credential'), + onSuccess: notifySuccess('Credential updated successfully') + } +}) + +export const useDeleteAcmeCredential = createMutationHook({ + endpoint: DeleteAcmeCredentialCommand.TSQ_url, + requestMethod: DeleteAcmeCredentialCommand.endpointDetails.REQUEST_METHOD, + responseSchema: DeleteAcmeCredentialCommand.ResponseSchema, + routeParamsSchema: DeleteAcmeCredentialCommand.RequestParamSchema, + rMutationParams: { + onError: notifyError('Delete ACME credential'), + onSuccess: notifySuccess('Credential deleted successfully') + } +}) + +export const useTestAcmeCredential = createMutationHook({ + endpoint: TestAcmeCredentialCommand.TSQ_url, + requestMethod: TestAcmeCredentialCommand.endpointDetails.REQUEST_METHOD, + responseSchema: TestAcmeCredentialCommand.ResponseSchema, + routeParamsSchema: TestAcmeCredentialCommand.RequestParamSchema, + rMutationParams: { + onError: notifyError('Test ACME credential') + } +}) + +export const useCreateAcmeCertificate = createMutationHook({ + bodySchema: CreateAcmeCertificateCommand.RequestBodySchema, + endpoint: CreateAcmeCertificateCommand.TSQ_url, + requestMethod: CreateAcmeCertificateCommand.endpointDetails.REQUEST_METHOD, + responseSchema: CreateAcmeCertificateCommand.ResponseSchema, + rMutationParams: { + onError: notifyError('Create certificate'), + onSuccess: notifySuccess('Certificate created successfully') + } +}) + +export const useUpdateAcmeCertificate = createMutationHook({ + bodySchema: UpdateAcmeCertificateCommand.RequestBodySchema, + endpoint: UpdateAcmeCertificateCommand.TSQ_url, + requestMethod: UpdateAcmeCertificateCommand.endpointDetails.REQUEST_METHOD, + responseSchema: UpdateAcmeCertificateCommand.ResponseSchema, + rMutationParams: { + onError: notifyError('Update certificate'), + onSuccess: notifySuccess('Certificate updated successfully') + } +}) + +export const useDeleteAcmeCertificate = createMutationHook({ + endpoint: DeleteAcmeCertificateCommand.TSQ_url, + requestMethod: DeleteAcmeCertificateCommand.endpointDetails.REQUEST_METHOD, + responseSchema: DeleteAcmeCertificateCommand.ResponseSchema, + routeParamsSchema: DeleteAcmeCertificateCommand.RequestParamSchema, + rMutationParams: { + onError: notifyError('Delete certificate'), + onSuccess: notifySuccess('Certificate deleted successfully') + } +}) + +export const useImportAcmeCertificate = createMutationHook({ + bodySchema: ImportAcmeCertificateCommand.RequestBodySchema, + endpoint: ImportAcmeCertificateCommand.TSQ_url, + requestMethod: ImportAcmeCertificateCommand.endpointDetails.REQUEST_METHOD, + responseSchema: ImportAcmeCertificateCommand.ResponseSchema, + rMutationParams: { + onError: notifyError('Import certificate'), + onSuccess: notifySuccess('Certificate imported') + } +}) + +export const useReimportAcmeCertificate = createMutationHook({ + bodySchema: ReimportAcmeCertificateCommand.RequestBodySchema, + endpoint: ReimportAcmeCertificateCommand.TSQ_url, + requestMethod: ReimportAcmeCertificateCommand.endpointDetails.REQUEST_METHOD, + responseSchema: ReimportAcmeCertificateCommand.ResponseSchema, + routeParamsSchema: ReimportAcmeCertificateCommand.RequestParamSchema, + rMutationParams: { + onError: notifyError('Replace certificate material'), + onSuccess: notifySuccess('Certificate replaced, bound nodes restarted') + } +}) + +export const useIssueAcmeCertificate = createMutationHook({ + endpoint: IssueAcmeCertificateCommand.TSQ_url, + requestMethod: IssueAcmeCertificateCommand.endpointDetails.REQUEST_METHOD, + responseSchema: IssueAcmeCertificateCommand.ResponseSchema, + routeParamsSchema: IssueAcmeCertificateCommand.RequestParamSchema, + rMutationParams: { + onError: notifyError('Issue certificate'), + // Issuance is queued, not awaited: the certificate status and its event + // log are where the outcome shows up. + onSuccess: notifySuccess('Issuance queued, watch the certificate status') + } +}) + +export const usePublishAcmePersistRecord = createMutationHook({ + endpoint: PublishAcmePersistRecordCommand.TSQ_url, + requestMethod: PublishAcmePersistRecordCommand.endpointDetails.REQUEST_METHOD, + responseSchema: PublishAcmePersistRecordCommand.ResponseSchema, + routeParamsSchema: PublishAcmePersistRecordCommand.RequestParamSchema, + rMutationParams: { + onError: notifyError('Publish authorization record'), + onSuccess: notifySuccess('Authorization record published') + } +}) diff --git a/src/shared/api/hooks/acme/acme.query.hooks.ts b/src/shared/api/hooks/acme/acme.query.hooks.ts new file mode 100644 index 000000000..d8da2810b --- /dev/null +++ b/src/shared/api/hooks/acme/acme.query.hooks.ts @@ -0,0 +1,73 @@ +import { createQueryKeys } from '@lukemorales/query-key-factory' + +import { + GetAcmeCertificateEventsCommand, + GetAcmeCertificatesCommand, + GetAcmeCredentialsCommand, + GetAcmePersistRecordCommand +} from '@shared/api/contracts/acme.contract' +import { sToMs } from '@shared/utils/time-utils' + +import { createGetQueryHook, errorHandler } from '../../tsq-helpers' + +export const acmeQueryKeys = createQueryKeys('acme', { + getCertificateEvents: (route: GetAcmeCertificateEventsCommand.RequestParam) => ({ + queryKey: [route] + }), + getCertificates: { + queryKey: null + }, + getCredentials: { + queryKey: null + }, + getPersistRecord: (route: GetAcmePersistRecordCommand.RequestParam) => ({ + queryKey: [route] + }) +}) + +export const useGetAcmeCredentials = createGetQueryHook({ + endpoint: GetAcmeCredentialsCommand.TSQ_url, + errorHandler: (error) => errorHandler(error, 'Get ACME credentials'), + getQueryKey: () => acmeQueryKeys.getCredentials.queryKey, + responseSchema: GetAcmeCredentialsCommand.ResponseSchema, + rQueryParams: { + staleTime: sToMs(10) + } +}) + +export const useGetAcmeCertificates = createGetQueryHook({ + endpoint: GetAcmeCertificatesCommand.TSQ_url, + errorHandler: (error) => errorHandler(error, 'Get ACME certificates'), + getQueryKey: () => acmeQueryKeys.getCertificates.queryKey, + responseSchema: GetAcmeCertificatesCommand.ResponseSchema, + rQueryParams: { + // An order takes tens of seconds and moves the certificate through + // ISSUING to ACTIVE or ERROR; polling is what makes that visible without + // the operator reloading the page. + refetchInterval: sToMs(10), + staleTime: sToMs(5) + } +}) + +export const useGetAcmeCertificateEvents = createGetQueryHook({ + endpoint: GetAcmeCertificateEventsCommand.TSQ_url, + errorHandler: (error) => errorHandler(error, 'Get certificate events'), + getQueryKey: ({ route }) => acmeQueryKeys.getCertificateEvents(route!).queryKey, + responseSchema: GetAcmeCertificateEventsCommand.ResponseSchema, + routeParamsSchema: GetAcmeCertificateEventsCommand.RequestParamSchema, + rQueryParams: { + refetchInterval: sToMs(10) + } +}) + +export const useGetAcmePersistRecord = createGetQueryHook({ + endpoint: GetAcmePersistRecordCommand.TSQ_url, + errorHandler: (error) => errorHandler(error, 'Get persistent authorization record'), + getQueryKey: ({ route }) => acmeQueryKeys.getPersistRecord(route!).queryKey, + responseSchema: GetAcmePersistRecordCommand.ResponseSchema, + routeParamsSchema: GetAcmePersistRecordCommand.RequestParamSchema, + rQueryParams: { + retry: false, + staleTime: sToMs(30) + } +}) diff --git a/src/shared/api/hooks/index.ts b/src/shared/api/hooks/index.ts index 4f8723376..ccc3e1ceb 100644 --- a/src/shared/api/hooks/index.ts +++ b/src/shared/api/hooks/index.ts @@ -1,3 +1,6 @@ +export * from './acme/acme.mutation.hooks' +export * from './acme/acme.query.hooks' + export * from './api-tokens/api-tokens.mutation.hooks' export * from './api-tokens/api-tokens.query.hooks' diff --git a/src/shared/api/hooks/keys-factory.ts b/src/shared/api/hooks/keys-factory.ts index 83437c9c9..c094014d9 100644 --- a/src/shared/api/hooks/keys-factory.ts +++ b/src/shared/api/hooks/keys-factory.ts @@ -1,5 +1,6 @@ import { inferQueryKeyStore, mergeQueryKeys } from '@lukemorales/query-key-factory' +import { acmeQueryKeys } from './acme/acme.query.hooks' import { apiTokensQueryKeys } from './api-tokens/api-tokens.query.hooks' import { authQueryKeys } from './auth/auth.query.hooks' import { bandwidthStatsQueryKeys } from './bandwidth-stats/bandwidth-stats.query.hooks' @@ -43,7 +44,8 @@ export const QueryKeys = mergeQueryKeys( subpageConfigsQueryKeys, bandwidthStatsQueryKeys, connectionsQueryKeys, - nodePluginsQueryKeys + nodePluginsQueryKeys, + acmeQueryKeys ) export type TQueryKeys = inferQueryKeyStore diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts index f3f5273e7..a5a1b7540 100644 --- a/src/shared/constants/routes.ts +++ b/src/shared/constants/routes.ts @@ -23,6 +23,7 @@ export const ROUTES = { INTERNAL_SQUADS: '/dashboard/management/internal-squads', EXTERNAL_SQUADS: '/dashboard/management/external-squads', REMNAWAVE_SETTINGS: '/dashboard/management/settings', + ACME: '/dashboard/management/acme', NODE_PLUGINS: { ROOT: '/dashboard/management/plugins', NODE_PLUGIN_BY_UUID: '/dashboard/management/plugins/:uuid' diff --git a/src/widgets/dashboard/acme/certificate-card/CertificateCard.module.css b/src/widgets/dashboard/acme/certificate-card/CertificateCard.module.css new file mode 100644 index 000000000..55b9d416d --- /dev/null +++ b/src/widgets/dashboard/acme/certificate-card/CertificateCard.module.css @@ -0,0 +1,86 @@ +.certRow { + position: relative; + background: linear-gradient( + 135deg, + var(--mantine-color-dark-6) 0%, + var(--mantine-color-dark-7) 100% + ); + border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); + border-radius: var(--mantine-radius-md); + padding: 12px 48px 12px 16px; + margin-bottom: 8px; + cursor: pointer; +} + +.certRow:hover { + border-color: light-dark(var(--mantine-color-blue-3), var(--mantine-color-blue-5)) !important; +} + +.menuButton { + position: absolute; + top: 50%; + right: 8px; + transform: translateY(-50%); +} + +.desktopGrid { + display: grid; + grid-template-columns: minmax(0, 5.5fr) minmax(0, 2.5fr) minmax(0, 2fr) minmax(0, 2fr); + gap: var(--mantine-spacing-md); + align-items: center; +} + +.nameContainer { + min-width: 0; + flex: 1; +} + +.certName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.domainsText { + font-family: var(--mantine-font-family-monospace); + transition: color 0.15s ease; + cursor: copy; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.domainsText:hover { + color: var(--mantine-color-blue-6) !important; +} + +.icon { + color: var(--mantine-color-dimmed); + flex-shrink: 0; +} + +@media (max-width: 48em) { + .desktopGrid { + display: none; + } + + .certRow { + padding: 10px 36px 10px 10px; + margin-bottom: 6px; + } + + .menuButton { + right: 4px; + } + + .certName { + font-size: 0.9rem; + max-width: calc(100vw - 180px); + } + + .domainsText { + max-width: calc(100vw - 80px); + } +} diff --git a/src/widgets/dashboard/acme/certificate-card/certificate-card.widget.tsx b/src/widgets/dashboard/acme/certificate-card/certificate-card.widget.tsx new file mode 100644 index 000000000..6abf1051d --- /dev/null +++ b/src/widgets/dashboard/acme/certificate-card/certificate-card.widget.tsx @@ -0,0 +1,381 @@ +import { ActionIcon, Badge, Box, Flex, Menu, Progress, Text, Tooltip } from '@mantine/core' +import { useClipboard } from '@mantine/hooks' +import { notifications } from '@mantine/notifications' +import { memo } from 'react' +import { PiGlobeSimple, PiPencil, PiTrashDuotone } from 'react-icons/pi' +import { + TbDotsVertical, + TbFileUpload, + TbKey, + TbListDetails, + TbRefresh, + TbServer +} from 'react-icons/tb' +import { z } from 'zod' + +import { + ACME_CERTIFICATE_SOURCE, + ACME_CERTIFICATE_STATUS, + AcmeCertificateSchema, + TAcmeCertificateStatus +} from '@shared/api/contracts/acme.contract' +import { useIsMobile } from '@shared/hooks' + +import classes from './CertificateCard.module.css' + +type Certificate = z.infer + +interface IProps { + certificate: Certificate + onDelete: (certificate: Certificate) => void + onDetails: (certificate: Certificate) => void + onEdit: (certificate: Certificate) => void + onIssue: (certificate: Certificate) => void + onReplace: (certificate: Certificate) => void +} + +const STATUS_COLORS: Record = { + [ACME_CERTIFICATE_STATUS.ACTIVE]: 'teal', + [ACME_CERTIFICATE_STATUS.AWAITING_DNS]: 'yellow', + [ACME_CERTIFICATE_STATUS.ERROR]: 'red', + [ACME_CERTIFICATE_STATUS.ISSUING]: 'blue', + [ACME_CERTIFICATE_STATUS.PENDING]: 'gray' +} + +const getCertificateColors = (certificate: Certificate) => { + if (!certificate.isEnabled) { + return { + backgroundColor: 'rgba(107, 114, 128, 0.15)', + borderColor: 'rgba(107, 114, 128, 0.3)', + boxShadow: 'rgba(107, 114, 128, 0.2)' + } + } + switch (certificate.status) { + case ACME_CERTIFICATE_STATUS.ACTIVE: + return { + backgroundColor: 'rgba(45, 212, 191, 0.15)', + borderColor: 'rgba(45, 212, 191, 0.3)', + boxShadow: 'rgba(45, 212, 191, 0.2)' + } + case ACME_CERTIFICATE_STATUS.AWAITING_DNS: + case ACME_CERTIFICATE_STATUS.ISSUING: + return { + backgroundColor: 'rgba(245, 158, 11, 0.15)', + borderColor: 'rgba(245, 158, 11, 0.3)', + boxShadow: 'rgba(245, 158, 11, 0.2)' + } + case ACME_CERTIFICATE_STATUS.ERROR: + return { + backgroundColor: 'rgba(239, 68, 68, 0.15)', + borderColor: 'rgba(239, 68, 68, 0.3)', + boxShadow: 'rgba(239, 68, 68, 0.2)' + } + default: + return { + backgroundColor: 'rgba(107, 114, 128, 0.15)', + borderColor: 'rgba(107, 114, 128, 0.3)', + boxShadow: 'rgba(107, 114, 128, 0.2)' + } + } +} + +/** Days left, or null when the certificate has never been issued. */ +function daysLeft(expiresAt: Date | null): null | number { + if (!expiresAt) { + return null + } + + return Math.floor((new Date(expiresAt).getTime() - Date.now()) / 86_400_000) +} + +/** Elapsed share of the certificate lifetime, 0-100. */ +function lifetimeElapsedPercent(issuedAt: Date | null, expiresAt: Date | null): null | number { + if (!issuedAt || !expiresAt) { + return null + } + + const start = new Date(issuedAt).getTime() + const end = new Date(expiresAt).getTime() + if (end <= start) { + return 100 + } + + return Math.min(100, Math.max(0, Math.round(((Date.now() - start) / (end - start)) * 100))) +} + +export const AcmeCertificateCardWidget = memo((props: IProps) => { + const { certificate, onDelete, onDetails, onEdit, onIssue, onReplace } = props + + const isMobile = useIsMobile() + const clipboard = useClipboard({ timeout: 500 }) + + const isImported = certificate.source === ACME_CERTIFICATE_SOURCE.IMPORTED + const left = daysLeft(certificate.expiresAt) + const elapsed = lifetimeElapsedPercent(certificate.issuedAt, certificate.expiresAt) + const { backgroundColor, borderColor, boxShadow } = getCertificateColors(certificate) + + const expiryColor = + left === null || left <= 0 + ? 'red.6' + : left <= certificate.renewBeforeDays + ? 'yellow.6' + : 'teal.6' + + const domainsLine = certificate.domains.join(', ') + + const handleCopyDomains = (e: React.MouseEvent) => { + e.stopPropagation() + clipboard.copy(domainsLine) + notifications.show({ + message: domainsLine, + title: 'Copied', + color: 'teal' + }) + } + + const statusBadge = ( + + + {certificate.status} + + + ) + + const nodesBadge = ( + binding.nodeName ?? binding.nodeUuid) + .join(', ') + } + > + 0 ? 'blue' : 'gray'} + leftSection={} + miw="6ch" + size="lg" + variant="outline" + > + {certificate.nodes.length} + + + ) + + const secondaryBadges = ( + <> + {isImported && ( + + + imported + + + )} + + {!certificate.isEnabled && ( + + disabled + + )} + > + ) + + const expiryBlock = + left === null ? ( + + not issued yet + + ) : ( + + + + {left > 0 ? `${left} d left` : left === 0 ? 'expires today' : 'expired'} + + + {certificate.expiresAt + ? new Date(certificate.expiresAt).toLocaleDateString() + : ''} + + + + + ) + + const credentialBadge = certificate.credentialName && ( + + } + size="lg" + style={{ + maxWidth: '18ch', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' + }} + variant="light" + > + {certificate.credentialName} + + + ) + + const actionsMenu = ( + e.stopPropagation()}> + + + + + + + + + } + onClick={() => onEdit(certificate)} + > + Edit + + + {isImported ? ( + } + onClick={() => onReplace(certificate)} + > + Upload new material + + ) : ( + } + onClick={() => onIssue(certificate)} + > + Issue now + + )} + + } + onClick={() => onDetails(certificate)} + > + Details + + + + + } + onClick={() => onDelete(certificate)} + > + Delete + + + + + ) + + return ( + onEdit(certificate)} + style={{ + background: `linear-gradient( + 135deg, + ${backgroundColor} 0%, + var(--mantine-color-dark-7) 100% + )`, + borderColor, + boxShadow + }} + > + {actionsMenu} + + {!isMobile && ( + + + + {statusBadge} + + + + {certificate.name} + + + + + {secondaryBadges} + + + + + + + + + + {domainsLine} + + + + + + {expiryBlock} + + + + {credentialBadge} + {nodesBadge} + + + + )} + + {isMobile && ( + + + {statusBadge} + + + + {certificate.name} + + + + + + + + {domainsLine} + + + + {expiryBlock} + + + {credentialBadge} + {nodesBadge} + {secondaryBadges} + + + )} + + ) +}) diff --git a/src/widgets/dashboard/acme/certificate-details-drawer/certificate-details-drawer.widget.tsx b/src/widgets/dashboard/acme/certificate-details-drawer/certificate-details-drawer.widget.tsx new file mode 100644 index 000000000..7865b9f47 --- /dev/null +++ b/src/widgets/dashboard/acme/certificate-details-drawer/certificate-details-drawer.widget.tsx @@ -0,0 +1,182 @@ +import { + Alert, + Badge, + Button, + Code, + CopyButton, + Drawer, + Group, + Loader, + Paper, + ScrollArea, + Stack, + Text, + Timeline +} from '@mantine/core' +import { TbAlertTriangle, TbCertificate, TbCheck, TbCopy, TbUpload } from 'react-icons/tb' +import { z } from 'zod' + +import { queryClient } from '@shared/api' +import { ACME_CHALLENGE_TYPE, AcmeCertificateSchema } from '@shared/api/contracts/acme.contract' +import { + QueryKeys, + useGetAcmeCertificateEvents, + useGetAcmePersistRecord, + usePublishAcmePersistRecord +} from '@shared/api/hooks' +import { BaseOverlayHeader } from '@shared/ui/overlays/base-overlay-header' + +type Certificate = z.infer + +interface IProps { + certificate: Certificate | null + onClose: () => void +} + +export const AcmeCertificateDetailsDrawerWidget = ({ certificate, onClose }: IProps) => { + const isPersist = certificate?.challengeType === ACME_CHALLENGE_TYPE.DNS_PERSIST_01 + + const { data: events, isLoading: isEventsLoading } = useGetAcmeCertificateEvents({ + query: {}, + route: { uuid: certificate?.uuid ?? '' }, + rQueryParams: { enabled: Boolean(certificate) } + }) + + const { data: persistRecord, isLoading: isRecordLoading } = useGetAcmePersistRecord({ + query: {}, + route: { uuid: certificate?.uuid ?? '' }, + rQueryParams: { enabled: Boolean(certificate) && isPersist } + }) + + const publishRecord = usePublishAcmePersistRecord({}) + + const handlePublish = async () => { + if (!certificate) { + return + } + + await publishRecord.mutateAsync({ route: { uuid: certificate.uuid } }) + await queryClient.invalidateQueries({ + queryKey: QueryKeys.acme.getPersistRecord({ uuid: certificate.uuid }).queryKey + }) + } + + return ( + + } + > + + {certificate?.lastError && ( + } variant="light"> + {certificate.lastError} + + )} + + {isPersist && ( + + + + Persistent authorization record + + {persistRecord?.isPublished ? ( + + found in DNS + + ) : ( + + not visible yet + + )} + + + {isRecordLoading && } + + {persistRecord && ( + <> + + Publish this TXT record once. Every issuance and renewal + afterwards needs no DNS access at all. + + + {persistRecord.name} + {persistRecord.value} + + + + {({ copied, copy }) => ( + + ) : ( + + ) + } + onClick={copy} + variant="default" + > + {copied ? 'Copied' : 'Copy record'} + + )} + + + {persistRecord.canPublish && ( + } + loading={publishRecord.isPending} + onClick={handlePublish} + > + Publish via credential + + )} + + > + )} + + + )} + + + Log + + {isEventsLoading && } + + + + {events?.events.map((event) => ( + + {event.message} + + ))} + + + {events?.events.length === 0 && ( + + Nothing recorded yet. + + )} + + + + + ) +} diff --git a/src/widgets/dashboard/acme/certificate-modal/certificate-modal.widget.tsx b/src/widgets/dashboard/acme/certificate-modal/certificate-modal.widget.tsx new file mode 100644 index 000000000..145f94fa8 --- /dev/null +++ b/src/widgets/dashboard/acme/certificate-modal/certificate-modal.widget.tsx @@ -0,0 +1,356 @@ +import { + Alert, + Badge, + Button, + Divider, + Group, + Modal, + MultiSelect, + NumberInput, + Select, + Stack, + Switch, + TagsInput, + Text, + TextInput +} from '@mantine/core' +import { useForm } from '@mantine/form' +import { GetNodesCommand } from '@remnawave/backend-contract' +import { useEffect } from 'react' +import { TbAlertTriangle, TbCertificate, TbInfoCircle } from 'react-icons/tb' +import { z } from 'zod' + +import { queryClient } from '@shared/api' +import { + ACME_CERTIFICATE_SOURCE, + ACME_CHALLENGE_TYPE, + ACME_DIRECTORY_PRESETS, + ACME_KEY_TYPES, + ACME_PROVIDER, + ACME_PROVIDER_REGISTRY, + AcmeCertificateSchema, + AcmeCredentialSchema +} from '@shared/api/contracts/acme.contract' +import { QueryKeys, useCreateAcmeCertificate, useUpdateAcmeCertificate } from '@shared/api/hooks' +import { ModalFooter } from '@shared/ui/modal-footer' +import { BaseOverlayHeader } from '@shared/ui/overlays/base-overlay-header' + +type Certificate = z.infer +type Credential = z.infer + +const PROVIDER_LABELS: Record = Object.fromEntries( + ACME_PROVIDER_REGISTRY.map((info) => [info.provider, info.label]) +) + +interface IProps { + certificate: Certificate | null + credentials: Credential[] + nodes: GetNodesCommand.Response['response'] + onClose: () => void + opened: boolean +} + +export const AcmeCertificateModalWidget = (props: IProps) => { + const { certificate, credentials, nodes, onClose, opened } = props + + const isEdit = certificate !== null + + // For an imported certificate everything about the material is read from the + // PEM, so only the name, the bindings and the enabled flag are editable — + // the backend rejects the rest anyway. + const isImported = certificate?.source === ACME_CERTIFICATE_SOURCE.IMPORTED + + const createCertificate = useCreateAcmeCertificate({}) + const updateCertificate = useUpdateAcmeCertificate({}) + + const form = useForm({ + initialValues: { + challengeType: ACME_CHALLENGE_TYPE.DNS_01 as string, + credentialUuid: '', + directoryUrl: ACME_DIRECTORY_PRESETS[0].url as string, + domains: [] as string[], + eabHmacKey: '', + eabKid: '', + email: '', + isEnabled: true, + keyType: 'ECDSA_P256' as string, + name: '', + nodeUuids: [] as string[], + renewBeforeDays: 30 + }, + validate: { + credentialUuid: (value) => (isImported || value ? null : 'Pick a credential'), + domains: (value) => + !isImported && value.length === 0 ? 'At least one domain is required' : null, + email: (value) => + isImported || /^[^@\s]+@[^@\s]+$/.test(value) ? null : 'Enter a valid e-mail', + name: (value) => (value.trim().length < 2 ? 'Name is too short' : null) + } + }) + + useEffect(() => { + if (!opened) { + return + } + + form.setValues({ + challengeType: certificate?.challengeType ?? ACME_CHALLENGE_TYPE.DNS_01, + credentialUuid: certificate?.credentialUuid ?? credentials[0]?.uuid ?? '', + directoryUrl: certificate?.directoryUrl ?? ACME_DIRECTORY_PRESETS[0].url, + domains: certificate?.domains ?? [], + eabHmacKey: '', + eabKid: certificate?.eabKid ?? '', + email: certificate?.email ?? '', + isEnabled: certificate?.isEnabled ?? true, + keyType: certificate?.keyType ?? 'ECDSA_P256', + name: certificate?.name ?? '', + nodeUuids: certificate?.nodes.map((binding) => binding.nodeUuid) ?? [], + renewBeforeDays: certificate?.renewBeforeDays ?? 30 + }) + form.resetDirty() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [opened, certificate?.uuid]) + + const handleSubmit = form.onSubmit(async (values) => { + // Existing tag selections are preserved; the modal binds whole nodes, + // which is the common case (every TLS inbound of the node). + const nodeBindings = values.nodeUuids.map((nodeUuid) => ({ + inboundTags: + certificate?.nodes.find((binding) => binding.nodeUuid === nodeUuid)?.inboundTags ?? + [], + nodeUuid + })) + + if (isImported && certificate) { + await updateCertificate.mutateAsync({ + variables: { + isEnabled: values.isEnabled, + name: values.name, + nodes: nodeBindings, + uuid: certificate.uuid + } + }) + } else { + const body = { + challengeType: values.challengeType as never, + credentialUuid: values.credentialUuid, + directoryUrl: values.directoryUrl, + domains: values.domains, + ...(values.eabKid ? { eabKid: values.eabKid } : {}), + ...(values.eabHmacKey ? { eabHmacKey: values.eabHmacKey } : {}), + email: values.email, + isEnabled: values.isEnabled, + keyType: values.keyType as never, + name: values.name, + nodes: nodeBindings, + renewBeforeDays: values.renewBeforeDays + } + + if (isEdit) { + await updateCertificate.mutateAsync({ + variables: { ...body, uuid: certificate.uuid } + }) + } else { + await createCertificate.mutateAsync({ variables: body }) + } + } + + await queryClient.invalidateQueries({ queryKey: QueryKeys.acme.getCertificates.queryKey }) + await queryClient.invalidateQueries({ queryKey: QueryKeys.acme.getCredentials.queryKey }) + + onClose() + }) + + const selectedCredential = credentials.find( + (credential) => credential.uuid === form.values.credentialUuid + ) + + const isManual = selectedCredential?.provider === ACME_PROVIDER.MANUAL + const isDnsPersist = form.values.challengeType === ACME_CHALLENGE_TYPE.DNS_PERSIST_01 + const isProductionDirectory = !ACME_DIRECTORY_PRESETS.find( + (preset) => preset.url === form.values.directoryUrl + )?.isStaging + + return ( + + } + > + + + + + {isImported && ( + } variant="light"> + Imported certificate. Its domains, validity and key come from the + uploaded material — to change them, upload a new certificate. Here you + can rename it and change where it is delivered. + + )} + + {isImported && ( + + {certificate.domains.map((domain) => ( + + {domain} + + ))} + + )} + + {!isImported && ( + <> + + + ({ + label: `${credential.name} (${PROVIDER_LABELS[credential.provider] ?? credential.provider})`, + value: credential.uuid + }))} + label="Credential" + required + {...form.getInputProps('credentialUuid')} + /> + + + > + )} + + {!isImported && isManual && !isDnsPersist && ( + } variant="light"> + A manual credential cannot answer dns-01: that challenge needs a fresh + record within minutes of every order. Use dns-persist-01, or a + credential that can publish records. + + )} + + {!isImported && isDnsPersist && isProductionDirectory && ( + } variant="light"> + dns-persist-01 is not enabled on production CAs yet. Rehearse it on a + staging directory; a production order will be refused by the CA. + + )} + + {!isImported && ( + <> + ({ + label: preset.name, + value: preset.url + }))} + description="Staging first: it does not spend the production rate limit" + label="Certificate authority" + searchable + {...form.getInputProps('directoryUrl')} + /> + + + + ({ + label: keyType, + value: keyType + }))} + label="Key type" + {...form.getInputProps('keyType')} + /> + + + + + + + + > + )} + + + + ({ label: node.name, value: node.uuid }))} + label="Nodes" + placeholder="Pick the nodes that serve these names" + searchable + {...form.getInputProps('nodeUuids')} + /> + + + The certificate is injected into each bound node's own config, never into + the shared config profile — nodes that are not bound never receive the + private key. + + + + + + + + Cancel + + + {isEdit ? 'Save' : 'Create'} + + + + + ) +} diff --git a/src/widgets/dashboard/acme/certificates-list/certificates-list.widget.tsx b/src/widgets/dashboard/acme/certificates-list/certificates-list.widget.tsx new file mode 100644 index 000000000..6b44e5ee2 --- /dev/null +++ b/src/widgets/dashboard/acme/certificates-list/certificates-list.widget.tsx @@ -0,0 +1,163 @@ +import { Button, Center, Group, Stack, Text, ThemeIcon } from '@mantine/core' +import { modals } from '@mantine/modals' +import { notifications } from '@mantine/notifications' +import { GetNodesCommand } from '@remnawave/backend-contract' +import { useState } from 'react' +import { TbCertificate, TbFileUpload, TbPlus } from 'react-icons/tb' +import { z } from 'zod' + +import { queryClient } from '@shared/api' +import { AcmeCertificateSchema, AcmeCredentialSchema } from '@shared/api/contracts/acme.contract' +import { QueryKeys, useDeleteAcmeCertificate, useIssueAcmeCertificate } from '@shared/api/hooks' + +import { AcmeCertificateCardWidget } from '../certificate-card/certificate-card.widget' +import { AcmeCertificateDetailsDrawerWidget } from '../certificate-details-drawer/certificate-details-drawer.widget' +import { AcmeCertificateModalWidget } from '../certificate-modal/certificate-modal.widget' +import { AcmeImportCertificateModalWidget } from '../import-certificate-modal/import-certificate-modal.widget' + +type Certificate = z.infer +type Credential = z.infer + +interface IProps { + certificates: Certificate[] + credentials: Credential[] + nodes: GetNodesCommand.Response['response'] +} + +export const AcmeCertificatesListWidget = (props: IProps) => { + const { certificates, credentials, nodes } = props + + const [editing, setEditing] = useState(null) + const [isModalOpen, setIsModalOpen] = useState(false) + const [details, setDetails] = useState(null) + const [replacing, setReplacing] = useState(null) + const [isImportOpen, setIsImportOpen] = useState(false) + + const issueCertificate = useIssueAcmeCertificate({}) + const deleteCertificate = useDeleteAcmeCertificate({}) + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: QueryKeys.acme.getCertificates.queryKey }) + + const handleIssue = async (certificate: Certificate) => { + await issueCertificate.mutateAsync({ route: { uuid: certificate.uuid } }) + await invalidate() + + notifications.show({ + color: 'teal', + message: 'Issuance queued', + title: certificate.name + }) + } + + const handleDelete = (certificate: Certificate) => { + modals.openConfirmModal({ + cancelProps: { variant: 'subtle' }, + centered: true, + children: ( + + Delete {certificate.name}? Nodes keep serving the certificate they + already have until they are restarted. + + ), + confirmProps: { color: 'red', variant: 'soft' }, + labels: { cancel: 'Cancel', confirm: 'Delete' }, + onConfirm: async () => { + await deleteCertificate.mutateAsync({ route: { uuid: certificate.uuid } }) + await invalidate() + }, + title: 'Delete certificate' + }) + } + + return ( + + + } + onClick={() => { + setReplacing(null) + setIsImportOpen(true) + }} + variant="default" + > + Import + + + } + onClick={() => { + setEditing(null) + setIsModalOpen(true) + }} + > + Add certificate + + + + {certificates.length === 0 && ( + + + + + + + + + No certificates yet + + + {credentials.length === 0 + ? 'Add a credential first — a certificate needs one to answer DNS challenges.' + : 'Create a certificate, or import one issued elsewhere.'} + + + + + )} + + {certificates.length > 0 && ( + + {certificates.map((certificate) => ( + { + setEditing(cert) + setIsModalOpen(true) + }} + onIssue={handleIssue} + onReplace={(cert) => { + setReplacing(cert) + setIsImportOpen(true) + }} + /> + ))} + + )} + + setIsModalOpen(false)} + opened={isModalOpen} + /> + + setIsImportOpen(false)} + opened={isImportOpen} + /> + + setDetails(null)} + /> + + ) +} diff --git a/src/widgets/dashboard/acme/credential-modal/credential-modal.widget.tsx b/src/widgets/dashboard/acme/credential-modal/credential-modal.widget.tsx new file mode 100644 index 000000000..c24997a3f --- /dev/null +++ b/src/widgets/dashboard/acme/credential-modal/credential-modal.widget.tsx @@ -0,0 +1,187 @@ +import { Button, Modal, Select, Stack, Text, TextInput } from '@mantine/core' +import { useForm } from '@mantine/form' +import { useEffect } from 'react' +import { TbKey } from 'react-icons/tb' +import { z } from 'zod' + +import { queryClient } from '@shared/api' +import { + ACME_PROVIDER, + ACME_PROVIDER_REGISTRY, + AcmeCredentialSchema +} from '@shared/api/contracts/acme.contract' +import { QueryKeys, useCreateAcmeCredential, useUpdateAcmeCredential } from '@shared/api/hooks' +import { ModalFooter } from '@shared/ui/modal-footer' +import { BaseOverlayHeader } from '@shared/ui/overlays/base-overlay-header' + +type Credential = z.infer + +interface IProps { + credential: Credential | null + onClose: () => void + opened: boolean +} + +const PROVIDER_OPTIONS = ACME_PROVIDER_REGISTRY.map((info) => ({ + label: info.label, + value: info.provider as string +})) + +export const AcmeCredentialModalWidget = (props: IProps) => { + const { credential, onClose, opened } = props + + const isEdit = credential !== null + + const createCredential = useCreateAcmeCredential({}) + const updateCredential = useUpdateAcmeCredential({}) + + const form = useForm({ + initialValues: { + config: {} as Record, + name: '', + provider: ACME_PROVIDER.CLOUDFLARE as string + }, + validate: { + config: (value, values) => { + const info = ACME_PROVIDER_REGISTRY.find( + (entry) => entry.provider === values.provider + ) + + for (const field of info?.fields ?? []) { + // On edit an empty secret means "keep what is stored". + if (field.required && !value[field.key] && !(isEdit && field.secret)) { + return `${field.label} is required` + } + } + + return null + }, + name: (value) => (value.trim().length < 2 ? 'Name is too short' : null) + } + }) + + useEffect(() => { + if (!opened) { + return + } + + form.setValues({ + config: { ...credential?.config }, + name: credential?.name ?? '', + provider: credential?.provider ?? ACME_PROVIDER.CLOUDFLARE + }) + form.resetDirty() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [opened, credential?.uuid]) + + const invalidate = async () => { + await queryClient.invalidateQueries({ queryKey: QueryKeys.acme.getCredentials.queryKey }) + } + + const handleSubmit = form.onSubmit(async (values) => { + // Empty values are dropped: for secrets on edit that means "keep stored". + const config = Object.fromEntries( + Object.entries(values.config).filter(([, value]) => value !== '') + ) + + if (isEdit) { + await updateCredential.mutateAsync({ + variables: { config, name: values.name, uuid: credential.uuid } + }) + } else { + await createCredential.mutateAsync({ + variables: { config, name: values.name, provider: values.provider as never } + }) + } + + await invalidate() + onClose() + }) + + const providerInfo = ACME_PROVIDER_REGISTRY.find( + (entry) => entry.provider === form.values.provider + ) + + return ( + + } + > + + + + + + + {providerInfo?.description && ( + + {providerInfo.description} + + )} + + {providerInfo?.fields.map((field) => ( + + ))} + + {form.errors.config && ( + + {form.errors.config} + + )} + + + + + Cancel + + + {isEdit ? 'Save' : 'Create'} + + + + + ) +} diff --git a/src/widgets/dashboard/acme/credentials-grid/credentials-grid.widget.tsx b/src/widgets/dashboard/acme/credentials-grid/credentials-grid.widget.tsx new file mode 100644 index 000000000..c91fcc446 --- /dev/null +++ b/src/widgets/dashboard/acme/credentials-grid/credentials-grid.widget.tsx @@ -0,0 +1,192 @@ +import { Badge, Button, Center, Group, Menu, Stack, Text, ThemeIcon, Tooltip } from '@mantine/core' +import { modals } from '@mantine/modals' +import { notifications } from '@mantine/notifications' +import { useState } from 'react' +import { PiPencil, PiTrashDuotone } from 'react-icons/pi' +import { TbCertificate, TbKey, TbPlugConnected, TbPlus } from 'react-icons/tb' +import { z } from 'zod' + +import { queryClient } from '@shared/api' +import { ACME_PROVIDER_REGISTRY, AcmeCredentialSchema } from '@shared/api/contracts/acme.contract' +import { QueryKeys, useDeleteAcmeCredential, useTestAcmeCredential } from '@shared/api/hooks' +import { EntityCardShared } from '@shared/ui/entity-card' +import { VirtualizedDndGrid } from '@shared/ui/virtualized-dnd-grid' + +import { AcmeCredentialModalWidget } from '../credential-modal/credential-modal.widget' + +type Credential = z.infer + +const PROVIDER_LABELS: Record = Object.fromEntries( + ACME_PROVIDER_REGISTRY.map((info) => [info.provider, info.label]) +) + +interface IProps { + credentials: Credential[] +} + +export const AcmeCredentialsGridWidget = ({ credentials }: IProps) => { + const [editing, setEditing] = useState(null) + const [isModalOpen, setIsModalOpen] = useState(false) + + const testCredential = useTestAcmeCredential({}) + const deleteCredential = useDeleteAcmeCredential({}) + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: QueryKeys.acme.getCredentials.queryKey }) + + const handleTest = async (credential: Credential) => { + const result = await testCredential.mutateAsync({ route: { uuid: credential.uuid } }) + + notifications.show({ + color: result.isOk ? 'teal' : 'red', + message: result.message, + title: credential.name + }) + } + + const handleDelete = (credential: Credential) => { + modals.openConfirmModal({ + cancelProps: { variant: 'subtle' }, + centered: true, + children: ( + + Delete credential {credential.name}? Certificates using it would no + longer be able to renew. + + ), + confirmProps: { color: 'red', variant: 'soft' }, + labels: { cancel: 'Cancel', confirm: 'Delete' }, + onConfirm: async () => { + await deleteCredential.mutateAsync({ route: { uuid: credential.uuid } }) + await invalidate() + }, + title: 'Delete credential' + }) + } + + const renderCard = (credential: Credential) => ( + 0}> + + 0} + onClick={() => { + setEditing(credential) + setIsModalOpen(true) + }} + > + + + + + + + {PROVIDER_LABELS[credential.provider] ?? credential.provider} + + + + {credential.hasSecret ? 'secret stored' : 'no secret'} + + + + 0 ? 'blue' : 'gray'} + leftSection={} + size="lg" + variant="soft" + > + {credential.certificatesCount} + + + + + + + + } + onClick={() => { + setEditing(credential) + setIsModalOpen(true) + }} + > + Edit + + + + } + onClick={() => handleTest(credential)} + > + Test + + + } + onClick={() => handleDelete(credential)} + > + Delete + + + + + ) + + return ( + + + } + onClick={() => { + setEditing(null) + setIsModalOpen(true) + }} + > + Add credential + + + + {credentials.length === 0 && ( + + + + + + + + + No credentials yet + + + Credentials answer DNS challenges. They are reusable: many + certificates can share one. + + + + + )} + + {credentials.length > 0 && ( + + )} + + setIsModalOpen(false)} + opened={isModalOpen} + /> + + ) +} diff --git a/src/widgets/dashboard/acme/import-certificate-modal/import-certificate-modal.widget.tsx b/src/widgets/dashboard/acme/import-certificate-modal/import-certificate-modal.widget.tsx new file mode 100644 index 000000000..5d9f75397 --- /dev/null +++ b/src/widgets/dashboard/acme/import-certificate-modal/import-certificate-modal.widget.tsx @@ -0,0 +1,263 @@ +import { + Alert, + Button, + FileButton, + Group, + Modal, + MultiSelect, + Stack, + Switch, + Text, + Textarea, + TextInput +} from '@mantine/core' +import { useForm } from '@mantine/form' +import { GetNodesCommand } from '@remnawave/backend-contract' +import { useEffect, useState } from 'react' +import { TbFileUpload, TbInfoCircle } from 'react-icons/tb' +import { z } from 'zod' + +import { queryClient } from '@shared/api' +import { AcmeCertificateSchema } from '@shared/api/contracts/acme.contract' +import { QueryKeys, useImportAcmeCertificate, useReimportAcmeCertificate } from '@shared/api/hooks' +import { ModalFooter } from '@shared/ui/modal-footer' +import { BaseOverlayHeader } from '@shared/ui/overlays/base-overlay-header' + +type Certificate = z.infer + +interface IProps { + /** Set when replacing the material of an existing imported certificate. */ + certificate: Certificate | null + nodes: GetNodesCommand.Response['response'] + onClose: () => void + opened: boolean +} + +export const AcmeImportCertificateModalWidget = (props: IProps) => { + const { certificate, nodes, onClose, opened } = props + + const isReplace = certificate !== null + + const importCertificate = useImportAcmeCertificate({}) + const reimportCertificate = useReimportAcmeCertificate({}) + + const [fileError, setFileError] = useState(null) + + const form = useForm({ + initialValues: { + fullchainPem: '', + isEnabled: true, + name: '', + nodeUuids: [] as string[], + privateKeyPem: '' + }, + validate: { + fullchainPem: (value) => + value.includes('-----BEGIN CERTIFICATE-----') ? null : 'Expected a PEM certificate', + name: (value) => (isReplace || value.trim().length >= 2 ? null : 'Name is too short'), + privateKeyPem: (value) => + value.includes('-----BEGIN') ? null : 'Expected a PEM private key' + } + }) + + useEffect(() => { + if (!opened) { + return + } + + setFileError(null) + form.setValues({ + fullchainPem: '', + isEnabled: certificate?.isEnabled ?? true, + name: certificate?.name ?? '', + nodeUuids: certificate?.nodes.map((binding) => binding.nodeUuid) ?? [], + privateKeyPem: '' + }) + form.resetDirty() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [opened, certificate?.uuid]) + + /** Files are read here and land in the same field as pasted text. */ + const readFileInto = (field: 'fullchainPem' | 'privateKeyPem') => async (file: File | null) => { + if (!file) { + return + } + + try { + form.setFieldValue(field, await file.text()) + setFileError(null) + } catch (error) { + setFileError(error instanceof Error ? error.message : 'Could not read the file') + } + } + + const handleSubmit = form.onSubmit(async (values) => { + if (isReplace) { + await reimportCertificate.mutateAsync({ + route: { uuid: certificate.uuid }, + variables: { + fullchainPem: values.fullchainPem, + privateKeyPem: values.privateKeyPem + } + }) + } else { + await importCertificate.mutateAsync({ + variables: { + fullchainPem: values.fullchainPem, + isEnabled: values.isEnabled, + name: values.name, + nodes: values.nodeUuids.map((nodeUuid) => ({ + inboundTags: [], + nodeUuid + })), + privateKeyPem: values.privateKeyPem + } + }) + } + + await queryClient.invalidateQueries({ queryKey: QueryKeys.acme.getCertificates.queryKey }) + + onClose() + }) + + return ( + + } + > + + + } variant="light"> + Domains, validity and key type are read from the certificate itself. The + panel never renews an imported certificate — when it is reissued elsewhere, + upload the new material here. + + + {!isReplace && ( + + )} + + + + + Certificate (fullchain) + + + + {(fileProps) => ( + } + size="xs" + variant="default" + {...fileProps} + > + From file + + )} + + + + + + + + + + Private key + + + + {(fileProps) => ( + } + size="xs" + variant="default" + {...fileProps} + > + From file + + )} + + + + + + + {fileError && ( + + {fileError} + + )} + + {!isReplace && ( + <> + ({ label: node.name, value: node.uuid }))} + label="Nodes" + placeholder="Pick the nodes that serve these names" + searchable + {...form.getInputProps('nodeUuids')} + /> + + + > + )} + + + + + Cancel + + + {isReplace ? 'Replace' : 'Import'} + + + + + ) +}
{persistRecord.name}
{persistRecord.value}