feat(license): 退出授权 / 释放本实例(确认后 deactivate)
设置→授权 Pro 态管理员可退出:在线 LIC- 先调 opsx deactivate(含 activation_token),成功才清本地;失败保留现状。LICOFF- 仅解本机并提示后台名额可能仍占。禁静默清码。
This commit is contained in:
1 parent
f3d27ceb04
commit
cec7c6eeaf
10 files changed
+338
-3
No files matched your search
@@ -21,4 +21,6 @@ export interface LicenseInfo {
|
||||
export const licenseApi = {
|
||||
status: () => http.get<{ license: LicenseInfo; pro: boolean; features: string[] }>('/system/license'),
|
||||
activate: (key: string) => http.post<LicenseInfo>('/system/license/activate', { key }),
|
||||
deactivate: () =>
|
||||
http.post<{ offlineLocalOnly?: boolean; message?: string }>('/system/license/deactivate'),
|
||||
}
|
||||
@@ -21,6 +21,14 @@ export default {
|
||||
activating: 'Activating…',
|
||||
activated: 'Pro activated — all features unlocked',
|
||||
activateFailed: 'Activation failed',
|
||||
deactivate: 'Deactivate / release this instance',
|
||||
deactivateConfirmTitle: 'Deactivate license?',
|
||||
deactivateConfirmDesc: 'This releases the seat for this instance and returns the machine to Free. Online keys notify the license server first; on failure local state is kept (never silently cleared).',
|
||||
deactivateConfirm: 'Confirm deactivate',
|
||||
deactivating: 'Deactivating…',
|
||||
deactivated: 'License deactivated — this machine is Free again',
|
||||
deactivateFailed: 'Deactivation failed',
|
||||
deactivateOfflineNote: 'Local offline license unbound. The server seat may still be occupied — ask ops to clear it if needed.',
|
||||
legacyBanner: 'Legacy HMAC keys are in a migration window. Please switch to a LIC- / LICOFF- key. Old keys stay valid until the window ends — never silently invalidated.',
|
||||
graceBanner: 'Offline grace: {{days}} day(s) left. Reconnect to re-verify.',
|
||||
status: {
|
||||
|
||||
@@ -21,6 +21,14 @@ export default {
|
||||
activating: '激活中…',
|
||||
activated: 'Pro 已激活,全部功能已解锁',
|
||||
activateFailed: '激活失败',
|
||||
deactivate: '退出授权 / 释放本实例',
|
||||
deactivateConfirmTitle: '退出授权?',
|
||||
deactivateConfirmDesc: '将释放本实例占用的授权名额,本机回到免费版。在线授权会先通知授权服务器;失败则保留现状,不会静默清码。',
|
||||
deactivateConfirm: '确认退出',
|
||||
deactivating: '退出中…',
|
||||
deactivated: '已退出授权,本机已回到免费版',
|
||||
deactivateFailed: '退出授权失败',
|
||||
deactivateOfflineNote: '已解除本机离线授权。后台名额可能仍占用,如需释放请联系运营处理。',
|
||||
legacyBanner: '旧版 HMAC 许可码迁移窗口:请尽快换发 LIC- / LICOFF- 授权码,旧码在窗口内仍有效,不会静默失效。',
|
||||
graceBanner: '离线宽限中:剩余 {{days}} 天,恢复网络后续验。',
|
||||
status: {
|
||||
|
||||
@@ -6,19 +6,22 @@ import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { KeyRound } from 'lucide-react'
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog'
|
||||
import { InstanceIdRow } from '@/components/InstanceIdRow'
|
||||
import { LicenseStatusBanners } from '@/components/LicenseStatusBanners'
|
||||
import { licenseApi } from '@/api/license'
|
||||
import { useLicense } from '@/hooks/useLicense'
|
||||
import { getUser } from '@/lib/auth-storage'
|
||||
|
||||
/** 授权区块:当前版本状态 + 许可码激活(仅管理员可操作) */
|
||||
/** 授权区块:当前版本状态 + 许可码激活 / 退出授权(仅管理员可操作) */
|
||||
export const LicenseSection = memo(function LicenseSection() {
|
||||
const { t } = useTranslation('license')
|
||||
const { pro, info, reload } = useLicense()
|
||||
const isAdmin = getUser()?.role === 'admin'
|
||||
const [key, setKey] = useState('')
|
||||
const [activating, setActivating] = useState(false)
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [deactivating, setDeactivating] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const focusKey = () => {
|
||||
@@ -41,6 +44,24 @@ export const LicenseSection = memo(function LicenseSection() {
|
||||
}
|
||||
}
|
||||
|
||||
const deactivate = async () => {
|
||||
setDeactivating(true)
|
||||
try {
|
||||
const res = await licenseApi.deactivate()
|
||||
if (res?.offlineLocalOnly) {
|
||||
toast.success(res.message || t('deactivateOfflineNote'))
|
||||
} else {
|
||||
toast.success(res?.message || t('deactivated'))
|
||||
}
|
||||
setConfirmOpen(false)
|
||||
reload()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('deactivateFailed'))
|
||||
} finally {
|
||||
setDeactivating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const expiry = info?.expiresAt
|
||||
? info.expiresAt > 0
|
||||
? new Date(info.expiresAt * 1000).toLocaleDateString()
|
||||
@@ -80,21 +101,40 @@ export const LicenseSection = memo(function LicenseSection() {
|
||||
{isAdmin && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t('activateHint')}</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={key}
|
||||
onChange={e => setKey(e.target.value)}
|
||||
placeholder={t('keyPlaceholder')}
|
||||
className="font-mono text-sm"
|
||||
className="min-w-[12rem] flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Button onClick={() => void activate()} disabled={activating || !key.trim()}>
|
||||
{activating ? t('activating') : t('activate')}
|
||||
</Button>
|
||||
{pro && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-destructive/40 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
disabled={deactivating}
|
||||
>
|
||||
{t('deactivate')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={setConfirmOpen}
|
||||
title={t('deactivateConfirmTitle')}
|
||||
description={t('deactivateConfirmDesc')}
|
||||
confirmText={t('deactivateConfirm')}
|
||||
loading={deactivating}
|
||||
onConfirm={() => void deactivate()}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
Reference in new issue
Block a user