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:
Jason Staack
2026-03-19 22:28:56 -05:00
parent 0142107e68
commit fdc8d9cb68
9 changed files with 81 additions and 5 deletions

View File

@@ -1 +1 @@
9.7.0 9.7.1

View File

@@ -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")

View File

@@ -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",
}

View File

@@ -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 = [

View File

@@ -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",

View File

@@ -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 */}

View File

@@ -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
}

View File

@@ -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">

View File

@@ -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