From 6fe9f1820c2757bf0ab51f31cff9fc8444452e52 Mon Sep 17 00:00:00 2001 From: kikimor Date: Fri, 18 Sep 2026 18:58:10 +0300 Subject: [PATCH] Add licence UI and vendor signing tool Admin screen shows the machine fingerprint to send for a licence key, installs a pasted key, and lists connected stations. A persistent banner warns all users during the grace period. The browser sends the station heartbeat rather than the bridge, because the bridge deliberately has no dependency on Django; it already talks to both. Two tabs on one station share a slot, and a station reconnecting at the limit reclaims its own slot so a brief drop cannot lock it out behind its own expiring registration. license_tool.py is the only thing that can sign a licence - the application can only verify. The private key it generates is gitignored and must never ship: if it leaks every licence becomes forgeable, and it cannot be rotated without reissuing to every customer, since the public key is compiled into shipped binaries. Co-Authored-By: Claude Opus 5 --- .../src/components/License/LicenseManager.css | 266 ++++++++++++++++++ .../src/components/License/LicenseManager.jsx | 167 +++++++++++ frontend/src/contexts/LicenseContext.jsx | 109 +++++++ tools/license_tool.py | 141 ++++++++++ 4 files changed, 683 insertions(+) create mode 100644 frontend/src/components/License/LicenseManager.css create mode 100644 frontend/src/components/License/LicenseManager.jsx create mode 100644 frontend/src/contexts/LicenseContext.jsx create mode 100644 tools/license_tool.py diff --git a/frontend/src/components/License/LicenseManager.css b/frontend/src/components/License/LicenseManager.css new file mode 100644 index 0000000..4ed2008 --- /dev/null +++ b/frontend/src/components/License/LicenseManager.css @@ -0,0 +1,266 @@ +.license-manager { + min-height: 100vh; + background-color: #f5f5f7; +} + +.lm-body { + padding: 20px; + max-width: 800px; + margin: 0 auto; +} + +.lm-loading, +.lm-empty { + padding: 24px; + text-align: center; + color: #999; + font-style: italic; +} + +.lm-toolbar { + margin-bottom: 16px; +} + +.lm-back { + padding: 6px 12px; + background: white; + border: 1px solid #ddd; + border-radius: 6px; + color: #666; + cursor: pointer; + font-size: 13px; +} + +.lm-back:hover { + border-color: #667eea; + color: #667eea; +} + +.lm-ok, +.lm-error { + padding: 8px 12px; + border-radius: 6px; + font-size: 13px; + margin-bottom: 12px; +} + +.lm-ok { + background-color: #e8f5e9; + border: 1px solid #c8e6c9; + color: #2e7d32; +} + +.lm-error { + background-color: #fee; + border: 1px solid #fcc; + color: #c33; +} + +.lm-panel { + background: white; + border-radius: 8px; + padding: 18px; + margin-bottom: 16px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.lm-panel h3 { + margin: 0 0 12px 0; + font-size: 12px; + font-weight: 600; + color: #888; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.lm-badge { + display: inline-block; + padding: 3px 10px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 10px; +} + +.lm-badge--ok { + background-color: #e8f5e9; + color: #2e7d32; +} + +.lm-badge--warn { + background-color: #fff3cd; + color: #856404; +} + +.lm-facts { + display: grid; + grid-template-columns: 140px 1fr; + gap: 6px 12px; + margin: 0; +} + +.lm-facts dt { + font-size: 11px; + font-weight: 600; + color: #888; + text-transform: uppercase; + letter-spacing: 0.5px; + align-self: center; +} + +.lm-facts dd { + margin: 0; + font-size: 14px; + color: #333; +} + +.lm-reason { + margin: 0 0 8px 0; + font-size: 14px; + color: #333; +} + +.lm-grace { + margin: 0; + font-size: 13px; + color: #666; + line-height: 1.5; +} + +.lm-hint { + margin: 0 0 12px 0; + font-size: 13px; + color: #666; + line-height: 1.5; +} + +.lm-fingerprint { + display: flex; + align-items: center; + gap: 8px; +} + +.lm-fingerprint code { + flex: 1; + padding: 10px 12px; + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 6px; + font-family: 'Courier New', monospace; + font-size: 18px; + letter-spacing: 1px; + color: #333; + word-break: break-all; +} + +.lm-components { + font-size: 11px !important; + letter-spacing: 0 !important; +} + +.lm-fingerprint button { + padding: 8px 14px; + background: none; + border: 1px solid #ddd; + border-radius: 6px; + color: #666; + font-size: 13px; + cursor: pointer; + flex-shrink: 0; +} + +.lm-fingerprint button:hover { + border-color: #667eea; + color: #667eea; +} + +.lm-details { + margin-top: 10px; +} + +.lm-details summary { + font-size: 12px; + color: #888; + cursor: pointer; + margin-bottom: 8px; +} + +.lm-blob { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 6px; + font-family: 'Courier New', monospace; + font-size: 12px; + box-sizing: border-box; + resize: vertical; +} + +.lm-install { + margin-top: 10px; + padding: 9px 18px; + background: linear-gradient(135deg, #667eea, #764ba2); + border: none; + border-radius: 6px; + color: white; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.lm-install:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 3px 8px rgba(102, 126, 234, 0.4); +} + +.lm-install:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.lm-table { + width: 100%; + border-collapse: collapse; +} + +.lm-table th { + padding: 0.5rem 0.75rem; + text-align: left; + font-weight: 600; + color: #495057; + font-size: 0.75rem; + border-bottom: 2px solid #dee2e6; + text-transform: uppercase; + letter-spacing: 0.3px; + background-color: #f8f9fa; +} + +.lm-table td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #dee2e6; + font-size: 0.85rem; + color: #333; + font-family: 'Courier New', monospace; +} + +/* Persistent, non-dismissible banner shown app-wide during grace */ +.license-banner { + padding: 8px 16px; + background-color: #fff3cd; + border-bottom: 1px solid #ffe69c; + color: #856404; + font-size: 13px; + text-align: center; +} + +.license-banner strong { + font-weight: 600; +} + +.license-banner--blocked { + background-color: #fee; + border-bottom-color: #fcc; + color: #c33; +} diff --git a/frontend/src/components/License/LicenseManager.jsx b/frontend/src/components/License/LicenseManager.jsx new file mode 100644 index 0000000..0457ea0 --- /dev/null +++ b/frontend/src/components/License/LicenseManager.jsx @@ -0,0 +1,167 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Header from '../Header'; +import { useLicense } from '../../contexts/LicenseContext'; +import api from '../../services/api'; +import './LicenseManager.css'; + +export default function LicenseManager() { + const navigate = useNavigate(); + const { status, refreshStatus } = useLicense(); + + const [blob, setBlob] = useState(''); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + const [copied, setCopied] = useState(''); + + const copy = (text, label) => { + navigator.clipboard?.writeText(text).then( + () => { setCopied(label); setTimeout(() => setCopied(''), 2000); }, + () => setError('Could not copy - select and copy manually') + ); + }; + + const handleInstall = async () => { + if (!blob.trim()) return; + setSaving(true); setError(''); setMessage(''); + try { + await api.post('/api/license/', { key_blob: blob.trim() }); + await refreshStatus(); + setBlob(''); + setMessage('Licence installed.'); + } catch (err) { + setError(err.response?.data?.detail || 'Could not install licence'); + } finally { + setSaving(false); + } + }; + + if (!status) { + return ( +
+
+
Loading licence...
+
+ ); + } + + const componentsJson = JSON.stringify(status.components || {}); + + return ( +
+
+
+
+ +
+ + {message &&
{message}
} + {error &&
{error}
} + + {/* Current state */} +
+

Licence

+ {status.valid ? ( +
+
Licensed
+
+
Customer
{status.customer || '—'}
+
Stations
+
{status.stations_used} of {status.stations} in use
+
+
+ ) : ( +
+
+ {status.in_grace ? 'Not licensed' : 'Licence expired'} +
+

{status.reason}

+ {status.in_grace ? ( +

+ Everything keeps working for {status.grace_days_left} more days. + After that only {1} station can measure. Send the fingerprint below to + your supplier to get a licence key. +

+ ) : ( +

+ The grace period has ended, so only {status.stations} station can measure. + Send the fingerprint below to your supplier. +

+ )} +
+ )} +
+ + {/* Fingerprint to send */} +
+

This machine

+

+ Send this to your supplier to obtain a licence key. It identifies this + server only — the values are one-way hashes, not hardware serial numbers. +

+ +
+ {status.fingerprint} + +
+ +
+ Full details (needed to issue the key) +
+ {componentsJson} + +
+
+
+ + {/* Install */} +
+

Install a licence key

+