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:
opsx committed 2026-11-01 03:08:37 +08:00
1 parent af0298e994
commit 62d9b81a70
10 files changed
+265 -45

No files matched your search

+2 -1
View File
@@ -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>
)
})
+18 -3
View File
@@ -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>
+8 -1
View File
@@ -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.',
+8 -1
View File
@@ -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>