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 (
+
+ );
+ }
+
+ const componentsJson = JSON.stringify(status.components || {});
+
+ return (
+
+
+
+
+ navigate('/')}>← Back
+
+
+ {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}
+ copy(status.fingerprint, 'fingerprint')}>
+ {copied === 'fingerprint' ? 'Copied' : 'Copy'}
+
+
+
+
+ Full details (needed to issue the key)
+
+ {componentsJson}
+ copy(componentsJson, 'components')}>
+ {copied === 'components' ? 'Copied' : 'Copy'}
+
+
+
+
+
+ {/* Install */}
+
+
Install a licence key
+
+
+ {/* Connected stations */}
+ {status.slots && (
+
+
Connected stations ({status.slots.length})
+ {status.slots.length === 0 ? (
+
No stations have reported in.
+ ) : (
+
+
+
+ Station COM port Bridge version Last seen
+
+
+
+ {status.slots.map(s => (
+
+ {s.station_key}
+ {s.com_port || '—'}
+ {s.bridge_version || '—'}
+ {new Date(s.last_seen).toLocaleTimeString()}
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/contexts/LicenseContext.jsx b/frontend/src/contexts/LicenseContext.jsx
new file mode 100644
index 0000000..5d5ea2a
--- /dev/null
+++ b/frontend/src/contexts/LicenseContext.jsx
@@ -0,0 +1,109 @@
+import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
+import api from '../services/api';
+
+const LicenseContext = createContext(null);
+
+const SERIAL_URL = process.env.REACT_APP_SERIAL_URL || 'http://localhost:5000';
+const HEARTBEAT_MS = 30000; // server releases a slot after 120s, so 4 missed beats
+
+export const useLicense = () => {
+ const ctx = useContext(LicenseContext);
+ if (!ctx) throw new Error('useLicense must be used within LicenseProvider');
+ return ctx;
+};
+
+/** Stable identifier for the bridge this browser is using.
+ *
+ * The bridge deliberately never talks to Django, so the browser reports which
+ * station it is on. Built from the bridge address plus its COM port, so two
+ * browsers on the same station share one slot rather than consuming two.
+ */
+async function readStationKey() {
+ try {
+ const res = await fetch(`${SERIAL_URL}/status`);
+ if (!res.ok) return null;
+ const status = await res.json();
+ const raw = `${SERIAL_URL}|${status.com_port || 'unknown'}`;
+ // Short stable hash - this is an identifier, not a secret
+ let hash = 0;
+ for (let i = 0; i < raw.length; i++) {
+ hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0;
+ }
+ return {
+ station_key: `st-${Math.abs(hash).toString(36)}`,
+ com_port: status.com_port || '',
+ bridge_version: status.version || '',
+ };
+ } catch {
+ return null;
+ }
+}
+
+export function LicenseProvider({ children }) {
+ const [state, setState] = useState({
+ allowed: true, // assume allowed until told otherwise, so a slow
+ stationsUsed: null, // first heartbeat never blocks a working station
+ stationsLicensed: null,
+ detail: null,
+ stationKey: null,
+ });
+ const [status, setStatus] = useState(null);
+
+ const stationRef = useRef(null);
+
+ const beat = useCallback(async () => {
+ if (!stationRef.current) {
+ stationRef.current = await readStationKey();
+ }
+ const station = stationRef.current;
+ if (!station) return; // bridge unreachable; nothing to register
+
+ try {
+ const res = await api.post('/api/license/heartbeat/', station);
+ setState({
+ allowed: res.data.allowed,
+ stationsUsed: res.data.stations_used,
+ stationsLicensed: res.data.stations_licensed,
+ detail: null,
+ stationKey: station.station_key,
+ });
+ } catch (err) {
+ if (err.response?.status === 403) {
+ const d = err.response.data || {};
+ setState({
+ allowed: false,
+ stationsUsed: d.stations_used ?? null,
+ stationsLicensed: d.stations_licensed ?? null,
+ detail: d.detail || 'Licence limit reached',
+ stationKey: station.station_key,
+ });
+ }
+ // Other failures (server down) leave the previous state alone -
+ // a licence check must not stop a working weighbridge.
+ }
+ }, []);
+
+ useEffect(() => {
+ beat();
+ const timer = setInterval(beat, HEARTBEAT_MS);
+ return () => clearInterval(timer);
+ }, [beat]);
+
+ const loadStatus = useCallback(async () => {
+ try {
+ const res = await api.get('/api/license/');
+ setStatus(res.data);
+ return res.data;
+ } catch {
+ return null;
+ }
+ }, []);
+
+ useEffect(() => { loadStatus(); }, [loadStatus]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/tools/license_tool.py b/tools/license_tool.py
new file mode 100644
index 0000000..b54ac2b
--- /dev/null
+++ b/tools/license_tool.py
@@ -0,0 +1,141 @@
+"""Vendor licence signing tool - KEEP THIS AND THE PRIVATE KEY OUT OF CUSTOMER BUILDS.
+
+This is the only thing that can create a valid licence. The application can only
+verify. If the private key leaks, every licence ever issued becomes forgeable and
+it cannot be rotated without reissuing to every customer, because the public key
+is compiled into shipped binaries.
+
+Usage:
+
+ Create your keypair once:
+ python license_tool.py keygen
+
+ Then for each customer, from the fingerprint and components they send you:
+ python license_tool.py sign --customer "Port of Varna" --stations 5 \\
+ --components '{"board":"ab12...","disk":"cd34...","mac":"ef56...","guid":"78ab..."}'
+
+The customer reads their component hashes from the admin licence screen (there is
+a copy button) - they are hashes, not raw serial numbers, so they carry no
+identifying hardware detail.
+"""
+
+import argparse
+import base64
+import json
+import os
+import sys
+
+from cryptography.hazmat.primitives.asymmetric.ed25519 import (
+ Ed25519PrivateKey, Ed25519PublicKey,
+)
+from cryptography.hazmat.primitives import serialization
+
+KEY_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'license_private.key')
+
+
+def keygen(args):
+ if os.path.exists(KEY_PATH) and not args.force:
+ print(f"Refusing to overwrite {KEY_PATH}")
+ print("Every licence signed with the existing key would stop verifying.")
+ print("Pass --force only if you are certain.")
+ return 1
+
+ private_key = Ed25519PrivateKey.generate()
+ raw = private_key.private_bytes(
+ encoding=serialization.Encoding.Raw,
+ format=serialization.PrivateFormat.Raw,
+ encryption_algorithm=serialization.NoEncryption(),
+ )
+ with open(KEY_PATH, 'wb') as fh:
+ fh.write(base64.b64encode(raw))
+
+ public_raw = private_key.public_key().public_bytes(
+ encoding=serialization.Encoding.Raw,
+ format=serialization.PublicFormat.Raw,
+ )
+ public_b64 = base64.b64encode(public_raw).decode()
+
+ print(f"Private key written to {KEY_PATH}")
+ print(" Back this up somewhere safe. It cannot be recovered.")
+ print(" Never commit it, never ship it.\n")
+ print("Put this public key in backend/api/licensing.py as LICENSE_PUBLIC_KEY:\n")
+ print(f"LICENSE_PUBLIC_KEY = '{public_b64}'")
+ return 0
+
+
+def _load_private_key():
+ if not os.path.exists(KEY_PATH):
+ print(f"No private key at {KEY_PATH} - run 'keygen' first.")
+ sys.exit(1)
+ with open(KEY_PATH, 'rb') as fh:
+ return Ed25519PrivateKey.from_private_bytes(base64.b64decode(fh.read()))
+
+
+def sign(args):
+ private_key = _load_private_key()
+
+ try:
+ components = json.loads(args.components)
+ except json.JSONDecodeError as exc:
+ print(f"--components is not valid JSON: {exc}")
+ return 1
+
+ known = {'board', 'disk', 'mac', 'guid'}
+ unknown = set(components) - known
+ if unknown:
+ print(f"Unknown component keys: {sorted(unknown)} (expected {sorted(known)})")
+ return 1
+ present = [k for k, v in components.items() if v]
+ if len(present) < 3:
+ print(f"Only {len(present)} components supplied; at least 3 must match on the")
+ print("customer machine, so signing this licence would lock them out.")
+ return 1
+
+ payload = {
+ 'customer': args.customer,
+ 'stations': args.stations,
+ 'components': components,
+ }
+ if args.expires:
+ payload['expires'] = args.expires
+
+ payload_json = json.dumps(payload, sort_keys=True, separators=(',', ':'))
+ signature = private_key.sign(payload_json.encode('utf-8'))
+ blob = base64.b64encode(
+ f"{payload_json}.{base64.b64encode(signature).decode()}".encode('utf-8')
+ ).decode()
+
+ print(f"Licence for {args.customer}, {args.stations} station(s)")
+ print(f"Components supplied: {', '.join(sorted(present))}")
+ if args.expires:
+ print(f"Expires: {args.expires}")
+ else:
+ print("Perpetual (no expiry)")
+ print("\nSend this to the customer:\n")
+ print(blob)
+ return 0
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ sub = parser.add_subparsers(dest='command', required=True)
+
+ p_keygen = sub.add_parser('keygen', help='Create the vendor keypair (once)')
+ p_keygen.add_argument('--force', action='store_true')
+ p_keygen.set_defaults(func=keygen)
+
+ p_sign = sub.add_parser('sign', help='Sign a licence for a customer machine')
+ p_sign.add_argument('--customer', required=True)
+ p_sign.add_argument('--stations', type=int, required=True)
+ p_sign.add_argument('--components', required=True,
+ help='JSON of hashed components from the admin screen')
+ p_sign.add_argument('--expires', default=None,
+ help='ISO date, e.g. 2027-01-01T00:00:00+00:00 (omit for perpetual)')
+ p_sign.set_defaults(func=sign)
+
+ args = parser.parse_args()
+ sys.exit(args.func(args))
+
+
+if __name__ == '__main__':
+ main()