Wenn der License-Server valid:false zurückgibt, wird payload.reason jetzt als roter Alert angezeigt — bisher war der Grund nicht sichtbar und der Operator sah nur "Abgelaufen" ohne Erklärung. Formular-Placeholder NMG-XXXX → NETC-XXXX (mail-gateway copy-paste). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
247 lines
8.4 KiB
TypeScript
247 lines
8.4 KiB
TypeScript
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
|
|
reason?: 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, t: (k: string) => string) {
|
|
if (s.status === 'active' && s.type === 'trial') return <Tag color="orange">{t('license.tagTrial')}</Tag>
|
|
if (s.status === 'active') return <Tag color="green">{t('license.tagActive')}</Tag>
|
|
if (s.status === 'expired') return <Tag color="red">{t('license.tagExpired')}</Tag>
|
|
if (s.status === 'invalid') return <Tag color="red">{t('license.tagInvalid')}</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
|
|
/>
|
|
)}
|
|
|
|
{status?.status !== 'active' && status?.payload?.reason && (
|
|
<Alert
|
|
type="error"
|
|
showIcon
|
|
message={t('license.serverRejected')}
|
|
description={status.payload.reason}
|
|
className="mb-16"
|
|
/>
|
|
)}
|
|
|
|
{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, t) : '-'}
|
|
</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="NETC-XXXX-XXXX-XXXX-XXXX" />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</>
|
|
)
|
|
}
|