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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<LicenseContext.Provider value={{ ...state, status, refreshStatus: loadStatus }}>
|
||||
{children}
|
||||
</LicenseContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user