feat(license): Lizenz-System mit Ed25519-Verify gegen license.netcell-it.com
Portiert mail-gateway/internal/license (Verify, Cache, Trial, Signature) + DB-Mirror (internal/services/license) + REST-Handler (status/verify/key/clear) + UI-Page /license (Activate, Status, Limits, Features, Re-verify) + <LicenseBanner /> neben UpdateBanner (trial-expiring, expired, verify-failed) + Scheduler: täglich Re-verify (24h-Tick) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy'))
|
||||
const DNSPage = lazy(() => import('./pages/DNS'))
|
||||
const NTPPage = lazy(() => import('./pages/NTP'))
|
||||
const ClusterPage = lazy(() => import('./pages/Cluster'))
|
||||
const LicensePage = lazy(() => import('./pages/License'))
|
||||
const SettingsPage = lazy(() => import('./pages/Settings'))
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -108,6 +109,7 @@ export default function App() {
|
||||
<Route path="/dns" element={<DNSPage />} />
|
||||
<Route path="/ntp" element={<NTPPage />} />
|
||||
<Route path="/cluster" element={<ClusterPage />} />
|
||||
<Route path="/license" element={<LicensePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Sidebar from './Sidebar'
|
||||
import Header from './Header'
|
||||
import UpdateBanner from '../UpdateBanner'
|
||||
import LicenseBanner from '../LicenseBanner'
|
||||
|
||||
// PAGE_TITLES maps the pathname to an i18n nav key. Header reads
|
||||
// this to render "where you are". Empty fallback = app.title.
|
||||
@@ -16,6 +17,7 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
'/networks': 'nav.networks',
|
||||
'/ip-addresses': 'nav.ipAddresses',
|
||||
'/cluster': 'nav.cluster',
|
||||
'/license': 'nav.license',
|
||||
'/settings': 'nav.settings',
|
||||
}
|
||||
|
||||
@@ -42,6 +44,7 @@ export default function AppLayout() {
|
||||
|
||||
<main className="main-content">
|
||||
<Header pageTitle={title} onMenuToggle={() => setSidebarOpen(true)} />
|
||||
<LicenseBanner />
|
||||
<UpdateBanner />
|
||||
<div className="content-area">
|
||||
<Outlet />
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ClockCircleOutlined,
|
||||
CloudServerOutlined,
|
||||
ClusterOutlined,
|
||||
CrownOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
FireOutlined,
|
||||
@@ -70,12 +71,13 @@ const NAV: NavSection[] = [
|
||||
labelKey: 'nav.section.system',
|
||||
items: [
|
||||
{ path: '/cluster', labelKey: 'nav.cluster', icon: <ApartmentOutlined /> },
|
||||
{ path: '/license', labelKey: 'nav.license', icon: <CrownOutlined /> },
|
||||
{ path: '/settings', labelKey: 'nav.settings', icon: <SettingOutlined /> },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const VERSION = '1.0.46'
|
||||
const VERSION = '1.0.47'
|
||||
|
||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
91
management-ui/src/components/LicenseBanner.tsx
Normal file
91
management-ui/src/components/LicenseBanner.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Alert } from 'antd'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../api/client'
|
||||
|
||||
interface LicenseStatus {
|
||||
license_key?: string
|
||||
status: string
|
||||
type?: string
|
||||
valid_until?: string
|
||||
expires_at?: string
|
||||
last_error?: string | null
|
||||
}
|
||||
|
||||
// LicenseBanner shows up in AppLayout next to UpdateBanner.
|
||||
// Three visible states:
|
||||
// - trial-expiring (≤14d remaining) → warning
|
||||
// - expired / invalid → error
|
||||
// - last verify failed → warning (transient)
|
||||
// Everything else stays silent.
|
||||
export default function LicenseBanner() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { data: s } = useQuery({
|
||||
queryKey: ['license', 'status'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/license/status')
|
||||
return isEnvelope(r.data) ? (r.data.data as LicenseStatus) : null
|
||||
},
|
||||
refetchInterval: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
if (!s) return null
|
||||
|
||||
const expiry = s.valid_until || s.expires_at
|
||||
const days = expiry ? Math.ceil((new Date(expiry).getTime() - Date.now()) / 86_400_000) : null
|
||||
const isTrial = s.type === 'trial' || !s.license_key
|
||||
|
||||
if (s.status === 'expired' || s.status === 'invalid') {
|
||||
return (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
banner
|
||||
message={
|
||||
<>
|
||||
{t('licenseBanner.expired')}{' '}
|
||||
<Link to="/license">{t('licenseBanner.cta')}</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isTrial && days !== null && days <= 14) {
|
||||
return (
|
||||
<Alert
|
||||
type={days <= 3 ? 'error' : 'warning'}
|
||||
showIcon
|
||||
banner
|
||||
message={
|
||||
<>
|
||||
{t('licenseBanner.trialExpiring', { days })}{' '}
|
||||
<Link to="/license">{t('licenseBanner.cta')}</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (s.last_error) {
|
||||
return (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
banner
|
||||
message={
|
||||
<>
|
||||
{t('licenseBanner.verifyFailed')}: {s.last_error}{' '}
|
||||
<Link to="/license">{t('licenseBanner.openPage')}</Link>
|
||||
</>
|
||||
}
|
||||
closable
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
"ntp": "Zeit (NTP)",
|
||||
"firewall": "Firewall",
|
||||
"cluster": "Cluster",
|
||||
"license": "Lizenz",
|
||||
"settings": "Einstellungen",
|
||||
"section": {
|
||||
"overview": "Übersicht",
|
||||
@@ -551,5 +552,42 @@
|
||||
"download": "Download",
|
||||
"copy": "Kopieren",
|
||||
"copied": "Kopiert"
|
||||
},
|
||||
"license": {
|
||||
"title": "Lizenz",
|
||||
"status": "Status",
|
||||
"product": "Produkt",
|
||||
"key": "Lizenz-Schlüssel",
|
||||
"noKey": "Kein Schlüssel hinterlegt",
|
||||
"validUntil": "Gültig bis",
|
||||
"expired": "Abgelaufen",
|
||||
"daysLeft": "noch {{days}} Tage",
|
||||
"lastVerifiedAt": "Letzte Verifizierung",
|
||||
"verifiedBy": "Verifiziert von",
|
||||
"limits": "Limits",
|
||||
"unlimited": "Unbegrenzt",
|
||||
"features": "Features",
|
||||
"reverify": "Erneut prüfen",
|
||||
"reverified": "Lizenz erfolgreich verifiziert",
|
||||
"enterKey": "Schlüssel eingeben",
|
||||
"replaceKey": "Schlüssel ersetzen",
|
||||
"enterKeyHint": "Lizenz-Schlüssel aus dem Self-Service-Portal von license.netcell-it.com einfügen.",
|
||||
"activate": "Aktivieren",
|
||||
"saved": "Lizenz gespeichert und verifiziert",
|
||||
"savedButVerifyFailed": "Schlüssel gespeichert, aber Server-Verifizierung fehlgeschlagen",
|
||||
"clearKey": "Schlüssel entfernen",
|
||||
"cleared": "Lizenz entfernt — System fällt auf Trial zurück",
|
||||
"confirmClear": "Lizenz-Schlüssel wirklich entfernen?",
|
||||
"confirmClearHint": "System fällt auf Trial-Modus zurück, sobald der Schlüssel gelöscht wird.",
|
||||
"lastVerifyFailed": "Letzte Server-Verifizierung fehlgeschlagen",
|
||||
"trialExpiring": "Trial läuft in {{days}} Tag(en) ab",
|
||||
"trialExpiringHint": "Lizenz aktivieren, bevor die Trial-Periode endet."
|
||||
},
|
||||
"licenseBanner": {
|
||||
"expired": "Lizenz abgelaufen oder ungültig.",
|
||||
"trialExpiring": "Trial läuft in {{days}} Tag(en) ab.",
|
||||
"verifyFailed": "Lizenz-Verifizierung fehlgeschlagen",
|
||||
"cta": "Jetzt aktivieren →",
|
||||
"openPage": "Lizenz-Seite öffnen →"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"ntp": "Time (NTP)",
|
||||
"firewall": "Firewall",
|
||||
"cluster": "Cluster",
|
||||
"license": "License",
|
||||
"settings": "Settings",
|
||||
"section": {
|
||||
"overview": "Overview",
|
||||
@@ -551,5 +552,42 @@
|
||||
"download": "Download",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied"
|
||||
},
|
||||
"license": {
|
||||
"title": "License",
|
||||
"status": "Status",
|
||||
"product": "Product",
|
||||
"key": "License key",
|
||||
"noKey": "No key configured",
|
||||
"validUntil": "Valid until",
|
||||
"expired": "Expired",
|
||||
"daysLeft": "{{days}} days left",
|
||||
"lastVerifiedAt": "Last verified",
|
||||
"verifiedBy": "Verified by",
|
||||
"limits": "Limits",
|
||||
"unlimited": "Unlimited",
|
||||
"features": "Features",
|
||||
"reverify": "Re-verify",
|
||||
"reverified": "License re-verified successfully",
|
||||
"enterKey": "Enter key",
|
||||
"replaceKey": "Replace key",
|
||||
"enterKeyHint": "Paste your license key from the self-service portal at license.netcell-it.com.",
|
||||
"activate": "Activate",
|
||||
"saved": "License saved and verified",
|
||||
"savedButVerifyFailed": "Key saved but server-verify failed",
|
||||
"clearKey": "Remove key",
|
||||
"cleared": "License removed — system falls back to trial",
|
||||
"confirmClear": "Really remove the license key?",
|
||||
"confirmClearHint": "The system will fall back to trial-mode once the key is deleted.",
|
||||
"lastVerifyFailed": "Last server verify failed",
|
||||
"trialExpiring": "Trial expires in {{days}} day(s)",
|
||||
"trialExpiringHint": "Activate a license before the trial period ends."
|
||||
},
|
||||
"licenseBanner": {
|
||||
"expired": "License expired or invalid.",
|
||||
"trialExpiring": "Trial expires in {{days}} day(s).",
|
||||
"verifyFailed": "License verification failed",
|
||||
"cta": "Activate now →",
|
||||
"openPage": "Open license page →"
|
||||
}
|
||||
}
|
||||
|
||||
235
management-ui/src/pages/License/index.tsx
Normal file
235
management-ui/src/pages/License/index.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Card, Descriptions, Form, Input, Modal, Popconfirm, Space, Tag, Typography, message,
|
||||
} from 'antd'
|
||||
import { CrownOutlined, ReloadOutlined, 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'
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
|
||||
interface LicenseStatus {
|
||||
license_key?: string
|
||||
status: string
|
||||
type?: string
|
||||
valid?: boolean
|
||||
valid_until?: string
|
||||
expires_at?: string
|
||||
last_verified_at?: string
|
||||
last_verified_node?: string
|
||||
last_error?: string | null
|
||||
reason?: string
|
||||
payload?: {
|
||||
product?: string
|
||||
type?: string
|
||||
limits?: Record<string, number>
|
||||
features?: Record<string, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
function daysUntil(iso?: string): number | null {
|
||||
if (!iso) return null
|
||||
const ms = new Date(iso).getTime() - Date.now()
|
||||
return Math.ceil(ms / 86_400_000)
|
||||
}
|
||||
|
||||
function statusTag(s: LicenseStatus) {
|
||||
if (s.status === 'active' && s.type === 'trial') return <Tag color="orange">Trial</Tag>
|
||||
if (s.status === 'active') return <Tag color="green">Aktiv</Tag>
|
||||
if (s.status === 'expired') return <Tag color="red">Abgelaufen</Tag>
|
||||
if (s.status === 'invalid') return <Tag color="red">Ungültig</Tag>
|
||||
return <Tag>{s.status}</Tag>
|
||||
}
|
||||
|
||||
export default function LicensePage() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [form] = Form.useForm<{ license_key: string }>()
|
||||
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ['license', 'status'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/license/status')
|
||||
return isEnvelope(r.data) ? (r.data.data as LicenseStatus) : null
|
||||
},
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
const setKey = useMutation({
|
||||
mutationFn: async (key: string) => {
|
||||
const r = await apiClient.put('/license/key', { license_key: key })
|
||||
return isEnvelope(r.data) ? r.data.data : r.data
|
||||
},
|
||||
onSuccess: (res: { verify_error?: string }) => {
|
||||
if (res?.verify_error) {
|
||||
message.warning(t('license.savedButVerifyFailed') + ': ' + res.verify_error)
|
||||
} else {
|
||||
message.success(t('license.saved'))
|
||||
}
|
||||
setOpen(false)
|
||||
form.resetFields()
|
||||
qc.invalidateQueries({ queryKey: ['license'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const verify = useMutation({
|
||||
mutationFn: async () => { await apiClient.post('/license/verify') },
|
||||
onSuccess: () => {
|
||||
message.success(t('license.reverified'))
|
||||
qc.invalidateQueries({ queryKey: ['license'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const clear = useMutation({
|
||||
mutationFn: async () => { await apiClient.delete('/license/key') },
|
||||
onSuccess: () => {
|
||||
message.success(t('license.cleared'))
|
||||
qc.invalidateQueries({ queryKey: ['license'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const expiry = status?.valid_until ?? status?.expires_at
|
||||
const days = daysUntil(expiry)
|
||||
const isTrial = status?.type === 'trial' || (!status?.license_key && status?.status === 'active')
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={t('license.title')}
|
||||
icon={<CrownOutlined />}
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => verify.mutate()} loading={verify.isPending}
|
||||
disabled={!status?.license_key}>
|
||||
{t('license.reverify')}
|
||||
</Button>
|
||||
<Button type="primary" icon={<SafetyCertificateOutlined />} onClick={() => setOpen(true)}>
|
||||
{status?.license_key ? t('license.replaceKey') : t('license.enterKey')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading && <Alert message={t('common.loading')} type="info" />}
|
||||
|
||||
{status?.last_error && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('license.lastVerifyFailed')}
|
||||
description={status.last_error}
|
||||
className="mb-16"
|
||||
closable
|
||||
/>
|
||||
)}
|
||||
|
||||
{isTrial && days !== null && days <= 14 && (
|
||||
<Alert
|
||||
type={days <= 3 ? 'error' : 'warning'}
|
||||
showIcon
|
||||
message={t('license.trialExpiring', { days })}
|
||||
description={t('license.trialExpiringHint')}
|
||||
className="mb-16"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label={t('license.status')}>
|
||||
{status ? statusTag(status) : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('license.product')}>
|
||||
{status?.payload?.product || (isTrial ? 'Trial' : '-')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('license.key')}>
|
||||
<Text code copyable={!!status?.license_key}>
|
||||
{status?.license_key || '— ' + t('license.noKey') + ' —'}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('license.validUntil')}>
|
||||
{expiry ? (
|
||||
<>
|
||||
{new Date(expiry).toLocaleString()}{' '}
|
||||
{days !== null && (
|
||||
<Tag color={days < 0 ? 'red' : days <= 14 ? 'orange' : 'blue'}>
|
||||
{days < 0 ? t('license.expired') : t('license.daysLeft', { days })}
|
||||
</Tag>
|
||||
)}
|
||||
</>
|
||||
) : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('license.lastVerifiedAt')}>
|
||||
{status?.last_verified_at ? new Date(status.last_verified_at).toLocaleString() : '-'}
|
||||
</Descriptions.Item>
|
||||
{status?.last_verified_node && (
|
||||
<Descriptions.Item label={t('license.verifiedBy')}>
|
||||
<Text code>{status.last_verified_node}</Text>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
{status?.payload?.limits && Object.keys(status.payload.limits).length > 0 && (
|
||||
<>
|
||||
<Paragraph strong className="mt-24">{t('license.limits')}</Paragraph>
|
||||
<Descriptions column={2} bordered size="small">
|
||||
{Object.entries(status.payload.limits).map(([k, v]) => (
|
||||
<Descriptions.Item key={k} label={k}>
|
||||
{v === 0 ? <Tag>{t('license.unlimited')}</Tag> : v}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status?.payload?.features && Object.keys(status.payload.features).length > 0 && (
|
||||
<>
|
||||
<Paragraph strong className="mt-24">{t('license.features')}</Paragraph>
|
||||
<Space wrap>
|
||||
{Object.entries(status.payload.features).map(([k, v]) => (
|
||||
<Tag color={v ? 'green' : 'default'} key={k}>{k}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status?.license_key && (
|
||||
<div className="mt-24">
|
||||
<Popconfirm
|
||||
title={t('license.confirmClear')}
|
||||
description={t('license.confirmClearHint')}
|
||||
okText={t('license.clearKey')}
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => clear.mutate()}
|
||||
>
|
||||
<Button danger>{t('license.clearKey')}</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={status?.license_key ? t('license.replaceKey') : t('license.enterKey')}
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={setKey.isPending}
|
||||
okText={t('license.activate')}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Paragraph type="secondary">{t('license.enterKeyHint')}</Paragraph>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => setKey.mutate(v.license_key.trim())}>
|
||||
<Form.Item name="license_key" label={t('license.key')} rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={3} placeholder="NMG-XXXX-XXXX-XXXX-XXXX" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user