feat(license): add BSL license enforcement with device limit indicator
- Add LICENSE_DEVICES env var (default 250, matches BSL 1.1 free tier) - Add /api/settings/license endpoint returning device count vs limit - Header shows flashing red "502/500 licensed" badge when over limit - About page shows license tier, device count, and over-limit warning - Nothing is crippled — all features work regardless of device count - Bump version to 9.7.1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "9.7.0",
|
||||
"version": "9.7.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { useAuth, isSuperAdmin } from '@/lib/auth'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { tenantsApi, metricsApi } from '@/lib/api'
|
||||
import { getLicenseStatus } from '@/lib/settingsApi'
|
||||
import { useEventStreamContext } from '@/contexts/EventStreamContext'
|
||||
import type { ConnectionState } from '@/hooks/useEventStream'
|
||||
import { NotificationBell } from '@/components/alerts/NotificationBell'
|
||||
@@ -88,6 +89,14 @@ export function ContextStrip() {
|
||||
const offlineCount = fleet?.filter((d) => d.status === 'offline').length ?? 0
|
||||
const degradedCount = fleet?.filter((d) => d.status === 'degraded').length ?? 0
|
||||
|
||||
// License status (super_admin only)
|
||||
const { data: license } = useQuery({
|
||||
queryKey: ['license-status'],
|
||||
queryFn: getLicenseStatus,
|
||||
enabled: superAdmin,
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
void navigate({ to: '/login' })
|
||||
@@ -188,6 +197,11 @@ export function ContextStrip() {
|
||||
) : (
|
||||
<span className="text-xs text-text-muted">Status loading...</span>
|
||||
)}
|
||||
{license?.over_limit && (
|
||||
<span className="text-xs font-mono text-error animate-pulse">
|
||||
{license.actual_devices}/{license.licensed_devices} licensed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
|
||||
@@ -45,3 +45,15 @@ export async function testSMTPSettings(data: {
|
||||
const res = await api.post('/api/settings/smtp/test', data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export interface LicenseStatus {
|
||||
licensed_devices: number
|
||||
actual_devices: number
|
||||
over_limit: boolean
|
||||
tier: 'free' | 'commercial'
|
||||
}
|
||||
|
||||
export async function getLicenseStatus(): Promise<LicenseStatus> {
|
||||
const res = await api.get('/api/settings/license')
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { RugLogo } from '@/components/brand/RugLogo'
|
||||
import { APP_VERSION } from '@/lib/version'
|
||||
import { AnsiNfoModal } from '@/components/about/AnsiNfoModal'
|
||||
import { getLicenseStatus } from '@/lib/settingsApi'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/about')({
|
||||
component: AboutPage,
|
||||
@@ -483,6 +485,7 @@ function AboutPage() {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showQR, setShowQR] = useState(false)
|
||||
const [showNfo, setShowNfo] = useState(false)
|
||||
const { data: license } = useQuery({ queryKey: ['license-status'], queryFn: getLicenseStatus })
|
||||
|
||||
const copyAddress = async () => {
|
||||
try {
|
||||
@@ -516,6 +519,28 @@ function AboutPage() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* License */}
|
||||
{license && (
|
||||
<div className="rounded-lg border border-border bg-surface p-5 space-y-2">
|
||||
<h2 className="text-sm font-semibold text-text-primary uppercase tracking-wider">
|
||||
License
|
||||
</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-secondary">
|
||||
{license.tier === 'commercial' ? 'Commercial License' : 'BSL 1.1 — Free Tier'}
|
||||
</span>
|
||||
<span className={`text-sm font-mono ${license.over_limit ? 'text-error' : 'text-text-secondary'}`}>
|
||||
{license.actual_devices} / {license.licensed_devices === 0 ? 'Unlimited' : license.licensed_devices} devices
|
||||
</span>
|
||||
</div>
|
||||
{license.over_limit && (
|
||||
<p className="text-xs text-error">
|
||||
Device count exceeds licensed limit. A commercial license is required.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Features summary */}
|
||||
<div className="rounded-lg border border-border bg-surface p-5 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-text-primary uppercase tracking-wider">
|
||||
|
||||
Reference in New Issue
Block a user