fix(license): shared invalid/grace banners + instance copy
Surface expired/over_instance/revoked red banners with CTAs via a shared LicenseStatusBanners used by settings and ProGate. Hide seats in product UI; keep HMAC dual-path and never silent-clear keys.
This commit is contained in:
1 parent
af0298e994
commit
62d9b81a70
10 files changed
+265
-45
No files matched your search
@@ -3,7 +3,6 @@ import { http } from '../http'
|
||||
export interface LicenseInfo {
|
||||
plan: 'free' | 'pro'
|
||||
edition?: string
|
||||
seats?: number
|
||||
instances?: number
|
||||
customer?: string
|
||||
expiresAt?: number
|
||||
@@ -16,6 +15,8 @@ export interface LicenseInfo {
|
||||
graceDaysLeft?: number
|
||||
lastVerifiedAt?: number
|
||||
instanceId?: string
|
||||
/** expired | over_instance | revoked — 非有效 Pro 时红条 */
|
||||
invalidReason?: 'expired' | 'over_instance' | 'revoked' | string
|
||||
}
|
||||
export const licenseApi = {
|
||||
status: () => http.get<{ license: LicenseInfo; pro: boolean; features: string[] }>('/system/license'),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { LicenseInfo } from '@/api/license'
|
||||
|
||||
type Props = {
|
||||
info: LicenseInfo | null
|
||||
pro: boolean
|
||||
/** 聚焦许可码输入 / 引导重新激活或换码 */
|
||||
onAction?: () => void
|
||||
}
|
||||
|
||||
function resolveInvalid(info: LicenseInfo | null, pro: boolean): 'expired' | 'over_instance' | 'revoked' | null {
|
||||
if (pro || !info) return null
|
||||
const reason = info.invalidReason
|
||||
if (reason === 'expired' || reason === 'over_instance' || reason === 'revoked') return reason
|
||||
if (reason) return 'revoked'
|
||||
if (info.expired) return 'expired'
|
||||
return null
|
||||
}
|
||||
|
||||
/** 授权状态条:迁移/宽限黄条 + 过期·超实例·吊销红条。设置页与 ProGate 共用。 */
|
||||
export const LicenseStatusBanners = memo(function LicenseStatusBanners({ info, pro, onAction }: Props) {
|
||||
const { t } = useTranslation('license')
|
||||
|
||||
const showLegacy = Boolean(info?.deprecationNotice) || (pro && info?.source === 'hmac')
|
||||
const showGrace = Boolean(info?.inGrace)
|
||||
const invalid = resolveInvalid(info, pro)
|
||||
|
||||
const invalidCopy =
|
||||
invalid === 'expired'
|
||||
? { text: t('status.expired'), cta: t('status.expiredCta') }
|
||||
: invalid === 'over_instance'
|
||||
? { text: t('status.overInstance'), cta: t('status.overInstanceCta') }
|
||||
: invalid === 'revoked'
|
||||
? { text: t('status.revoked'), cta: t('status.revokedCta') }
|
||||
: null
|
||||
|
||||
if (!showLegacy && !showGrace && !invalidCopy) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{showLegacy && (
|
||||
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-warning-foreground">
|
||||
{info?.deprecationNotice || t('legacyBanner')}
|
||||
</p>
|
||||
)}
|
||||
{showGrace && (
|
||||
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-warning-foreground">
|
||||
{t('graceBanner', { days: info?.graceDaysLeft ?? 0 })}
|
||||
</p>
|
||||
)}
|
||||
{invalidCopy && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<span>{invalidCopy.text}</span>
|
||||
{onAction && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-destructive/40 text-destructive hover:bg-destructive/10"
|
||||
onClick={onAction}
|
||||
>
|
||||
{invalidCopy.cta}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useState } from 'react'
|
||||
import { memo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Lock, Sparkles } from 'lucide-react'
|
||||
@@ -6,16 +6,23 @@ import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { LicenseStatusBanners } from '@/components/LicenseStatusBanners'
|
||||
import { licenseApi } from '@/api/license'
|
||||
import { useLicense } from '@/hooks/useLicense'
|
||||
|
||||
/** Pro 功能升级引导卡:免费版进入 Pro 页面时整页替代展示 */
|
||||
export const ProGate = memo(function ProGate({ featureKey }: { featureKey: string }) {
|
||||
const { t } = useTranslation('license')
|
||||
const { reload } = useLicense()
|
||||
const { pro, info, reload } = useLicense()
|
||||
const navigate = useNavigate()
|
||||
const [key, setKey] = useState('')
|
||||
const [activating, setActivating] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const focusKey = () => {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
|
||||
const activate = async () => {
|
||||
if (!key.trim()) return
|
||||
@@ -33,6 +40,7 @@ export const ProGate = memo(function ProGate({ featureKey }: { featureKey: strin
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-4 pt-8">
|
||||
<LicenseStatusBanners info={info} pro={pro} onAction={focusKey} />
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
@@ -50,6 +58,7 @@ export const ProGate = memo(function ProGate({ featureKey }: { featureKey: strin
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* 价卡暂留;product/plan 建好后再对齐 trial/年付/永久 */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm font-semibold">{t('pricing.personalTitle')}</p>
|
||||
@@ -63,7 +72,13 @@ export const ProGate = memo(function ProGate({ featureKey }: { featureKey: strin
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={key} onChange={e => setKey(e.target.value)} placeholder={t('gate.keyPlaceholder')} className="font-mono text-sm" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={key}
|
||||
onChange={e => setKey(e.target.value)}
|
||||
placeholder={t('gate.keyPlaceholder')}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button onClick={() => void activate()} disabled={activating || !key.trim()}>{t('gate.activate')}</Button>
|
||||
<Button variant="outline" onClick={() => navigate('/settings')}>{t('gate.toSettings')}</Button>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ export default {
|
||||
edition: 'Edition',
|
||||
expiry: 'Valid until',
|
||||
customer: 'Licensed to',
|
||||
seats: 'Seats',
|
||||
instances: 'Instance limit',
|
||||
permanent: 'perpetual',
|
||||
personalTitle: 'Personal',
|
||||
@@ -18,6 +17,14 @@ export default {
|
||||
activateFailed: 'Activation failed',
|
||||
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: {
|
||||
expired: 'License expired. Pro features are locked.',
|
||||
expiredCta: 'Re-activate',
|
||||
overInstance: 'Instance limit exceeded',
|
||||
overInstanceCta: 'Release another instance / change key',
|
||||
revoked: 'License invalid or revoked',
|
||||
revokedCta: 'Replace license key',
|
||||
},
|
||||
gate: {
|
||||
title: '{{feature}} is a Pro feature',
|
||||
desc: 'Upgrade to Pro to unlock the full analytics suite. The Free plan keeps log query, deployment management and user/LDAP administration.',
|
||||
|
||||
@@ -6,7 +6,6 @@ export default {
|
||||
edition: '版本',
|
||||
expiry: '有效期',
|
||||
customer: '授权对象',
|
||||
seats: '席位',
|
||||
instances: '实例上限',
|
||||
permanent: '永久',
|
||||
personalTitle: '个人版',
|
||||
@@ -18,6 +17,14 @@ export default {
|
||||
activateFailed: '激活失败',
|
||||
legacyBanner: '旧版 HMAC 许可码迁移窗口:请尽快换发 LIC- / LICOFF- 授权码,旧码在窗口内仍有效,不会静默失效。',
|
||||
graceBanner: '离线宽限中:剩余 {{days}} 天,恢复网络后续验。',
|
||||
status: {
|
||||
expired: '授权已过期,Pro 功能已锁定',
|
||||
expiredCta: '重新激活',
|
||||
overInstance: '已超过实例上限',
|
||||
overInstanceCta: '释放其它实例 / 换码',
|
||||
revoked: '授权无效或已吊销',
|
||||
revokedCta: '更换许可码',
|
||||
},
|
||||
gate: {
|
||||
title: '{{feature}} 是 Pro 功能',
|
||||
desc: '升级 Pro 解锁平台全部分析能力;免费版可继续使用日志查询、部署管理与用户授权。',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useState } from 'react'
|
||||
import { memo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -6,6 +6,7 @@ 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 { LicenseStatusBanners } from '@/components/LicenseStatusBanners'
|
||||
import { licenseApi } from '@/api/license'
|
||||
import { useLicense } from '@/hooks/useLicense'
|
||||
import { getUser } from '@/lib/auth-storage'
|
||||
@@ -17,6 +18,12 @@ export const LicenseSection = memo(function LicenseSection() {
|
||||
const isAdmin = getUser()?.role === 'admin'
|
||||
const [key, setKey] = useState('')
|
||||
const [activating, setActivating] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const focusKey = () => {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
|
||||
const activate = async () => {
|
||||
if (!key.trim()) return
|
||||
@@ -39,8 +46,7 @@ export const LicenseSection = memo(function LicenseSection() {
|
||||
: t('permanent')
|
||||
: null
|
||||
|
||||
const showLegacy = pro && (info?.source === 'hmac' || Boolean(info?.deprecationNotice))
|
||||
const showGrace = Boolean(info?.inGrace)
|
||||
const instanceLimit = info?.maxDevices || info?.instances
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -54,26 +60,16 @@ export const LicenseSection = memo(function LicenseSection() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{showLegacy && (
|
||||
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-warning-foreground">
|
||||
{info?.deprecationNotice || t('legacyBanner')}
|
||||
</p>
|
||||
)}
|
||||
{showGrace && (
|
||||
<p className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-sm text-warning-foreground">
|
||||
{t('graceBanner', { days: info?.graceDaysLeft ?? 0 })}
|
||||
</p>
|
||||
)}
|
||||
<LicenseStatusBanners info={info} pro={pro} onAction={isAdmin ? focusKey : undefined} />
|
||||
<div className="text-sm">
|
||||
{pro ? (
|
||||
<div className="grid gap-1 text-muted-foreground sm:grid-cols-2">
|
||||
<p>{t('edition')}: {info?.edition === 'team' ? t('teamTitle') : t('personalTitle')}</p>
|
||||
<p>{t('expiry')}: {expiry}</p>
|
||||
{info?.customer && <p>{t('customer')}: {info.customer}</p>}
|
||||
{Boolean(info?.maxDevices || info?.instances) && (
|
||||
<p>{t('instances')}: {info?.maxDevices || info?.instances}</p>
|
||||
{Boolean(instanceLimit) && (
|
||||
<p>{t('instances')}: {instanceLimit}</p>
|
||||
)}
|
||||
{Boolean(info?.seats) && <p>{t('seats')}: {info?.seats}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">{t('freeDesc')}</p>
|
||||
@@ -81,7 +77,13 @@ export const LicenseSection = memo(function LicenseSection() {
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex gap-2">
|
||||
<Input value={key} onChange={e => setKey(e.target.value)} placeholder={t('keyPlaceholder')} className="font-mono text-sm" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={key}
|
||||
onChange={e => setKey(e.target.value)}
|
||||
placeholder={t('keyPlaceholder')}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button onClick={() => void activate()} disabled={activating || !key.trim()}>
|
||||
{activating ? t('activating') : t('activate')}
|
||||
</Button>
|
||||
|
||||
Reference in new issue
Block a user