feat(license): show Instance ID with copy on settings + ProGate
Always return machine fingerprint instanceId from license status for offline LICOFF- binding. Shared InstanceIdRow (zh/en) above activate.
This commit is contained in:
1 parent
db92426973
commit
f3d27ceb04
6 files changed
+86
-22
No files matched your search
@@ -306,14 +306,17 @@ func deriveInvalidReason(lic *models.License) string {
|
|||||||
|
|
||||||
// LicenseStatusPayload 给前端:始终带回库存证的 sanitized + 是否有效 Pro。
|
// LicenseStatusPayload 给前端:始终带回库存证的 sanitized + 是否有效 Pro。
|
||||||
// 无效时带 invalidReason(expired|over_instance|revoked),且不清 LicenseKey。
|
// 无效时带 invalidReason(expired|over_instance|revoked),且不清 LicenseKey。
|
||||||
|
// instanceId 始终返回本机指纹(DATA_DIR/instance.id),供离线 LICOFF- 绑码展示。
|
||||||
func LicenseStatusPayload() (map[string]interface{}, bool) {
|
func LicenseStatusPayload() (map[string]interface{}, bool) {
|
||||||
RefreshOpsxLicenseIfDue()
|
RefreshOpsxLicenseIfDue()
|
||||||
current := CurrentLicense()
|
current := CurrentLicense()
|
||||||
stored := StoredLicense()
|
stored := StoredLicense()
|
||||||
|
instanceID := GetOrCreateInstanceID()
|
||||||
if stored == nil {
|
if stored == nil {
|
||||||
return map[string]interface{}{"plan": "free"}, false
|
return map[string]interface{}{"plan": "free", "instanceId": instanceID}, false
|
||||||
}
|
}
|
||||||
out := stored.Sanitized()
|
out := stored.Sanitized()
|
||||||
|
out["instanceId"] = instanceID
|
||||||
if current != nil {
|
if current != nil {
|
||||||
delete(out, "invalidReason")
|
delete(out, "invalidReason")
|
||||||
return out, true
|
return out, true
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { memo, useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Copy } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
instanceId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 实例 ID 展示行:设置授权区与 ProGate 共用;全文 + 一键复制,无 PII。 */
|
||||||
|
export const InstanceIdRow = memo(function InstanceIdRow({ instanceId }: Props) {
|
||||||
|
const { t } = useTranslation('license')
|
||||||
|
const id = instanceId?.trim() || ''
|
||||||
|
|
||||||
|
const onCopy = useCallback(() => {
|
||||||
|
if (!id) return
|
||||||
|
void navigator.clipboard?.writeText(id).then(
|
||||||
|
() => toast.success(t('instanceIdCopied')),
|
||||||
|
() => toast.error(t('instanceIdCopyFailed')),
|
||||||
|
)
|
||||||
|
}, [id, t])
|
||||||
|
|
||||||
|
if (!id) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex flex-wrap items-start gap-2">
|
||||||
|
<span className="shrink-0 pt-0.5 text-sm text-muted-foreground">{t('instanceId')}</span>
|
||||||
|
<code className="min-w-0 flex-1 break-all font-mono text-sm">{id}</code>
|
||||||
|
<Button type="button" size="sm" variant="outline" className="shrink-0 gap-1" onClick={onCopy}>
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
{t('copy')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('instanceIdHint')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -6,6 +6,7 @@ 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 { 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'
|
||||||
@@ -41,6 +42,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} />
|
<LicenseStatusBanners info={info} pro={pro} onAction={focusKey} />
|
||||||
|
<InstanceIdRow instanceId={info?.instanceId} />
|
||||||
<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">
|
||||||
@@ -71,16 +73,19 @@ export const ProGate = memo(function ProGate({ featureKey }: { featureKey: strin
|
|||||||
<p className="mt-1 text-xs text-muted-foreground">{t('pricing.teamDesc')}</p>
|
<p className="mt-1 text-xs text-muted-foreground">{t('pricing.teamDesc')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="space-y-2">
|
||||||
<Input
|
<p className="text-xs text-muted-foreground">{t('activateHint')}</p>
|
||||||
ref={inputRef}
|
<div className="flex gap-2">
|
||||||
value={key}
|
<Input
|
||||||
onChange={e => setKey(e.target.value)}
|
ref={inputRef}
|
||||||
placeholder={t('gate.keyPlaceholder')}
|
value={key}
|
||||||
className="font-mono text-sm"
|
onChange={e => setKey(e.target.value)}
|
||||||
/>
|
placeholder={t('gate.keyPlaceholder')}
|
||||||
<Button onClick={() => void activate()} disabled={activating || !key.trim()}>{t('gate.activate')}</Button>
|
className="font-mono text-sm"
|
||||||
<Button variant="outline" onClick={() => navigate('/settings')}>{t('gate.toSettings')}</Button>
|
/>
|
||||||
|
<Button onClick={() => void activate()} disabled={activating || !key.trim()}>{t('gate.activate')}</Button>
|
||||||
|
<Button variant="outline" onClick={() => navigate('/settings')}>{t('gate.toSettings')}</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">{t('gate.freeHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('gate.freeHint')}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ export default {
|
|||||||
expiry: 'Valid until',
|
expiry: 'Valid until',
|
||||||
customer: 'Licensed to',
|
customer: 'Licensed to',
|
||||||
instances: 'Instance limit',
|
instances: 'Instance limit',
|
||||||
|
instanceId: 'Instance ID',
|
||||||
|
instanceIdHint: 'Provide this ID for offline licensing. Machine fingerprint only — no personal data.',
|
||||||
|
instanceIdCopied: 'Instance ID copied',
|
||||||
|
instanceIdCopyFailed: 'Copy failed — select manually',
|
||||||
|
copy: 'Copy',
|
||||||
|
activateHint: 'Online keys start with LIC-; offline LICOFF- keys must bind the Instance ID above',
|
||||||
permanent: 'perpetual',
|
permanent: 'perpetual',
|
||||||
personalTitle: 'Personal',
|
personalTitle: 'Personal',
|
||||||
teamTitle: 'Team',
|
teamTitle: 'Team',
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ export default {
|
|||||||
expiry: '有效期',
|
expiry: '有效期',
|
||||||
customer: '授权对象',
|
customer: '授权对象',
|
||||||
instances: '实例上限',
|
instances: '实例上限',
|
||||||
|
instanceId: '实例 ID',
|
||||||
|
instanceIdHint: '离线授权需提供此 ID;仅本机指纹,不含个人信息',
|
||||||
|
instanceIdCopied: '实例 ID 已复制',
|
||||||
|
instanceIdCopyFailed: '复制失败,请手动选择',
|
||||||
|
copy: '复制',
|
||||||
|
activateHint: '在线码以 LIC- 开头;离线码 LICOFF-,须与上方实例 ID 绑定',
|
||||||
permanent: '永久',
|
permanent: '永久',
|
||||||
personalTitle: '个人版',
|
personalTitle: '个人版',
|
||||||
teamTitle: '团队版',
|
teamTitle: '团队版',
|
||||||
|
|||||||
@@ -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 { 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'
|
||||||
@@ -61,6 +62,7 @@ export const LicenseSection = memo(function LicenseSection() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<LicenseStatusBanners info={info} pro={pro} onAction={isAdmin ? focusKey : undefined} />
|
<LicenseStatusBanners info={info} pro={pro} onAction={isAdmin ? focusKey : undefined} />
|
||||||
|
<InstanceIdRow instanceId={info?.instanceId} />
|
||||||
<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">
|
||||||
@@ -76,17 +78,20 @@ export const LicenseSection = memo(function LicenseSection() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="flex gap-2">
|
<div className="space-y-2">
|
||||||
<Input
|
<p className="text-xs text-muted-foreground">{t('activateHint')}</p>
|
||||||
ref={inputRef}
|
<div className="flex gap-2">
|
||||||
value={key}
|
<Input
|
||||||
onChange={e => setKey(e.target.value)}
|
ref={inputRef}
|
||||||
placeholder={t('keyPlaceholder')}
|
value={key}
|
||||||
className="font-mono text-sm"
|
onChange={e => setKey(e.target.value)}
|
||||||
/>
|
placeholder={t('keyPlaceholder')}
|
||||||
<Button onClick={() => void activate()} disabled={activating || !key.trim()}>
|
className="font-mono text-sm"
|
||||||
{activating ? t('activating') : t('activate')}
|
/>
|
||||||
</Button>
|
<Button onClick={() => void activate()} disabled={activating || !key.trim()}>
|
||||||
|
{activating ? t('activating') : t('activate')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
Reference in new issue
Block a user