diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx
index 3d15066..7bbe526 100644
--- a/management-ui/src/components/Layout/Sidebar.tsx
+++ b/management-ui/src/components/Layout/Sidebar.tsx
@@ -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:
},
+ { path: '/license', labelKey: 'nav.license', icon:
},
{ path: '/settings', labelKey: 'nav.settings', icon:
},
],
},
]
-const VERSION = '1.0.46'
+const VERSION = '1.0.47'
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
const { t } = useTranslation()
diff --git a/management-ui/src/components/LicenseBanner.tsx b/management-ui/src/components/LicenseBanner.tsx
new file mode 100644
index 0000000..21a2637
--- /dev/null
+++ b/management-ui/src/components/LicenseBanner.tsx
@@ -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 (
+
+ {t('licenseBanner.expired')}{' '}
+ {t('licenseBanner.cta')}
+ >
+ }
+ />
+ )
+ }
+
+ if (isTrial && days !== null && days <= 14) {
+ return (
+
+ {t('licenseBanner.trialExpiring', { days })}{' '}
+ {t('licenseBanner.cta')}
+ >
+ }
+ />
+ )
+ }
+
+ if (s.last_error) {
+ return (
+
+ {t('licenseBanner.verifyFailed')}: {s.last_error}{' '}
+ {t('licenseBanner.openPage')}
+ >
+ }
+ closable
+ />
+ )
+ }
+
+ return null
+}
diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json
index 74b1358..23fc9fb 100644
--- a/management-ui/src/i18n/locales/de/common.json
+++ b/management-ui/src/i18n/locales/de/common.json
@@ -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 →"
}
}
diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json
index ab31155..707f41f 100644
--- a/management-ui/src/i18n/locales/en/common.json
+++ b/management-ui/src/i18n/locales/en/common.json
@@ -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 →"
}
}
diff --git a/management-ui/src/pages/License/index.tsx b/management-ui/src/pages/License/index.tsx
new file mode 100644
index 0000000..820e582
--- /dev/null
+++ b/management-ui/src/pages/License/index.tsx
@@ -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
+ features?: Record
+ }
+}
+
+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 Trial
+ if (s.status === 'active') return Aktiv
+ if (s.status === 'expired') return Abgelaufen
+ if (s.status === 'invalid') return Ungültig
+ return {s.status}
+}
+
+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 (
+ <>
+ }
+ extra={
+
+ } onClick={() => verify.mutate()} loading={verify.isPending}
+ disabled={!status?.license_key}>
+ {t('license.reverify')}
+
+ } onClick={() => setOpen(true)}>
+ {status?.license_key ? t('license.replaceKey') : t('license.enterKey')}
+
+
+ }
+ />
+
+ {isLoading && }
+
+ {status?.last_error && (
+
+ )}
+
+ {isTrial && days !== null && days <= 14 && (
+
+ )}
+
+
+
+
+ {status ? statusTag(status) : '-'}
+
+
+ {status?.payload?.product || (isTrial ? 'Trial' : '-')}
+
+
+
+ {status?.license_key || '— ' + t('license.noKey') + ' —'}
+
+
+
+ {expiry ? (
+ <>
+ {new Date(expiry).toLocaleString()}{' '}
+ {days !== null && (
+
+ {days < 0 ? t('license.expired') : t('license.daysLeft', { days })}
+
+ )}
+ >
+ ) : '-'}
+
+
+ {status?.last_verified_at ? new Date(status.last_verified_at).toLocaleString() : '-'}
+
+ {status?.last_verified_node && (
+
+ {status.last_verified_node}
+
+ )}
+
+
+ {status?.payload?.limits && Object.keys(status.payload.limits).length > 0 && (
+ <>
+ {t('license.limits')}
+
+ {Object.entries(status.payload.limits).map(([k, v]) => (
+
+ {v === 0 ? {t('license.unlimited')} : v}
+
+ ))}
+
+ >
+ )}
+
+ {status?.payload?.features && Object.keys(status.payload.features).length > 0 && (
+ <>
+ {t('license.features')}
+
+ {Object.entries(status.payload.features).map(([k, v]) => (
+ {k}
+ ))}
+
+ >
+ )}
+
+ {status?.license_key && (
+
+
clear.mutate()}
+ >
+
+
+
+ )}
+
+
+ setOpen(false)}
+ onOk={() => form.submit()}
+ confirmLoading={setKey.isPending}
+ okText={t('license.activate')}
+ destroyOnHidden
+ >
+ {t('license.enterKeyHint')}
+
+
+
+
+
+ >
+ )
+}