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:
@@ -135,9 +135,13 @@ class Settings(BaseSettings):
|
|||||||
# Retention cleanup — delete config snapshots older than N days
|
# Retention cleanup — delete config snapshots older than N days
|
||||||
CONFIG_RETENTION_DAYS: int = 90
|
CONFIG_RETENTION_DAYS: int = 90
|
||||||
|
|
||||||
|
# Licensing — BSL 1.1 free tier allows up to 250 devices.
|
||||||
|
# Commercial license required above this limit. Set to 0 for unlimited.
|
||||||
|
LICENSE_DEVICES: int = 250
|
||||||
|
|
||||||
# App settings
|
# App settings
|
||||||
APP_NAME: str = "TOD - The Other Dude"
|
APP_NAME: str = "TOD - The Other Dude"
|
||||||
APP_VERSION: str = "9.7.0"
|
APP_VERSION: str = "9.7.1"
|
||||||
DEBUG: bool = False
|
DEBUG: bool = False
|
||||||
|
|
||||||
@field_validator("CREDENTIAL_ENCRYPTION_KEY")
|
@field_validator("CREDENTIAL_ENCRYPTION_KEY")
|
||||||
|
|||||||
@@ -176,3 +176,24 @@ async def clear_winbox_sessions(user=Depends(require_role("super_admin"))):
|
|||||||
return {"status": "ok", "deleted": deleted}
|
return {"status": "ok", "deleted": deleted}
|
||||||
finally:
|
finally:
|
||||||
await rd.aclose()
|
await rd.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# License status (any authenticated user)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/license", summary="Get license status")
|
||||||
|
async def get_license_status():
|
||||||
|
"""Return current license tier, device limit, and actual device count."""
|
||||||
|
async with AdminAsyncSessionLocal() as session:
|
||||||
|
result = await session.execute(text("SELECT count(*)::int FROM devices"))
|
||||||
|
device_count = result.scalar() or 0
|
||||||
|
|
||||||
|
limit = settings.LICENSE_DEVICES
|
||||||
|
return {
|
||||||
|
"licensed_devices": limit,
|
||||||
|
"actual_devices": device_count,
|
||||||
|
"over_limit": limit > 0 and device_count > limit,
|
||||||
|
"tier": "commercial" if limit > 250 else "free",
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "the-other-dude-backend"
|
name = "the-other-dude-backend"
|
||||||
version = "9.7.0"
|
version = "9.7.1"
|
||||||
description = "MikroTik Fleet Management Portal - Backend API"
|
description = "MikroTik Fleet Management Portal - Backend API"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "9.7.0",
|
"version": "9.7.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { useAuth, isSuperAdmin } from '@/lib/auth'
|
import { useAuth, isSuperAdmin } from '@/lib/auth'
|
||||||
import { useUIStore } from '@/lib/store'
|
import { useUIStore } from '@/lib/store'
|
||||||
import { tenantsApi, metricsApi } from '@/lib/api'
|
import { tenantsApi, metricsApi } from '@/lib/api'
|
||||||
|
import { getLicenseStatus } from '@/lib/settingsApi'
|
||||||
import { useEventStreamContext } from '@/contexts/EventStreamContext'
|
import { useEventStreamContext } from '@/contexts/EventStreamContext'
|
||||||
import type { ConnectionState } from '@/hooks/useEventStream'
|
import type { ConnectionState } from '@/hooks/useEventStream'
|
||||||
import { NotificationBell } from '@/components/alerts/NotificationBell'
|
import { NotificationBell } from '@/components/alerts/NotificationBell'
|
||||||
@@ -88,6 +89,14 @@ export function ContextStrip() {
|
|||||||
const offlineCount = fleet?.filter((d) => d.status === 'offline').length ?? 0
|
const offlineCount = fleet?.filter((d) => d.status === 'offline').length ?? 0
|
||||||
const degradedCount = fleet?.filter((d) => d.status === 'degraded').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 () => {
|
const handleLogout = async () => {
|
||||||
await logout()
|
await logout()
|
||||||
void navigate({ to: '/login' })
|
void navigate({ to: '/login' })
|
||||||
@@ -188,6 +197,11 @@ export function ContextStrip() {
|
|||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-text-muted">Status loading...</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Right: Actions */}
|
{/* Right: Actions */}
|
||||||
|
|||||||
@@ -45,3 +45,15 @@ export async function testSMTPSettings(data: {
|
|||||||
const res = await api.post('/api/settings/smtp/test', data)
|
const res = await api.post('/api/settings/smtp/test', data)
|
||||||
return res.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 { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { RugLogo } from '@/components/brand/RugLogo'
|
import { RugLogo } from '@/components/brand/RugLogo'
|
||||||
import { APP_VERSION } from '@/lib/version'
|
import { APP_VERSION } from '@/lib/version'
|
||||||
import { AnsiNfoModal } from '@/components/about/AnsiNfoModal'
|
import { AnsiNfoModal } from '@/components/about/AnsiNfoModal'
|
||||||
|
import { getLicenseStatus } from '@/lib/settingsApi'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/about')({
|
export const Route = createFileRoute('/_authenticated/about')({
|
||||||
component: AboutPage,
|
component: AboutPage,
|
||||||
@@ -483,6 +485,7 @@ function AboutPage() {
|
|||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [showQR, setShowQR] = useState(false)
|
const [showQR, setShowQR] = useState(false)
|
||||||
const [showNfo, setShowNfo] = useState(false)
|
const [showNfo, setShowNfo] = useState(false)
|
||||||
|
const { data: license } = useQuery({ queryKey: ['license-status'], queryFn: getLicenseStatus })
|
||||||
|
|
||||||
const copyAddress = async () => {
|
const copyAddress = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -516,6 +519,28 @@ function AboutPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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 */}
|
{/* Features summary */}
|
||||||
<div className="rounded-lg border border-border bg-surface p-5 space-y-3">
|
<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">
|
<h2 className="text-sm font-semibold text-text-primary uppercase tracking-wider">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name: tod
|
|||||||
description: The Other Dude — MikroTik fleet management platform
|
description: The Other Dude — MikroTik fleet management platform
|
||||||
type: application
|
type: application
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
appVersion: "9.7.0"
|
appVersion: "9.7.1"
|
||||||
kubeVersion: ">=1.28.0-0"
|
kubeVersion: ">=1.28.0-0"
|
||||||
keywords:
|
keywords:
|
||||||
- mikrotik
|
- mikrotik
|
||||||
|
|||||||
Reference in New Issue
Block a user