6fe9f1820c
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>
110 lines
3.7 KiB
React
110 lines
3.7 KiB
React
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>
|
|
);
|
|
}
|