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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user