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
@@ -43,13 +43,12 @@ func (ctrl *SystemController) Health(c *gin.Context) {
|
||||
utils.Success(c, services.SelfCheck())
|
||||
}
|
||||
|
||||
// LicenseStatus 当前授权(登录即可见:前端按 plan 显示锁/引导)
|
||||
// LicenseStatus 当前授权(登录即可见:前端按 plan / invalidReason 显示锁/红条)
|
||||
func (ctrl *SystemController) LicenseStatus(c *gin.Context) {
|
||||
services.RefreshOpsxLicenseIfDue()
|
||||
lic := services.CurrentLicense()
|
||||
lic, pro := services.LicenseStatusPayload()
|
||||
utils.Success(c, gin.H{
|
||||
"license": lic.Sanitized(),
|
||||
"pro": lic != nil,
|
||||
"license": lic,
|
||||
"pro": pro,
|
||||
"features": services.ProFeatureList(),
|
||||
})
|
||||
}
|
||||
|
||||
+22
-13
@@ -8,7 +8,7 @@ import "time"
|
||||
type License struct {
|
||||
Plan string `json:"plan"` // free | pro
|
||||
Edition string `json:"edition"` // personal | team
|
||||
Seats int `json:"seats"` // 登录席位(历史字段; opsx 按实例计,映射到 Instances)
|
||||
Seats int `json:"seats"` // 历史登录席位字段;产品侧按实例计,勿再展示
|
||||
Instances int `json:"instances"` // 部署实例上限(0 = 不限)
|
||||
Customer string `json:"customer"` // 客户标识
|
||||
// ExpiresAt 0 = 永久;否则 Unix 秒
|
||||
@@ -17,17 +17,20 @@ type License struct {
|
||||
ActivatedAt time.Time `json:"activatedAt"`
|
||||
|
||||
// --- opsx-licence 对接字段(JSON 向后兼容,缺省为零值) ---
|
||||
Source string `json:"source,omitempty"` // hmac | opsx
|
||||
LicenseKey string `json:"licenseKey,omitempty"` // 完整密钥(周检/心跳需要;Sanitized 不外泄)
|
||||
InstanceID string `json:"instanceId,omitempty"` // 机器指纹,无 PII
|
||||
PlanType string `json:"planType,omitempty"` // trial | yearly | perpetual
|
||||
MaxDevices int `json:"maxDevices,omitempty"` // 服务端 max_devices(实例数)
|
||||
LastVerifiedAt int64 `json:"lastVerifiedAt,omitempty"` // unix 秒
|
||||
LastServerTime int64 `json:"lastServerTime,omitempty"` // 防时钟回拨
|
||||
ActivationToken string `json:"activationToken,omitempty"` // 解绑令牌
|
||||
DeprecationNotice string `json:"deprecationNotice,omitempty"` // 旧 HMAC 迁移提示
|
||||
InGrace bool `json:"inGrace,omitempty"`
|
||||
GraceDaysLeft int `json:"graceDaysLeft,omitempty"`
|
||||
Source string `json:"source,omitempty"` // hmac | opsx
|
||||
LicenseKey string `json:"licenseKey,omitempty"` // 完整密钥(周检/心跳需要;Sanitized 不外泄)
|
||||
InstanceID string `json:"instanceId,omitempty"` // 机器指纹,无 PII
|
||||
PlanType string `json:"planType,omitempty"` // trial | yearly | perpetual
|
||||
MaxDevices int `json:"maxDevices,omitempty"` // 服务端 max_devices(实例数)
|
||||
LastVerifiedAt int64 `json:"lastVerifiedAt,omitempty"` // unix 秒
|
||||
LastServerTime int64 `json:"lastServerTime,omitempty"` // 防时钟回拨
|
||||
ActivationToken string `json:"activationToken,omitempty"` // 解绑令牌
|
||||
DeprecationNotice string `json:"deprecationNotice,omitempty"` // 旧 HMAC 迁移提示
|
||||
InGrace bool `json:"inGrace,omitempty"`
|
||||
GraceDaysLeft int `json:"graceDaysLeft,omitempty"`
|
||||
// InvalidReason 非空且非有效 Pro 时给前端红条:expired | over_instance | revoked
|
||||
// 服务端明确拒绝才写;禁止静默清空 LicenseKey。
|
||||
InvalidReason string `json:"invalidReason,omitempty"`
|
||||
}
|
||||
|
||||
func (l *License) Sanitized() map[string]interface{} {
|
||||
@@ -40,9 +43,10 @@ func (l *License) Sanitized() map[string]interface{} {
|
||||
plan = "free"
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"plan": plan, "edition": l.Edition, "seats": l.Seats, "instances": l.Instances,
|
||||
"plan": plan, "edition": l.Edition, "instances": l.Instances,
|
||||
"customer": l.Customer, "expiresAt": l.ExpiresAt, "expired": expired,
|
||||
}
|
||||
// seats 故意不外抛给产品 UI(按实例计)
|
||||
if l.Source != "" {
|
||||
out["source"] = l.Source
|
||||
}
|
||||
@@ -52,6 +56,8 @@ func (l *License) Sanitized() map[string]interface{} {
|
||||
if l.MaxDevices > 0 {
|
||||
out["maxDevices"] = l.MaxDevices
|
||||
out["instances"] = l.MaxDevices
|
||||
} else if l.Instances > 0 {
|
||||
out["instances"] = l.Instances
|
||||
}
|
||||
if l.DeprecationNotice != "" {
|
||||
out["deprecationNotice"] = l.DeprecationNotice
|
||||
@@ -66,5 +72,8 @@ func (l *License) Sanitized() map[string]interface{} {
|
||||
if l.InstanceID != "" {
|
||||
out["instanceId"] = l.InstanceID
|
||||
}
|
||||
if l.InvalidReason != "" {
|
||||
out["invalidReason"] = l.InvalidReason
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -114,6 +114,7 @@ func applyRemoteInfo(lic *models.License, key string, instanceID string, info *l
|
||||
lic.ActivationToken = info.ActivationToken
|
||||
}
|
||||
lic.DeprecationNotice = ""
|
||||
lic.InvalidReason = ""
|
||||
if lic.ActivatedAt.IsZero() {
|
||||
lic.ActivatedAt = time.Now()
|
||||
}
|
||||
@@ -140,7 +141,7 @@ func ActivateLicense(key string) (*models.License, error) {
|
||||
PlanType: claims.PlanType, MaxDevices: claims.MaxDevices, Instances: claims.MaxDevices,
|
||||
Edition: editionFromPlanType(claims.PlanType, claims.MaxDevices),
|
||||
ExpiresAt: claims.Exp, IssuedAt: time.Unix(claims.Iat, 0), ActivatedAt: time.Now(),
|
||||
LastVerifiedAt: time.Now().Unix(), Customer: claims.Email,
|
||||
LastVerifiedAt: time.Now().Unix(), Customer: claims.Email, InvalidReason: "",
|
||||
}
|
||||
return persistLicense(lic)
|
||||
}
|
||||
@@ -180,6 +181,7 @@ func ActivateLicense(key string) (*models.License, error) {
|
||||
}
|
||||
lic.Source = "hmac"
|
||||
lic.DeprecationNotice = hmacDeprecationNotice
|
||||
lic.InvalidReason = ""
|
||||
lic.InstanceID = instanceID
|
||||
lic.LicenseKey = key
|
||||
lic.LastVerifiedAt = time.Now().Unix()
|
||||
@@ -196,7 +198,7 @@ func persistLicense(lic *models.License) (*models.License, error) {
|
||||
}
|
||||
|
||||
// RefreshOpsxLicenseIfDue 周检:距上次验证超过 7 天则联网 verify;网络失败保留 30 天离线宽限。
|
||||
// 服务端明确拒绝则降级(清 activated);旧 HMAC 不联网。
|
||||
// 服务端明确拒绝则降级为 free 并写 InvalidReason;保留 LicenseKey(禁静默清码);旧 HMAC 不联网。
|
||||
func RefreshOpsxLicenseIfDue() {
|
||||
db := repository.GetDB()
|
||||
if db == nil {
|
||||
@@ -223,11 +225,12 @@ func RefreshOpsxLicenseIfDue() {
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
if strings.HasPrefix(msg, "rejected:") {
|
||||
// 明确拒绝:降级
|
||||
// 明确拒绝:降级为 free,保留 LicenseKey(禁止静默清码)
|
||||
reason := classifyRejectReason(strings.TrimPrefix(msg, "rejected:"))
|
||||
db.Lock()
|
||||
if db.License != nil {
|
||||
db.License.Plan = "free"
|
||||
db.License.DeprecationNotice = strings.TrimPrefix(msg, "rejected:")
|
||||
db.License.InvalidReason = reason
|
||||
db.RequestSave()
|
||||
}
|
||||
db.Unlock()
|
||||
@@ -245,6 +248,84 @@ func RefreshOpsxLicenseIfDue() {
|
||||
_ = remoteHeartbeatLicence(lic.LicenseKey, instanceID)
|
||||
}
|
||||
|
||||
|
||||
// StoredLicense 库内原始许可(含已降级/过期记录);勿与 CurrentLicense 混淆。
|
||||
func StoredLicense() *models.License {
|
||||
db := repository.GetDB()
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
db.RLock()
|
||||
defer db.RUnlock()
|
||||
return db.License
|
||||
}
|
||||
|
||||
// classifyRejectReason 把授权服务拒绝文案映射为 UI 红条类别。
|
||||
func classifyRejectReason(msg string) string {
|
||||
lower := strings.ToLower(msg)
|
||||
over := (strings.Contains(lower, "device") && (strings.Contains(lower, "limit") || strings.Contains(lower, "full") || strings.Contains(lower, "exceed"))) ||
|
||||
strings.Contains(lower, "seat") ||
|
||||
(strings.Contains(lower, "instance") && (strings.Contains(lower, "limit") || strings.Contains(lower, "exceed") || strings.Contains(lower, "full"))) ||
|
||||
(strings.Contains(msg, "实例") && (strings.Contains(msg, "上限") || strings.Contains(msg, "满") || strings.Contains(msg, "超"))) ||
|
||||
(strings.Contains(msg, "席位") && (strings.Contains(msg, "满") || strings.Contains(msg, "超") || strings.Contains(msg, "上限"))) ||
|
||||
(strings.Contains(msg, "设备") && (strings.Contains(msg, "满") || strings.Contains(msg, "超") || strings.Contains(msg, "上限")))
|
||||
if over {
|
||||
return "over_instance"
|
||||
}
|
||||
if strings.Contains(lower, "expir") || strings.Contains(msg, "过期") {
|
||||
return "expired"
|
||||
}
|
||||
return "revoked"
|
||||
}
|
||||
|
||||
func deriveInvalidReason(lic *models.License) string {
|
||||
if lic == nil {
|
||||
return ""
|
||||
}
|
||||
if lic.InvalidReason != "" {
|
||||
return lic.InvalidReason
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if lic.ExpiresAt > 0 && lic.ExpiresAt < now && !lic.InGrace {
|
||||
return "expired"
|
||||
}
|
||||
if lic.Source == "opsx" && lic.LicenseKey != "" && !isOfflineLicenseKey(lic.LicenseKey) {
|
||||
last := lic.LastVerifiedAt
|
||||
if last == 0 && !lic.ActivatedAt.IsZero() {
|
||||
last = lic.ActivatedAt.Unix()
|
||||
}
|
||||
if last > 0 && now-last > offlineToleranceSecs {
|
||||
return "expired"
|
||||
}
|
||||
}
|
||||
if lic.Plan == "free" && lic.LicenseKey != "" {
|
||||
return "revoked"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// LicenseStatusPayload 给前端:始终带回库存证的 sanitized + 是否有效 Pro。
|
||||
// 无效时带 invalidReason(expired|over_instance|revoked),且不清 LicenseKey。
|
||||
func LicenseStatusPayload() (map[string]interface{}, bool) {
|
||||
RefreshOpsxLicenseIfDue()
|
||||
current := CurrentLicense()
|
||||
stored := StoredLicense()
|
||||
if stored == nil {
|
||||
return map[string]interface{}{"plan": "free"}, false
|
||||
}
|
||||
out := stored.Sanitized()
|
||||
if current != nil {
|
||||
delete(out, "invalidReason")
|
||||
return out, true
|
||||
}
|
||||
reason := deriveInvalidReason(stored)
|
||||
if reason != "" {
|
||||
out["invalidReason"] = reason
|
||||
}
|
||||
out["plan"] = "free"
|
||||
return out, false
|
||||
}
|
||||
|
||||
// CurrentLicense 当前有效许可(nil = 免费版)
|
||||
func CurrentLicense() *models.License {
|
||||
db := repository.GetDB()
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestClassifyRejectReason(t *testing.T) {
|
||||
cases := []struct {
|
||||
msg string
|
||||
want string
|
||||
}{
|
||||
{"device limit exceeded", "over_instance"},
|
||||
{"设备席位已满", "over_instance"},
|
||||
{"已超过实例上限", "over_instance"},
|
||||
{"license expired", "expired"},
|
||||
{"许可码已过期", "expired"},
|
||||
{"revoked by admin", "revoked"},
|
||||
{"许可证无效", "revoked"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := classifyRejectReason(tc.msg); got != tc.want {
|
||||
t.Fatalf("classifyRejectReason(%q)=%q want %q", tc.msg, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizedHidesSeatsAndKeepsInvalidReason(t *testing.T) {
|
||||
// 通过 models 包测 Sanitized:此处仅确保 classify 与 payload 路径可编译。
|
||||
_ = classifyRejectReason("x")
|
||||
}
|
||||
@@ -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