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
@@ -68,3 +68,13 @@ func (ctrl *SystemController) ActivateLicense(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
utils.Success(c, lic.Sanitized())
|
utils.Success(c, lic.Sanitized())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeactivateLicense 退出授权 / 释放本实例(admin);成功才清本地,失败保留现状。
|
||||||
|
func (ctrl *SystemController) DeactivateLicense(c *gin.Context) {
|
||||||
|
res, err := services.DeactivateLicense()
|
||||||
|
if err != nil {
|
||||||
|
utils.Error(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, res)
|
||||||
|
}
|
||||||
@@ -169,6 +169,7 @@ func SetupRouter() *gin.Engine {
|
|||||||
system.GET("/self-check", systemCtrl.Health)
|
system.GET("/self-check", systemCtrl.Health)
|
||||||
system.GET("/license", systemCtrl.LicenseStatus)
|
system.GET("/license", systemCtrl.LicenseStatus)
|
||||||
system.POST("/license/activate", middleware.AdminAuth(), systemCtrl.ActivateLicense)
|
system.POST("/license/activate", middleware.AdminAuth(), systemCtrl.ActivateLicense)
|
||||||
|
system.POST("/license/deactivate", middleware.AdminAuth(), systemCtrl.DeactivateLicense)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 审计日志:仅管理员可查(含他人操作记录)
|
// 审计日志:仅管理员可查(含他人操作记录)
|
||||||
|
|||||||
@@ -331,3 +331,25 @@ func remoteHeartbeatLicence(licenseKey, deviceID string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func remoteDeactivateLicence(licenseKey, deviceID, activationToken string) error {
|
||||||
|
body := map[string]string{
|
||||||
|
"license_key": licenseKey,
|
||||||
|
"device_id": deviceID,
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(activationToken) != "" {
|
||||||
|
body["activation_token"] = strings.TrimSpace(activationToken)
|
||||||
|
}
|
||||||
|
env, err := postLicenceJSON("/v1/license/deactivate", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if envelopeCode(env) != 0 {
|
||||||
|
msg := envelopeMessage(env)
|
||||||
|
if msg == "" {
|
||||||
|
msg = "解绑失败"
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s", msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -188,6 +188,75 @@ func ActivateLicense(key string) (*models.License, error) {
|
|||||||
return persistLicense(lic)
|
return persistLicense(lic)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeactivateResult 退出授权结果。OfflineLocalOnly 表示仅解本机(LICOFF-),后台名额可能仍占。
|
||||||
|
type DeactivateResult struct {
|
||||||
|
OfflineLocalOnly bool `json:"offlineLocalOnly"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivateLicense 用户主动退出授权 / 释放本实例。
|
||||||
|
// - 在线 LIC-:先 POST /v1/license/deactivate(带 activation_token),成功才清本地;失败保留现状。
|
||||||
|
// - 离线 LICOFF-:只解本机绑定,并提示后台名额可能仍占。
|
||||||
|
// - 旧 HMAC:仅清本地(无远端座位)。
|
||||||
|
// 禁止在校验失败路径静默清码;本函数仅用户确认后调用。
|
||||||
|
func DeactivateLicense() (*DeactivateResult, error) {
|
||||||
|
db := repository.GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return nil, fmt.Errorf("数据库未就绪")
|
||||||
|
}
|
||||||
|
db.RLock()
|
||||||
|
lic := db.License
|
||||||
|
db.RUnlock()
|
||||||
|
if lic == nil {
|
||||||
|
return nil, fmt.Errorf("当前未激活授权")
|
||||||
|
}
|
||||||
|
// 允许对仍存证的 Pro(含宽限)或刚降级但仍占远端席位的 opsx 证做主动释放
|
||||||
|
key := strings.TrimSpace(lic.LicenseKey)
|
||||||
|
if lic.Plan != "pro" && key == "" {
|
||||||
|
return nil, fmt.Errorf("当前未激活授权")
|
||||||
|
}
|
||||||
|
|
||||||
|
token := lic.ActivationToken
|
||||||
|
instanceID := lic.InstanceID
|
||||||
|
if instanceID == "" {
|
||||||
|
instanceID = GetOrCreateInstanceID()
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &DeactivateResult{}
|
||||||
|
|
||||||
|
if key != "" && isOfflineLicenseKey(key) {
|
||||||
|
clearStoredLicense()
|
||||||
|
result.OfflineLocalOnly = true
|
||||||
|
result.Message = "已解除本机离线授权。后台名额可能仍占用,如需释放请联系运营处理。"
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if key != "" && isOpsxLicenseKey(key) {
|
||||||
|
if err := remoteDeactivateLicence(key, instanceID, token); err != nil {
|
||||||
|
return nil, fmt.Errorf("远端解绑失败,本机授权未清除: %w", err)
|
||||||
|
}
|
||||||
|
clearStoredLicense()
|
||||||
|
result.Message = "已退出授权并释放本实例"
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HMAC 或无 key 的本地 Pro
|
||||||
|
clearStoredLicense()
|
||||||
|
result.Message = "已清除本机授权"
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearStoredLicense() {
|
||||||
|
db := repository.GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Lock()
|
||||||
|
db.License = nil
|
||||||
|
db.RequestSave()
|
||||||
|
db.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func persistLicense(lic *models.License) (*models.License, error) {
|
func persistLicense(lic *models.License) (*models.License, error) {
|
||||||
db := repository.GetDB()
|
db := repository.GetDB()
|
||||||
db.Lock()
|
db.Lock()
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lokistack/config"
|
||||||
|
"lokistack/models"
|
||||||
|
"lokistack/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func withTempDB(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("DATA_DIR", t.TempDir())
|
||||||
|
t.Setenv("JWT_SECRET", "test-jwt-secret-with-at-least-32-characters")
|
||||||
|
t.Setenv("ENCRYPTION_KEY", "test-encryption-key-with-at-least-32-characters")
|
||||||
|
if err := repository.InitDB(); err != nil {
|
||||||
|
t.Fatalf("InitDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = repository.Shutdown() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeactivateLicenseHMACClearsLocal(t *testing.T) {
|
||||||
|
withTempDB(t)
|
||||||
|
key, err := GenerateLicenseKey("pro", "personal", "c", 1, 1, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ActivateLicense(key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if CurrentLicense() == nil {
|
||||||
|
t.Fatal("expected pro before deactivate")
|
||||||
|
}
|
||||||
|
res, err := DeactivateLicense()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.OfflineLocalOnly {
|
||||||
|
t.Fatal("HMAC should not be offlineLocalOnly")
|
||||||
|
}
|
||||||
|
if CurrentLicense() != nil || StoredLicense() != nil {
|
||||||
|
t.Fatal("expected local license cleared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeactivateLicenseOfflineLocalOnly(t *testing.T) {
|
||||||
|
withTempDB(t)
|
||||||
|
instanceID := GetOrCreateInstanceID()
|
||||||
|
// 无法签发真 LICOFF- 无私钥;直接写入库存模拟离线证
|
||||||
|
db := repository.GetDB()
|
||||||
|
db.Lock()
|
||||||
|
db.License = &models.License{
|
||||||
|
Plan: "pro", Source: "opsx", LicenseKey: "LICOFF-TEST-FAKE",
|
||||||
|
InstanceID: instanceID, ActivatedAt: time.Now(), IssuedAt: time.Now(),
|
||||||
|
}
|
||||||
|
db.RequestSave()
|
||||||
|
db.Unlock()
|
||||||
|
|
||||||
|
res, err := DeactivateLicense()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.OfflineLocalOnly {
|
||||||
|
t.Fatal("expected offlineLocalOnly")
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Message, "后台") && !strings.Contains(strings.ToLower(res.Message), "ops") {
|
||||||
|
// 中文提示
|
||||||
|
if res.Message == "" {
|
||||||
|
t.Fatal("expected offline note message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if StoredLicense() != nil {
|
||||||
|
t.Fatal("expected cleared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeactivateLicenseOnlineKeepsStateOnRemoteFailure(t *testing.T) {
|
||||||
|
withTempDB(t)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"code": 1, "message": "解绑校验失败"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
prev := cfg.LicenseAPIBase
|
||||||
|
cfg.LicenseAPIBase = srv.URL
|
||||||
|
defer func() { cfg.LicenseAPIBase = prev }()
|
||||||
|
|
||||||
|
db := repository.GetDB()
|
||||||
|
db.Lock()
|
||||||
|
db.License = &models.License{
|
||||||
|
Plan: "pro", Source: "opsx", LicenseKey: "LIC-KEEP-ON-FAIL",
|
||||||
|
InstanceID: GetOrCreateInstanceID(), ActivationToken: "tok",
|
||||||
|
ActivatedAt: time.Now(), IssuedAt: time.Now(), LastVerifiedAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
db.RequestSave()
|
||||||
|
db.Unlock()
|
||||||
|
|
||||||
|
_, err := DeactivateLicense()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected remote failure")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "远端解绑失败") {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
stored := StoredLicense()
|
||||||
|
if stored == nil || stored.LicenseKey != "LIC-KEEP-ON-FAIL" || stored.Plan != "pro" {
|
||||||
|
t.Fatalf("must keep local state on failure: %+v", stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeactivateLicenseOnlineSuccessClears(t *testing.T) {
|
||||||
|
withTempDB(t)
|
||||||
|
var gotBody map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/license/deactivate" {
|
||||||
|
t.Errorf("path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "message": "ok"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
prev := cfg.LicenseAPIBase
|
||||||
|
cfg.LicenseAPIBase = srv.URL
|
||||||
|
defer func() { cfg.LicenseAPIBase = prev }()
|
||||||
|
|
||||||
|
id := GetOrCreateInstanceID()
|
||||||
|
db := repository.GetDB()
|
||||||
|
db.Lock()
|
||||||
|
db.License = &models.License{
|
||||||
|
Plan: "pro", Source: "opsx", LicenseKey: "LIC-OK",
|
||||||
|
InstanceID: id, ActivationToken: "act-token",
|
||||||
|
ActivatedAt: time.Now(), IssuedAt: time.Now(), LastVerifiedAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
db.RequestSave()
|
||||||
|
db.Unlock()
|
||||||
|
|
||||||
|
res, err := DeactivateLicense()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.OfflineLocalOnly {
|
||||||
|
t.Fatal("online should not set offlineLocalOnly")
|
||||||
|
}
|
||||||
|
if gotBody["license_key"] != "LIC-OK" || gotBody["device_id"] != id || gotBody["activation_token"] != "act-token" {
|
||||||
|
t.Fatalf("bad deactivate body: %#v", gotBody)
|
||||||
|
}
|
||||||
|
if StoredLicense() != nil {
|
||||||
|
t.Fatal("expected cleared after success")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeactivateLicenseEmpty(t *testing.T) {
|
||||||
|
withTempDB(t)
|
||||||
|
if _, err := DeactivateLicense(); err == nil {
|
||||||
|
t.Fatal("expected error when no license")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,3 +72,13 @@ curl -sS 'https://licence.rlwj.cn/api/v1/products/code/lokistack'
|
|||||||
| 绑定字段 | `device_id` | `instance_id`(同语义) |
|
| 绑定字段 | `device_id` | `instance_id`(同语义) |
|
||||||
| 默认 Base | l123 主 / 国内备 | **国内主** / l123 备 |
|
| 默认 Base | l123 主 / 国内备 | **国内主** / l123 备 |
|
||||||
| 落盘 | `license.opsx` | JsonDB `license` 字段 |
|
| 落盘 | `license.opsx` | JsonDB `license` 字段 |
|
||||||
|
| 退出授权 | `clear` → remote deactivate + 删本地 | 设置→授权「退出授权」→ `POST /system/license/deactivate`;在线成功才清本地;LICOFF- 仅解本机并提示后台名额可能仍占 |
|
||||||
|
|
||||||
|
## 退出授权(本机)
|
||||||
|
|
||||||
|
- UI:设置 → 授权,Pro 态管理员可见「退出授权 / 释放本实例」,二次确认。
|
||||||
|
- 后端:`POST /api/v1/system/license/deactivate`(Admin)。
|
||||||
|
- 在线 `LIC-`:调用 `POST {LICENSE_API_BASE}/v1/license/deactivate`,body 含 `license_key`、`device_id`(=instance_id)、`activation_token`(若有);**成功才清本地**,失败保留现状并报错。
|
||||||
|
- 离线 `LICOFF-`:只清本地,响应 `offlineLocalOnly=true`,提示后台名额可能仍占。
|
||||||
|
- 旧 HMAC:只清本地。
|
||||||
|
- 禁止在周检/校验失败路径静默清码;仅用户确认后走本接口。
|
||||||
@@ -21,4 +21,6 @@ export interface LicenseInfo {
|
|||||||
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'),
|
||||||
activate: (key: string) => http.post<LicenseInfo>('/system/license/activate', { key }),
|
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…',
|
activating: 'Activating…',
|
||||||
activated: 'Pro activated — all features unlocked',
|
activated: 'Pro activated — all features unlocked',
|
||||||
activateFailed: 'Activation failed',
|
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.',
|
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: {
|
status: {
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ export default {
|
|||||||
activating: '激活中…',
|
activating: '激活中…',
|
||||||
activated: 'Pro 已激活,全部功能已解锁',
|
activated: 'Pro 已激活,全部功能已解锁',
|
||||||
activateFailed: '激活失败',
|
activateFailed: '激活失败',
|
||||||
|
deactivate: '退出授权 / 释放本实例',
|
||||||
|
deactivateConfirmTitle: '退出授权?',
|
||||||
|
deactivateConfirmDesc: '将释放本实例占用的授权名额,本机回到免费版。在线授权会先通知授权服务器;失败则保留现状,不会静默清码。',
|
||||||
|
deactivateConfirm: '确认退出',
|
||||||
|
deactivating: '退出中…',
|
||||||
|
deactivated: '已退出授权,本机已回到免费版',
|
||||||
|
deactivateFailed: '退出授权失败',
|
||||||
|
deactivateOfflineNote: '已解除本机离线授权。后台名额可能仍占用,如需释放请联系运营处理。',
|
||||||
legacyBanner: '旧版 HMAC 许可码迁移窗口:请尽快换发 LIC- / LICOFF- 授权码,旧码在窗口内仍有效,不会静默失效。',
|
legacyBanner: '旧版 HMAC 许可码迁移窗口:请尽快换发 LIC- / LICOFF- 授权码,旧码在窗口内仍有效,不会静默失效。',
|
||||||
graceBanner: '离线宽限中:剩余 {{days}} 天,恢复网络后续验。',
|
graceBanner: '离线宽限中:剩余 {{days}} 天,恢复网络后续验。',
|
||||||
status: {
|
status: {
|
||||||
|
|||||||
@@ -6,19 +6,22 @@ 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 { ConfirmDialog } from '@/components/ConfirmDialog'
|
||||||
import { InstanceIdRow } from '@/components/InstanceIdRow'
|
import { InstanceIdRow } from '@/components/InstanceIdRow'
|
||||||
import { LicenseStatusBanners } from '@/components/LicenseStatusBanners'
|
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'
|
||||||
|
|
||||||
/** 授权区块:当前版本状态 + 许可码激活(仅管理员可操作) */
|
/** 授权区块:当前版本状态 + 许可码激活 / 退出授权(仅管理员可操作) */
|
||||||
export const LicenseSection = memo(function LicenseSection() {
|
export const LicenseSection = memo(function LicenseSection() {
|
||||||
const { t } = useTranslation('license')
|
const { t } = useTranslation('license')
|
||||||
const { pro, info, reload } = useLicense()
|
const { pro, info, reload } = useLicense()
|
||||||
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 [confirmOpen, setConfirmOpen] = useState(false)
|
||||||
|
const [deactivating, setDeactivating] = useState(false)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const focusKey = () => {
|
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
|
const expiry = info?.expiresAt
|
||||||
? info.expiresAt > 0
|
? info.expiresAt > 0
|
||||||
? new Date(info.expiresAt * 1000).toLocaleDateString()
|
? new Date(info.expiresAt * 1000).toLocaleDateString()
|
||||||
@@ -80,21 +101,40 @@ export const LicenseSection = memo(function LicenseSection() {
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-muted-foreground">{t('activateHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('activateHint')}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={key}
|
value={key}
|
||||||
onChange={e => setKey(e.target.value)}
|
onChange={e => setKey(e.target.value)}
|
||||||
placeholder={t('keyPlaceholder')}
|
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()}>
|
<Button onClick={() => void activate()} disabled={activating || !key.trim()}>
|
||||||
{activating ? t('activating') : t('activate')}
|
{activating ? t('activating') : t('activate')}
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmOpen}
|
||||||
|
onOpenChange={setConfirmOpen}
|
||||||
|
title={t('deactivateConfirmTitle')}
|
||||||
|
description={t('deactivateConfirmDesc')}
|
||||||
|
confirmText={t('deactivateConfirm')}
|
||||||
|
loading={deactivating}
|
||||||
|
onConfirm={() => void deactivate()}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user