Files
edgeguard-native/management-ui/src/pages/Domains/Detail.tsx
Debian 2d027b3044 fix(rbac): Viewer-Rolle in Detail-Pages + ActionButtons — Edit ebenfalls sperren
- ActionButtons: Edit-Button wird für Viewer wie Delete gesperrt (Tooltip zeigt Reason)
- Domains/Detail: isViewer-Flag an alle Sub-Panels weitergegeben; Save-, TLS-Cert-,
  Routing-Rules- und Headers-Buttons für Viewer disabled
- Backends/Detail: Save-Button + ServerPanel Add-Button für Viewer disabled
- i18n: domains.backendUp/backendDown Keys (waren noch hardkodiert)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:10:29 +02:00

674 lines
26 KiB
TypeScript

import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Button, Card, Col, Divider, Form, Input, InputNumber, Modal,
Popconfirm, Row, Select, Space, Switch, Table, Tag, Tooltip, Typography, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import {
ArrowLeftOutlined, DeleteOutlined, EditOutlined, GlobalOutlined,
LockOutlined, PlusOutlined, SafetyCertificateOutlined,
} from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import PageHeader from '../../components/PageHeader'
import StatusDot from '../../components/StatusDot'
import { useAuthStore } from '../../stores/auth'
const { Text } = Typography
interface Domain {
id: number; name: string; active: boolean
primary_backend_id?: number | null
http_to_https: boolean
hsts_enabled: boolean; hsts_max_age: number
hsts_subdomains: boolean; hsts_preload: boolean
maintenance_mode: boolean; maintenance_message?: string | null
www_redirect: '' | 'to-naked' | 'to-www'
rate_limit_rps: number; max_body_kb: number
disable_h3: boolean
notes?: string | null
}
interface DomainFormValues {
name: string; active: boolean
primary_backend_id?: number | null
http_to_https: boolean
hsts_enabled: boolean; hsts_max_age: number
hsts_subdomains: boolean; hsts_preload: boolean
maintenance_mode: boolean; maintenance_message?: string
www_redirect: '' | 'to-naked' | 'to-www'
rate_limit_rps: number; max_body_kb: number
disable_h3: boolean
notes?: string
}
interface BackendLite { id: number; name: string; active: boolean }
interface ResponseHeader {
id: number; domain_id: number; name: string; value: string; position: number
}
interface RoutingRule {
id: number; domain_id: number; path_prefix: string
backend_id: number; priority: number; active: boolean
}
interface BackendLiteWithAddr { id: number; name: string; address: string; port: number }
interface TLSCertLite {
domain: string
status: 'pending' | 'active' | 'renewing' | 'expired' | 'error'
not_after?: string | null
}
interface HAProxyStat { backend: string; server: string; status: string }
async function getDomain(id: number): Promise<Domain | null> {
const r = await apiClient.get(`/domains/${id}`)
if (!isEnvelope(r.data)) return null
return r.data.data as Domain
}
async function listBackends(): Promise<BackendLite[]> {
const r = await apiClient.get('/backends')
if (!isEnvelope(r.data)) return []
return (r.data.data as { backends?: BackendLite[] }).backends ?? []
}
async function listHeaders(domainID: number): Promise<ResponseHeader[]> {
const r = await apiClient.get(`/domains/${domainID}/headers`)
if (!isEnvelope(r.data)) return []
return (r.data.data as { headers?: ResponseHeader[] }).headers ?? []
}
async function listDomainRules(domainID: number): Promise<RoutingRule[]> {
const r = await apiClient.get(`/domains/${domainID}/routing-rules`)
if (!isEnvelope(r.data)) return []
return (r.data.data as { routing_rules?: RoutingRule[] }).routing_rules ?? []
}
async function listBackendsWithAddr(): Promise<BackendLiteWithAddr[]> {
const r = await apiClient.get('/backends')
if (!isEnvelope(r.data)) return []
return (r.data.data as { backends?: BackendLiteWithAddr[] }).backends ?? []
}
async function listCerts(): Promise<TLSCertLite[]> {
const r = await apiClient.get('/tls-certs')
if (!isEnvelope(r.data)) return []
return (r.data.data as { tls_certs?: TLSCertLite[] }).tls_certs ?? []
}
async function listHAProxyStats(): Promise<HAProxyStat[]> {
try {
const r = await apiClient.get('/haproxy/stats')
if (!isEnvelope(r.data)) return []
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
} catch { return [] }
}
export default function DomainDetailPage() {
const { t } = useTranslation()
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const qc = useQueryClient()
const domainID = Number(id)
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
const { data: domain, isLoading } = useQuery({
queryKey: ['domain', domainID],
queryFn: () => getDomain(domainID),
enabled: !isNaN(domainID),
})
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
const { data: haproxyStats } = useQuery({
queryKey: ['haproxy', 'stats'],
queryFn: listHAProxyStats,
refetchInterval: 15_000,
})
const [form] = Form.useForm<DomainFormValues>()
const update = useMutation({
mutationFn: async (v: DomainFormValues) => {
await apiClient.put(`/domains/${domainID}`, v)
},
onSuccess: () => {
message.success(t('common.save'))
void qc.invalidateQueries({ queryKey: ['domains'] })
void qc.invalidateQueries({ queryKey: ['domain', domainID] })
},
onError: (e: Error) => message.error(e.message),
})
const issueCert = useMutation({
mutationFn: async (domainName: string) => {
await apiClient.post('/tls-certs/issue', { domain: domainName })
},
onSuccess: () => {
message.success(t('ssl.issueSuccess'))
void qc.invalidateQueries({ queryKey: ['tls-certs'] })
},
onError: (e: Error) => message.error(e.message),
})
if (isLoading || !domain) return null
const cert = certs?.find(c => c.domain === domain.name)
// Primary backend live health from HAProxy stats — UP/DOWN/null
const backendHealth = (() => {
if (!domain.primary_backend_id || !haproxyStats?.length) return null
const name = `eg_backend_${domain.primary_backend_id}`
const servers = haproxyStats.filter(s => s.backend === name)
if (!servers.length) return null
return servers.some(s => s.status === 'UP') ? 'UP' : 'DOWN'
})()
const certBadge = () => {
if (!cert) return <Tag icon={<LockOutlined />} color="default">{t('domains.tlsCertNone')}</Tag>
if (cert.status === 'expired') return <Tag icon={<LockOutlined />} color="red">{t('domains.tlsCertExpired')}</Tag>
if (cert.status === 'error') return <Tag icon={<LockOutlined />} color="red">{t('domains.tlsCertError')}</Tag>
const days = cert.not_after
? Math.round((new Date(cert.not_after).getTime() - Date.now()) / 86_400_000)
: null
if (days != null && days < 30) {
return (
<Tooltip title={cert.not_after}>
<Tag icon={<SafetyCertificateOutlined />} color="orange">
{t('domains.tlsCertExpiring', { days })}
</Tag>
</Tooltip>
)
}
return (
<Tooltip title={cert.not_after}>
<Tag icon={<SafetyCertificateOutlined />} color="green">{t('domains.tlsCertValid')}</Tag>
</Tooltip>
)
}
return (
<div>
<PageHeader
icon={<GlobalOutlined />}
title={domain.name}
subtitle={
<Space size={6}>
<StatusDot active={domain.active} />
{certBadge()}
{backendHealth === 'UP' && <Tag color="green" style={{ margin: 0 }}>{t('domains.backendUp')}</Tag>}
{backendHealth === 'DOWN' && <Tag color="red" style={{ margin: 0 }}>{t('domains.backendDown')}</Tag>}
</Space>
}
extra={
<Space>
{cert ? (
<Popconfirm
title={t('ssl.renewConfirmTitle')}
description={t('ssl.renewConfirmDesc', { domain: domain.name })}
onConfirm={() => issueCert.mutate(domain.name)}
okText={t('common.yes')} cancelText={t('common.no')}
disabled={isViewer}
>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button
icon={<SafetyCertificateOutlined />}
loading={issueCert.isPending}
disabled={isViewer}
>
{t('ssl.renewBtn')}
</Button>
</Tooltip>
</Popconfirm>
) : (
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button
icon={<SafetyCertificateOutlined />}
type="primary"
loading={issueCert.isPending}
disabled={isViewer}
onClick={() => issueCert.mutate(domain.name)}
>
{t('ssl.issueButton')}
</Button>
</Tooltip>
)}
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/domains')}>
{t('domains.backToList')}
</Button>
</Space>
}
/>
<Row gutter={[24, 0]}>
<Col xs={24}>
<RoutingRulesPanel domainID={domainID} domainName={domain.name} isViewer={isViewer} />
</Col>
</Row>
<Row gutter={24}>
<Col xs={24} lg={12}>
<Card size="small" title={t('domains.settingsCard')} className="mb-16">
<Form
form={form}
layout="vertical"
initialValues={{
name: domain.name,
active: domain.active,
primary_backend_id: domain.primary_backend_id ?? null,
http_to_https: domain.http_to_https,
hsts_enabled: domain.hsts_enabled,
hsts_max_age: domain.hsts_max_age || 31536000,
hsts_subdomains: domain.hsts_subdomains,
hsts_preload: domain.hsts_preload,
maintenance_mode: domain.maintenance_mode,
maintenance_message: domain.maintenance_message ?? '',
www_redirect: domain.www_redirect ?? '',
rate_limit_rps: domain.rate_limit_rps ?? 0,
max_body_kb: domain.max_body_kb ?? 0,
disable_h3: domain.disable_h3 ?? false,
notes: domain.notes ?? '',
}}
onFinish={(v) => update.mutate(v)}
>
<Form.Item label={t('domains.name')} name="name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label={t('domains.primaryBackend')} name="primary_backend_id"
extra={t('domains.primaryBackendHint')}>
<Select
allowClear showSearch optionFilterProp="label"
placeholder={t('domains.selectBackend')}
options={(backends ?? []).filter(b => b.active).map(b => ({
value: b.id, label: b.name,
}))}
/>
</Form.Item>
<Form.Item label={t('domains.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t('domains.httpToHttps')} name="http_to_https" valuePropName="checked">
<Switch />
</Form.Item>
<Divider plain>
<Text type="secondary" style={{ fontSize: 12 }}>{t('domains.settingsSection')}</Text>
</Divider>
<Form.Item label={t('domains.hsts')} name="hsts_enabled" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item noStyle shouldUpdate={(p, c) => p.hsts_enabled !== c.hsts_enabled}>
{({ getFieldValue }) => getFieldValue('hsts_enabled') ? (
<>
<Form.Item label={t('domains.hstsMaxAge')} name="hsts_max_age"
extra={t('domains.hstsMaxAgeHint')} rules={[{ type: 'number', min: 0 }]}>
<InputNumber min={0} step={3600} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('domains.hstsSubdomains')} name="hsts_subdomains" valuePropName="checked"
extra={t('domains.hstsSubdomainsHint')}>
<Switch />
</Form.Item>
<Form.Item label={t('domains.hstsPreload')} name="hsts_preload" valuePropName="checked"
extra={t('domains.hstsPreloadHint')}>
<Switch />
</Form.Item>
</>
) : null}
</Form.Item>
<Form.Item label={t('domains.wwwRedirect')} name="www_redirect" extra={t('domains.wwwRedirectHint')}>
<Select options={[
{ value: '', label: t('domains.wwwRedirectNone') },
{ value: 'to-naked', label: t('domains.wwwRedirectToNaked') },
{ value: 'to-www', label: t('domains.wwwRedirectToWWW') },
]} />
</Form.Item>
<Form.Item label={t('domains.maintenance')} name="maintenance_mode" valuePropName="checked"
extra={t('domains.maintenanceHint')}>
<Switch />
</Form.Item>
<Form.Item noStyle shouldUpdate={(p, c) => p.maintenance_mode !== c.maintenance_mode}>
{({ getFieldValue }) => getFieldValue('maintenance_mode') ? (
<Form.Item label={t('domains.maintenanceMessage')} name="maintenance_message">
<Input.TextArea rows={2} placeholder={t('domains.maintenanceMessagePlaceholder')} maxLength={300} />
</Form.Item>
) : null}
</Form.Item>
<Form.Item label={t('domains.rateLimit')} name="rate_limit_rps" extra={t('domains.rateLimitHint')}
rules={[{ type: 'number', min: 0 }]}>
<InputNumber min={0} step={10} style={{ width: '100%' }} addonAfter="req/s" />
</Form.Item>
<Form.Item label={t('domains.maxBody')} name="max_body_kb" extra={t('domains.maxBodyHint')}
rules={[{ type: 'number', min: 0 }]}>
<InputNumber min={0} step={64} style={{ width: '100%' }} addonAfter="KiB" />
</Form.Item>
<Form.Item label={t('domains.disableH3')} name="disable_h3" valuePropName="checked"
extra={t('domains.disableH3Hint')}>
<Switch />
</Form.Item>
<Form.Item label={t('domains.notes')} name="notes">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" htmlType="submit" loading={update.isPending} disabled={isViewer}>
{t('common.save')}
</Button>
</Tooltip>
</Form.Item>
</Form>
</Card>
</Col>
<Col xs={24} lg={12}>
<HeadersPanel domainID={domainID} domainName={domain.name} isViewer={isViewer} />
</Col>
</Row>
</div>
)
}
// ── Routing Rules Panel ─────────────────────────────────────────────────
function RoutingRulesPanel({ domainID, domainName, isViewer }: { domainID: number; domainName: string; isViewer: boolean }) {
const { t } = useTranslation()
const qc = useQueryClient()
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<RoutingRule | null>(null)
const [rForm] = Form.useForm<{ path_prefix: string; backend_id: number; priority: number; active: boolean }>()
const { data: rules, isLoading } = useQuery({
queryKey: ['domain-routing-rules', domainID],
queryFn: () => listDomainRules(domainID),
})
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackendsWithAddr })
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ['domain-routing-rules', domainID] })
void qc.invalidateQueries({ queryKey: ['routing-rules'] })
}
const create = useMutation({
mutationFn: async (v: { path_prefix: string; backend_id: number; priority: number; active: boolean }) =>
apiClient.post('/routing-rules', { ...v, domain_id: domainID }),
onSuccess: () => {
message.success(t('common.save'))
setOpen(false); rForm.resetFields(); invalidate()
},
onError: (e: Error) => message.error(e.message),
})
const update = useMutation({
mutationFn: async ({ id, v }: { id: number; v: { path_prefix: string; backend_id: number; priority: number; active: boolean } }) =>
apiClient.put(`/routing-rules/${id}`, { ...v, domain_id: domainID }),
onSuccess: () => {
message.success(t('common.save'))
setEditing(null); rForm.resetFields(); invalidate()
},
onError: (e: Error) => message.error(e.message),
})
const del = useMutation({
mutationFn: async (id: number) => apiClient.delete(`/routing-rules/${id}`),
onSuccess: invalidate,
onError: (e: Error) => message.error(e.message),
})
const quickToggle = useMutation({
mutationFn: async ({ id, row, checked }: { id: number; row: RoutingRule; checked: boolean }) =>
apiClient.put(`/routing-rules/${id}`, { ...row, active: checked }),
onSuccess: invalidate,
onError: (e: Error) => message.error(e.message),
})
const backendLabel = (id: number) => {
const b = backends?.find(x => x.id === id)
return b ? `${b.name} (${b.address}:${b.port})` : `#${id}`
}
const columns: ColumnsType<RoutingRule> = [
{ title: t('routing.pathPrefix'), dataIndex: 'path_prefix', key: 'path' },
{ title: t('routing.backend'), dataIndex: 'backend_id', key: 'backend', render: (id: number) => backendLabel(id) },
{ title: t('routing.priority'), dataIndex: 'priority', key: 'priority', width: 90 },
{
title: t('routing.active'), dataIndex: 'active', key: 'active', width: 80,
render: (v: boolean, r: RoutingRule) => (
<Switch
size="small"
checked={v}
disabled={isViewer}
loading={quickToggle.isPending && quickToggle.variables?.id === r.id}
onChange={(checked) => quickToggle.mutate({ id: r.id, row: r, checked })}
/>
),
},
{
title: t('common.actions'), key: 'a', width: 90,
render: (_, r) => (
<Space size={4}>
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('common.edit')}>
<Button type="text" size="small" icon={<EditOutlined />}
disabled={isViewer}
onClick={() => {
setEditing(r)
rForm.setFieldsValue({ path_prefix: r.path_prefix, backend_id: r.backend_id, priority: r.priority, active: r.active })
}}
/>
</Tooltip>
{isViewer ? (
<Tooltip title={t('auth.viewerBadge')}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} disabled />
</Tooltip>
) : (
<Popconfirm
title={t('routing.deleteConfirm')}
okText={t('common.yes')} cancelText={t('common.no')}
onConfirm={() => del.mutate(r.id)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
},
]
return (
<>
<Card
size="small"
title={t('domains.routingRulesTitle', { name: domainName })}
className="mb-16"
extra={
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" size="small" icon={<PlusOutlined />} disabled={isViewer} onClick={() => {
setOpen(true); rForm.resetFields()
rForm.setFieldsValue({ path_prefix: '/', priority: 100, active: true })
}}>
{t('routing.addRule')}
</Button>
</Tooltip>
}
>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
{t('domains.routingRulesHint')}
</Text>
<Table
rowKey="id" size="small" loading={isLoading}
dataSource={rules ?? []} columns={columns}
pagination={false}
locale={{ emptyText: t('domains.routingRulesEmpty') }}
/>
</Card>
<Modal
title={editing ? t('routing.editRule') : t('routing.addRule')}
open={open || editing !== null}
onCancel={() => { setOpen(false); setEditing(null); rForm.resetFields() }}
onOk={() => { void rForm.submit() }}
confirmLoading={create.isPending || update.isPending}
destroyOnHidden
>
<Form
form={rForm} layout="vertical"
onFinish={(v) => editing ? update.mutate({ id: editing.id, v }) : create.mutate(v)}
>
<Form.Item label={t('routing.pathPrefix')} name="path_prefix" rules={[{ required: true }]}>
<Input placeholder="/" />
</Form.Item>
<Form.Item label={t('routing.backend')} name="backend_id" rules={[{ required: true }]}>
<Select
showSearch optionFilterProp="label"
placeholder={t('routing.selectBackend')}
options={(backends ?? []).map(b => ({ value: b.id, label: `${b.name} (${b.address}:${b.port})` }))}
/>
</Form.Item>
<Form.Item label={t('routing.priority')} name="priority" rules={[{ required: true }]}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('routing.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</>
)
}
// ── Response Headers Panel ──────────────────────────────────────────────
function HeadersPanel({ domainID, domainName, isViewer }: { domainID: number; domainName: string; isViewer: boolean }) {
const { t } = useTranslation()
const qc = useQueryClient()
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<ResponseHeader | null>(null)
const [hForm] = Form.useForm<{ name: string; value: string; position: number }>()
const { data: headers, isLoading } = useQuery({
queryKey: ['domain-headers', domainID],
queryFn: () => listHeaders(domainID),
})
const invalidate = () => void qc.invalidateQueries({ queryKey: ['domain-headers', domainID] })
const create = useMutation({
mutationFn: async (v: { name: string; value: string; position: number }) =>
apiClient.post(`/domains/${domainID}/headers`, v),
onSuccess: () => {
message.success(t('common.save'))
setOpen(false); hForm.resetFields()
invalidate()
},
onError: (e: Error) => message.error(e.message),
})
const update = useMutation({
mutationFn: async ({ id, v }: { id: number; v: { name: string; value: string; position: number } }) =>
apiClient.put(`/domains/${domainID}/headers/${id}`, v),
onSuccess: () => {
message.success(t('common.save'))
setEditing(null); hForm.resetFields()
invalidate()
},
onError: (e: Error) => message.error(e.message),
})
const del = useMutation({
mutationFn: async (id: number) => apiClient.delete(`/domains/${domainID}/headers/${id}`),
onSuccess: invalidate,
onError: (e: Error) => message.error(e.message),
})
const columns: ColumnsType<ResponseHeader> = [
{ title: t('domains.headerName'), dataIndex: 'name', key: 'name',
render: (s: string) => <code style={{ fontSize: 12 }}>{s}</code> },
{ title: t('domains.headerValue'), dataIndex: 'value', key: 'value', ellipsis: true },
{ title: '#', dataIndex: 'position', key: 'pos', width: 50 },
{
title: t('common.actions'), key: 'a', width: 80,
render: (_, r) => (
<Space size={4}>
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('common.edit')}>
<Button type="text" size="small" icon={<EditOutlined />}
disabled={isViewer}
onClick={() => {
setEditing(r)
hForm.setFieldsValue({ name: r.name, value: r.value, position: r.position })
}}
/>
</Tooltip>
{isViewer ? (
<Tooltip title={t('auth.viewerBadge')}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} disabled />
</Tooltip>
) : (
<Popconfirm
title={t('domains.headerDeleteConfirm', { name: r.name })}
okText={t('common.yes')} cancelText={t('common.no')}
onConfirm={() => del.mutate(r.id)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
},
]
return (
<>
<Card
size="small"
title={t('domains.headersTitle', { name: domainName })}
extra={
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" size="small" icon={<PlusOutlined />} disabled={isViewer} onClick={() => {
setOpen(true); hForm.resetFields()
hForm.setFieldsValue({ name: '', value: '', position: headers?.length ?? 0 })
}}>
{t('domains.addHeader')}
</Button>
</Tooltip>
}
>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
{t('domains.headersHint')}
</Text>
<Table
rowKey="id" size="small" loading={isLoading}
dataSource={headers ?? []} columns={columns}
pagination={false}
locale={{ emptyText: t('domains.headersEmpty') }}
/>
</Card>
<Modal
title={editing ? t('domains.editHeader') : t('domains.addHeader')}
open={open || editing !== null}
onCancel={() => { setOpen(false); setEditing(null); hForm.resetFields() }}
onOk={() => { void hForm.submit() }}
confirmLoading={create.isPending || update.isPending}
destroyOnHidden
>
<Form
form={hForm} layout="vertical"
onFinish={(v) => editing ? update.mutate({ id: editing.id, v }) : create.mutate(v)}
>
<Form.Item label={t('domains.headerName')} name="name"
extra={t('domains.headerNameHint')}
rules={[{ required: true }, { pattern: /^[A-Za-z0-9-]+$/, message: t('domains.headerNamePattern') }]}>
<Input placeholder="X-Frame-Options" />
</Form.Item>
<Form.Item label={t('domains.headerValue')} name="value" rules={[{ required: true }]}>
<Input.TextArea rows={2} placeholder="DENY" />
</Form.Item>
<Form.Item label={t('domains.headerPosition')} name="position" initialValue={0}>
<InputNumber min={0} step={1} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</>
)
}