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())
|
utils.Success(c, services.SelfCheck())
|
||||||
}
|
}
|
||||||
|
|
||||||
// LicenseStatus 当前授权(登录即可见:前端按 plan 显示锁/引导)
|
// LicenseStatus 当前授权(登录即可见:前端按 plan / invalidReason 显示锁/红条)
|
||||||
func (ctrl *SystemController) LicenseStatus(c *gin.Context) {
|
func (ctrl *SystemController) LicenseStatus(c *gin.Context) {
|
||||||
services.RefreshOpsxLicenseIfDue()
|
lic, pro := services.LicenseStatusPayload()
|
||||||
lic := services.CurrentLicense()
|
|
||||||
utils.Success(c, gin.H{
|
utils.Success(c, gin.H{
|
||||||
"license": lic.Sanitized(),
|
"license": lic,
|
||||||
"pro": lic != nil,
|
"pro": pro,
|
||||||
"features": services.ProFeatureList(),
|
"features": services.ProFeatureList(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-13
@@ -8,7 +8,7 @@ import "time"
|
|||||||
type License struct {
|
type License struct {
|
||||||
Plan string `json:"plan"` // free | pro
|
Plan string `json:"plan"` // free | pro
|
||||||
Edition string `json:"edition"` // personal | team
|
Edition string `json:"edition"` // personal | team
|
||||||
Seats int `json:"seats"` // 登录席位(历史字段; opsx 按实例计,映射到 Instances)
|
Seats int `json:"seats"` // 历史登录席位字段;产品侧按实例计,勿再展示
|
||||||
Instances int `json:"instances"` // 部署实例上限(0 = 不限)
|
Instances int `json:"instances"` // 部署实例上限(0 = 不限)
|
||||||
Customer string `json:"customer"` // 客户标识
|
Customer string `json:"customer"` // 客户标识
|
||||||
// ExpiresAt 0 = 永久;否则 Unix 秒
|
// ExpiresAt 0 = 永久;否则 Unix 秒
|
||||||
@@ -17,17 +17,20 @@ type License struct {
|
|||||||
ActivatedAt time.Time `json:"activatedAt"`
|
ActivatedAt time.Time `json:"activatedAt"`
|
||||||
|
|
||||||
// --- opsx-licence 对接字段(JSON 向后兼容,缺省为零值) ---
|
// --- opsx-licence 对接字段(JSON 向后兼容,缺省为零值) ---
|
||||||
Source string `json:"source,omitempty"` // hmac | opsx
|
Source string `json:"source,omitempty"` // hmac | opsx
|
||||||
LicenseKey string `json:"licenseKey,omitempty"` // 完整密钥(周检/心跳需要;Sanitized 不外泄)
|
LicenseKey string `json:"licenseKey,omitempty"` // 完整密钥(周检/心跳需要;Sanitized 不外泄)
|
||||||
InstanceID string `json:"instanceId,omitempty"` // 机器指纹,无 PII
|
InstanceID string `json:"instanceId,omitempty"` // 机器指纹,无 PII
|
||||||
PlanType string `json:"planType,omitempty"` // trial | yearly | perpetual
|
PlanType string `json:"planType,omitempty"` // trial | yearly | perpetual
|
||||||
MaxDevices int `json:"maxDevices,omitempty"` // 服务端 max_devices(实例数)
|
MaxDevices int `json:"maxDevices,omitempty"` // 服务端 max_devices(实例数)
|
||||||
LastVerifiedAt int64 `json:"lastVerifiedAt,omitempty"` // unix 秒
|
LastVerifiedAt int64 `json:"lastVerifiedAt,omitempty"` // unix 秒
|
||||||
LastServerTime int64 `json:"lastServerTime,omitempty"` // 防时钟回拨
|
LastServerTime int64 `json:"lastServerTime,omitempty"` // 防时钟回拨
|
||||||
ActivationToken string `json:"activationToken,omitempty"` // 解绑令牌
|
ActivationToken string `json:"activationToken,omitempty"` // 解绑令牌
|
||||||
DeprecationNotice string `json:"deprecationNotice,omitempty"` // 旧 HMAC 迁移提示
|
DeprecationNotice string `json:"deprecationNotice,omitempty"` // 旧 HMAC 迁移提示
|
||||||
InGrace bool `json:"inGrace,omitempty"`
|
InGrace bool `json:"inGrace,omitempty"`
|
||||||
GraceDaysLeft int `json:"graceDaysLeft,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{} {
|
func (l *License) Sanitized() map[string]interface{} {
|
||||||
@@ -40,9 +43,10 @@ func (l *License) Sanitized() map[string]interface{} {
|
|||||||
plan = "free"
|
plan = "free"
|
||||||
}
|
}
|
||||||
out := map[string]interface{}{
|
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,
|
"customer": l.Customer, "expiresAt": l.ExpiresAt, "expired": expired,
|
||||||
}
|
}
|
||||||
|
// seats 故意不外抛给产品 UI(按实例计)
|
||||||
if l.Source != "" {
|
if l.Source != "" {
|
||||||
out["source"] = l.Source
|
out["source"] = l.Source
|
||||||
}
|
}
|
||||||
@@ -52,6 +56,8 @@ func (l *License) Sanitized() map[string]interface{} {
|
|||||||
if l.MaxDevices > 0 {
|
if l.MaxDevices > 0 {
|
||||||
out["maxDevices"] = l.MaxDevices
|
out["maxDevices"] = l.MaxDevices
|
||||||
out["instances"] = l.MaxDevices
|
out["instances"] = l.MaxDevices
|
||||||
|
} else if l.Instances > 0 {
|
||||||
|
out["instances"] = l.Instances
|
||||||
}
|
}
|
||||||
if l.DeprecationNotice != "" {
|
if l.DeprecationNotice != "" {
|
||||||
out["deprecationNotice"] = l.DeprecationNotice
|
out["deprecationNotice"] = l.DeprecationNotice
|
||||||
@@ -66,5 +72,8 @@ func (l *License) Sanitized() map[string]interface{} {
|
|||||||
if l.InstanceID != "" {
|
if l.InstanceID != "" {
|
||||||
out["instanceId"] = l.InstanceID
|
out["instanceId"] = l.InstanceID
|
||||||
}
|
}
|
||||||
|
if l.InvalidReason != "" {
|
||||||
|
out["invalidReason"] = l.InvalidReason
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -114,6 +114,7 @@ func applyRemoteInfo(lic *models.License, key string, instanceID string, info *l
|
|||||||
lic.ActivationToken = info.ActivationToken
|
lic.ActivationToken = info.ActivationToken
|
||||||
}
|
}
|
||||||
lic.DeprecationNotice = ""
|
lic.DeprecationNotice = ""
|
||||||
|
lic.InvalidReason = ""
|
||||||
if lic.ActivatedAt.IsZero() {
|
if lic.ActivatedAt.IsZero() {
|
||||||
lic.ActivatedAt = time.Now()
|
lic.ActivatedAt = time.Now()
|
||||||
}
|
}
|
||||||
@@ -140,7 +141,7 @@ func ActivateLicense(key string) (*models.License, error) {
|
|||||||
PlanType: claims.PlanType, MaxDevices: claims.MaxDevices, Instances: claims.MaxDevices,
|
PlanType: claims.PlanType, MaxDevices: claims.MaxDevices, Instances: claims.MaxDevices,
|
||||||
Edition: editionFromPlanType(claims.PlanType, claims.MaxDevices),
|
Edition: editionFromPlanType(claims.PlanType, claims.MaxDevices),
|
||||||
ExpiresAt: claims.Exp, IssuedAt: time.Unix(claims.Iat, 0), ActivatedAt: time.Now(),
|
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)
|
return persistLicense(lic)
|
||||||
}
|
}
|
||||||
@@ -180,6 +181,7 @@ func ActivateLicense(key string) (*models.License, error) {
|
|||||||
}
|
}
|
||||||
lic.Source = "hmac"
|
lic.Source = "hmac"
|
||||||
lic.DeprecationNotice = hmacDeprecationNotice
|
lic.DeprecationNotice = hmacDeprecationNotice
|
||||||
|
lic.InvalidReason = ""
|
||||||
lic.InstanceID = instanceID
|
lic.InstanceID = instanceID
|
||||||
lic.LicenseKey = key
|
lic.LicenseKey = key
|
||||||
lic.LastVerifiedAt = time.Now().Unix()
|
lic.LastVerifiedAt = time.Now().Unix()
|
||||||
@@ -196,7 +198,7 @@ func persistLicense(lic *models.License) (*models.License, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RefreshOpsxLicenseIfDue 周检:距上次验证超过 7 天则联网 verify;网络失败保留 30 天离线宽限。
|
// RefreshOpsxLicenseIfDue 周检:距上次验证超过 7 天则联网 verify;网络失败保留 30 天离线宽限。
|
||||||
// 服务端明确拒绝则降级(清 activated);旧 HMAC 不联网。
|
// 服务端明确拒绝则降级为 free 并写 InvalidReason;保留 LicenseKey(禁静默清码);旧 HMAC 不联网。
|
||||||
func RefreshOpsxLicenseIfDue() {
|
func RefreshOpsxLicenseIfDue() {
|
||||||
db := repository.GetDB()
|
db := repository.GetDB()
|
||||||
if db == nil {
|
if db == nil {
|
||||||
@@ -223,11 +225,12 @@ func RefreshOpsxLicenseIfDue() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
if strings.HasPrefix(msg, "rejected:") {
|
if strings.HasPrefix(msg, "rejected:") {
|
||||||
// 明确拒绝:降级
|
// 明确拒绝:降级为 free,保留 LicenseKey(禁止静默清码)
|
||||||
|
reason := classifyRejectReason(strings.TrimPrefix(msg, "rejected:"))
|
||||||
db.Lock()
|
db.Lock()
|
||||||
if db.License != nil {
|
if db.License != nil {
|
||||||
db.License.Plan = "free"
|
db.License.Plan = "free"
|
||||||
db.License.DeprecationNotice = strings.TrimPrefix(msg, "rejected:")
|
db.License.InvalidReason = reason
|
||||||
db.RequestSave()
|
db.RequestSave()
|
||||||
}
|
}
|
||||||
db.Unlock()
|
db.Unlock()
|
||||||
@@ -245,6 +248,84 @@ func RefreshOpsxLicenseIfDue() {
|
|||||||
_ = remoteHeartbeatLicence(lic.LicenseKey, instanceID)
|
_ = 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 = 免费版)
|
// CurrentLicense 当前有效许可(nil = 免费版)
|
||||||
func CurrentLicense() *models.License {
|
func CurrentLicense() *models.License {
|
||||||
db := repository.GetDB()
|
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 {
|
export interface LicenseInfo {
|
||||||
plan: 'free' | 'pro'
|
plan: 'free' | 'pro'
|
||||||
edition?: string
|
edition?: string
|
||||||
seats?: number
|
|
||||||
instances?: number
|
instances?: number
|
||||||
customer?: string
|
customer?: string
|
||||||
expiresAt?: number
|
expiresAt?: number
|
||||||
@@ -16,6 +15,8 @@ export interface LicenseInfo {
|
|||||||
graceDaysLeft?: number
|
graceDaysLeft?: number
|
||||||
lastVerifiedAt?: number
|
lastVerifiedAt?: number
|
||||||
instanceId?: string
|
instanceId?: string
|
||||||
|
/** expired | over_instance | revoked — 非有效 Pro 时红条 */
|
||||||
|
invalidReason?: 'expired' | 'over_instance' | 'revoked' | string
|
||||||
}
|
}
|
||||||
export const licenseApi = {
|
export const licenseApi = {
|
||||||
status: () => http.get<{ license: LicenseInfo; pro: boolean; features: string[] }>('/system/license'),
|
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 { useNavigate } from 'react-router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Lock, Sparkles } from 'lucide-react'
|
import { Lock, Sparkles } from 'lucide-react'
|
||||||
@@ -6,16 +6,23 @@ import { toast } from 'sonner'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { LicenseStatusBanners } from '@/components/LicenseStatusBanners'
|
||||||
import { licenseApi } from '@/api/license'
|
import { licenseApi } from '@/api/license'
|
||||||
import { useLicense } from '@/hooks/useLicense'
|
import { useLicense } from '@/hooks/useLicense'
|
||||||
|
|
||||||
/** Pro 功能升级引导卡:免费版进入 Pro 页面时整页替代展示 */
|
/** Pro 功能升级引导卡:免费版进入 Pro 页面时整页替代展示 */
|
||||||
export const ProGate = memo(function ProGate({ featureKey }: { featureKey: string }) {
|
export const ProGate = memo(function ProGate({ featureKey }: { featureKey: string }) {
|
||||||
const { t } = useTranslation('license')
|
const { t } = useTranslation('license')
|
||||||
const { reload } = useLicense()
|
const { pro, info, reload } = useLicense()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [key, setKey] = useState('')
|
const [key, setKey] = useState('')
|
||||||
const [activating, setActivating] = useState(false)
|
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 () => {
|
const activate = async () => {
|
||||||
if (!key.trim()) return
|
if (!key.trim()) return
|
||||||
@@ -33,6 +40,7 @@ export const ProGate = memo(function ProGate({ featureKey }: { featureKey: strin
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl space-y-4 pt-8">
|
<div className="mx-auto max-w-2xl space-y-4 pt-8">
|
||||||
|
<LicenseStatusBanners info={info} pro={pro} onAction={focusKey} />
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
@@ -50,6 +58,7 @@ export const ProGate = memo(function ProGate({ featureKey }: { featureKey: strin
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{/* 价卡暂留;product/plan 建好后再对齐 trial/年付/永久 */}
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<p className="text-sm font-semibold">{t('pricing.personalTitle')}</p>
|
<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>
|
</div>
|
||||||
<div className="flex gap-2">
|
<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 onClick={() => void activate()} disabled={activating || !key.trim()}>{t('gate.activate')}</Button>
|
||||||
<Button variant="outline" onClick={() => navigate('/settings')}>{t('gate.toSettings')}</Button>
|
<Button variant="outline" onClick={() => navigate('/settings')}>{t('gate.toSettings')}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export default {
|
|||||||
edition: 'Edition',
|
edition: 'Edition',
|
||||||
expiry: 'Valid until',
|
expiry: 'Valid until',
|
||||||
customer: 'Licensed to',
|
customer: 'Licensed to',
|
||||||
seats: 'Seats',
|
|
||||||
instances: 'Instance limit',
|
instances: 'Instance limit',
|
||||||
permanent: 'perpetual',
|
permanent: 'perpetual',
|
||||||
personalTitle: 'Personal',
|
personalTitle: 'Personal',
|
||||||
@@ -18,6 +17,14 @@ export default {
|
|||||||
activateFailed: 'Activation failed',
|
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.',
|
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.',
|
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: {
|
gate: {
|
||||||
title: '{{feature}} is a Pro feature',
|
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.',
|
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: '版本',
|
edition: '版本',
|
||||||
expiry: '有效期',
|
expiry: '有效期',
|
||||||
customer: '授权对象',
|
customer: '授权对象',
|
||||||
seats: '席位',
|
|
||||||
instances: '实例上限',
|
instances: '实例上限',
|
||||||
permanent: '永久',
|
permanent: '永久',
|
||||||
personalTitle: '个人版',
|
personalTitle: '个人版',
|
||||||
@@ -18,6 +17,14 @@ export default {
|
|||||||
activateFailed: '激活失败',
|
activateFailed: '激活失败',
|
||||||
legacyBanner: '旧版 HMAC 许可码迁移窗口:请尽快换发 LIC- / LICOFF- 授权码,旧码在窗口内仍有效,不会静默失效。',
|
legacyBanner: '旧版 HMAC 许可码迁移窗口:请尽快换发 LIC- / LICOFF- 授权码,旧码在窗口内仍有效,不会静默失效。',
|
||||||
graceBanner: '离线宽限中:剩余 {{days}} 天,恢复网络后续验。',
|
graceBanner: '离线宽限中:剩余 {{days}} 天,恢复网络后续验。',
|
||||||
|
status: {
|
||||||
|
expired: '授权已过期,Pro 功能已锁定',
|
||||||
|
expiredCta: '重新激活',
|
||||||
|
overInstance: '已超过实例上限',
|
||||||
|
overInstanceCta: '释放其它实例 / 换码',
|
||||||
|
revoked: '授权无效或已吊销',
|
||||||
|
revokedCta: '更换许可码',
|
||||||
|
},
|
||||||
gate: {
|
gate: {
|
||||||
title: '{{feature}} 是 Pro 功能',
|
title: '{{feature}} 是 Pro 功能',
|
||||||
desc: '升级 Pro 解锁平台全部分析能力;免费版可继续使用日志查询、部署管理与用户授权。',
|
desc: '升级 Pro 解锁平台全部分析能力;免费版可继续使用日志查询、部署管理与用户授权。',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useState } from 'react'
|
import { memo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Badge } from '@/components/ui/badge'
|
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { KeyRound } from 'lucide-react'
|
import { KeyRound } from 'lucide-react'
|
||||||
|
import { LicenseStatusBanners } from '@/components/LicenseStatusBanners'
|
||||||
import { licenseApi } from '@/api/license'
|
import { licenseApi } from '@/api/license'
|
||||||
import { useLicense } from '@/hooks/useLicense'
|
import { useLicense } from '@/hooks/useLicense'
|
||||||
import { getUser } from '@/lib/auth-storage'
|
import { getUser } from '@/lib/auth-storage'
|
||||||
@@ -17,6 +18,12 @@ export const LicenseSection = memo(function LicenseSection() {
|
|||||||
const isAdmin = getUser()?.role === 'admin'
|
const isAdmin = getUser()?.role === 'admin'
|
||||||
const [key, setKey] = useState('')
|
const [key, setKey] = useState('')
|
||||||
const [activating, setActivating] = useState(false)
|
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 () => {
|
const activate = async () => {
|
||||||
if (!key.trim()) return
|
if (!key.trim()) return
|
||||||
@@ -39,8 +46,7 @@ export const LicenseSection = memo(function LicenseSection() {
|
|||||||
: t('permanent')
|
: t('permanent')
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const showLegacy = pro && (info?.source === 'hmac' || Boolean(info?.deprecationNotice))
|
const instanceLimit = info?.maxDevices || info?.instances
|
||||||
const showGrace = Boolean(info?.inGrace)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -54,26 +60,16 @@ export const LicenseSection = memo(function LicenseSection() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{showLegacy && (
|
<LicenseStatusBanners info={info} pro={pro} onAction={isAdmin ? focusKey : undefined} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{pro ? (
|
{pro ? (
|
||||||
<div className="grid gap-1 text-muted-foreground sm:grid-cols-2">
|
<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('edition')}: {info?.edition === 'team' ? t('teamTitle') : t('personalTitle')}</p>
|
||||||
<p>{t('expiry')}: {expiry}</p>
|
<p>{t('expiry')}: {expiry}</p>
|
||||||
{info?.customer && <p>{t('customer')}: {info.customer}</p>}
|
{info?.customer && <p>{t('customer')}: {info.customer}</p>}
|
||||||
{Boolean(info?.maxDevices || info?.instances) && (
|
{Boolean(instanceLimit) && (
|
||||||
<p>{t('instances')}: {info?.maxDevices || info?.instances}</p>
|
<p>{t('instances')}: {instanceLimit}</p>
|
||||||
)}
|
)}
|
||||||
{Boolean(info?.seats) && <p>{t('seats')}: {info?.seats}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground">{t('freeDesc')}</p>
|
<p className="text-muted-foreground">{t('freeDesc')}</p>
|
||||||
@@ -81,7 +77,13 @@ export const LicenseSection = memo(function LicenseSection() {
|
|||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="flex gap-2">
|
<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()}>
|
<Button onClick={() => void activate()} disabled={activating || !key.trim()}>
|
||||||
{activating ? t('activating') : t('activate')}
|
{activating ? t('activating') : t('activate')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in new issue
Block a user