- A 404 without a known code (missing route, proxy error page) is now
pinned as a server error by two table rows, so "key not found" can
never be shown for it.
- The message-key test spells out the expected key of every code
instead of comparing the map with itself, and checks the keys are
distinct: swapping two entries now fails.
- Idempotent deactivation moves into deactivate_idempotent, without
AppHandle, tested against a mock server (404 no_active_activation ->
done, 403 revoked -> error).
- Deactivate buttons stay disabled while the list reloads
(selectCanDeactivate), so a machine deactivated during a reload
cannot reappear.
- The rate-limit message uses i18next plural forms (rateLimit_one /
rateLimit_other with count): no more "1 seconds".
- CHANGELOG: drop the bullet about the edition updating after an
activation. The edition does not change with or without an
activation token, so nothing is observable; refresh() stays.
Proven red on the fixed tree: 404 without a known code read as
LicenseNotFound 1, license_expired mapped to the revoked key 2,
idempotence removed 1, buttons live during a reload 1, {seconds}
instead of {count} 2.
Refs #270
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
243 lines
8.6 KiB
TypeScript
243 lines
8.6 KiB
TypeScript
import { useCallback, useEffect, useMemo, useReducer } from "react";
|
|
import {
|
|
activateMachine,
|
|
deactivateMachine,
|
|
listActivatedMachines,
|
|
getActivationStatus,
|
|
type ActivationStatus,
|
|
type LicenseError,
|
|
type LicenseResult,
|
|
type MachineInfo,
|
|
} from "../services/licenseService";
|
|
import { useLicenseContext } from "../contexts/LicenseContext";
|
|
|
|
// Machine activation state of the License card (issue #270).
|
|
//
|
|
// The license server allows 5 requests per minute and per IP, shared by
|
|
// activate, verify and deactivate. The machine-limit path (activate → 409 →
|
|
// list via verify → deactivate → activate) therefore has to fit in 4 calls:
|
|
// a successful deactivation removes the machine locally instead of re-running
|
|
// verify, and nothing loads the list from an effect (StrictMode would double
|
|
// it). List loads come only from explicit handlers.
|
|
|
|
export interface LicenseMachinesState {
|
|
activation: ActivationStatus | null;
|
|
machines: MachineInfo[];
|
|
listOpen: boolean;
|
|
listLoading: boolean;
|
|
listLoaded: boolean;
|
|
/** Error of the list itself (verify) or of a deactivation. */
|
|
listError: LicenseError | null;
|
|
activating: boolean;
|
|
/** Error of the last activation. Survives the list load it may trigger. */
|
|
activationError: LicenseError | null;
|
|
/** Machine being deactivated; every Deactivate button is disabled meanwhile. */
|
|
deactivatingId: string | null;
|
|
}
|
|
|
|
export type LicenseMachinesAction =
|
|
| { type: "ACTIVATION_STATUS"; activation: ActivationStatus }
|
|
| { type: "TOGGLE_LIST" }
|
|
| { type: "LIST_LOAD_START" }
|
|
| { type: "LIST_LOAD_DONE"; machines: MachineInfo[] }
|
|
| { type: "LIST_LOAD_ERROR"; error: LicenseError }
|
|
| { type: "ACTIVATE_START" }
|
|
| { type: "ACTIVATE_DONE" }
|
|
| { type: "ACTIVATE_ERROR"; error: LicenseError }
|
|
| { type: "DEACTIVATE_START"; machineId: string }
|
|
| { type: "DEACTIVATE_DONE"; machineId: string }
|
|
| { type: "DEACTIVATE_ERROR"; error: LicenseError };
|
|
|
|
export const initialLicenseMachinesState: LicenseMachinesState = {
|
|
activation: null,
|
|
machines: [],
|
|
listOpen: false,
|
|
listLoading: false,
|
|
listLoaded: false,
|
|
listError: null,
|
|
activating: false,
|
|
activationError: null,
|
|
deactivatingId: null,
|
|
};
|
|
|
|
export function licenseMachinesReducer(
|
|
state: LicenseMachinesState,
|
|
action: LicenseMachinesAction,
|
|
): LicenseMachinesState {
|
|
switch (action.type) {
|
|
case "ACTIVATION_STATUS":
|
|
return { ...state, activation: action.activation };
|
|
case "TOGGLE_LIST":
|
|
return { ...state, listOpen: !state.listOpen };
|
|
// The list actions never touch activationError: the machine-limit message
|
|
// must stay visible while the list it opened loads (D2).
|
|
case "LIST_LOAD_START":
|
|
return { ...state, listLoading: true, listError: null };
|
|
case "LIST_LOAD_DONE":
|
|
return {
|
|
...state,
|
|
listLoading: false,
|
|
listLoaded: true,
|
|
listError: null,
|
|
machines: action.machines,
|
|
};
|
|
case "LIST_LOAD_ERROR":
|
|
// listLoaded stays false so that reopening the list retries.
|
|
return {
|
|
...state,
|
|
listLoading: false,
|
|
listLoaded: false,
|
|
listError: action.error,
|
|
machines: [],
|
|
};
|
|
case "ACTIVATE_START":
|
|
return { ...state, activating: true, activationError: null };
|
|
case "ACTIVATE_DONE":
|
|
// The loaded list no longer reflects the server: close it and mark it
|
|
// stale rather than spending another verify call.
|
|
return { ...state, activating: false, listOpen: false, listLoaded: false, machines: [] };
|
|
case "ACTIVATE_ERROR":
|
|
return {
|
|
...state,
|
|
activating: false,
|
|
activationError: action.error,
|
|
listOpen: action.error.code === "machine_limit_reached" ? true : state.listOpen,
|
|
};
|
|
case "DEACTIVATE_START":
|
|
return { ...state, deactivatingId: action.machineId, listError: null };
|
|
case "DEACTIVATE_DONE":
|
|
return {
|
|
...state,
|
|
deactivatingId: null,
|
|
machines: state.machines.filter((m) => m.machine_id !== action.machineId),
|
|
// A slot is now free: the machine-limit message no longer holds.
|
|
activationError:
|
|
state.activationError?.code === "machine_limit_reached" ? null : state.activationError,
|
|
};
|
|
case "DEACTIVATE_ERROR":
|
|
return { ...state, deactivatingId: null, listError: action.error };
|
|
}
|
|
}
|
|
|
|
/** The "no machine activated" line: only for a loaded, empty, error-free list. */
|
|
export function selectShowEmptyState(state: LicenseMachinesState): boolean {
|
|
return (
|
|
state.listLoaded && !state.listLoading && state.machines.length === 0 && state.listError === null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Deactivate buttons are live only when no deactivation and no list reload is running. A
|
|
* reload (e.g. the one a 409 triggers over an already loaded list) returns the server's
|
|
* list as of its request: a machine deactivated meanwhile would reappear in it.
|
|
*/
|
|
export function selectCanDeactivate(state: LicenseMachinesState): boolean {
|
|
return state.deactivatingId === null && !state.listLoading;
|
|
}
|
|
|
|
/** Whether opening the list (from `state`, before the toggle) should load it. */
|
|
export function shouldLoadOnOpen(state: LicenseMachinesState): boolean {
|
|
return !state.listOpen && !state.listLoading && (!state.listLoaded || state.listError !== null);
|
|
}
|
|
|
|
export interface LicenseMachinesDeps {
|
|
activateMachine: () => Promise<LicenseResult<void>>;
|
|
deactivateMachine: (machineId: string) => Promise<LicenseResult<void>>;
|
|
listActivatedMachines: () => Promise<LicenseResult<MachineInfo[]>>;
|
|
getActivationStatus: () => Promise<ActivationStatus>;
|
|
refresh: () => Promise<void>;
|
|
dispatch: (action: LicenseMachinesAction) => void;
|
|
}
|
|
|
|
/** Local read (no network). Best effort: a failure keeps the last known status. */
|
|
export async function loadActivationStatus(deps: LicenseMachinesDeps): Promise<void> {
|
|
try {
|
|
const activation = await deps.getActivationStatus();
|
|
deps.dispatch({ type: "ACTIVATION_STATUS", activation });
|
|
} catch {
|
|
// The card keeps showing the previous activation status.
|
|
}
|
|
}
|
|
|
|
export async function loadMachines(deps: LicenseMachinesDeps): Promise<void> {
|
|
deps.dispatch({ type: "LIST_LOAD_START" });
|
|
const result = await deps.listActivatedMachines();
|
|
deps.dispatch(
|
|
result.ok
|
|
? { type: "LIST_LOAD_DONE", machines: result.value }
|
|
: { type: "LIST_LOAD_ERROR", error: result.error },
|
|
);
|
|
}
|
|
|
|
export async function performActivate(deps: LicenseMachinesDeps): Promise<void> {
|
|
deps.dispatch({ type: "ACTIVATE_START" });
|
|
const result = await deps.activateMachine();
|
|
if (!result.ok) {
|
|
deps.dispatch({ type: "ACTIVATE_ERROR", error: result.error });
|
|
if (result.error.code === "machine_limit_reached") {
|
|
await loadMachines(deps);
|
|
}
|
|
return;
|
|
}
|
|
deps.dispatch({ type: "ACTIVATE_DONE" });
|
|
await loadActivationStatus(deps);
|
|
// The edition shown by the app follows without a restart.
|
|
await deps.refresh();
|
|
}
|
|
|
|
export async function performDeactivate(deps: LicenseMachinesDeps, machineId: string): Promise<void> {
|
|
deps.dispatch({ type: "DEACTIVATE_START", machineId });
|
|
const result = await deps.deactivateMachine(machineId);
|
|
if (!result.ok) {
|
|
deps.dispatch({ type: "DEACTIVATE_ERROR", error: result.error });
|
|
return;
|
|
}
|
|
// Removed locally, no verify call: see the rate budget note at the top.
|
|
deps.dispatch({ type: "DEACTIVATE_DONE", machineId });
|
|
await loadActivationStatus(deps);
|
|
await deps.refresh();
|
|
}
|
|
|
|
/**
|
|
* Machine list and activation of the License card. `enabled` is false in the
|
|
* free edition, where the card shows no machine section.
|
|
*/
|
|
export function useLicenseMachines(enabled: boolean) {
|
|
const { refresh } = useLicenseContext();
|
|
const [state, dispatch] = useReducer(licenseMachinesReducer, initialLicenseMachinesState);
|
|
|
|
const deps = useMemo<LicenseMachinesDeps>(
|
|
() => ({
|
|
activateMachine,
|
|
deactivateMachine,
|
|
listActivatedMachines,
|
|
getActivationStatus,
|
|
refresh,
|
|
dispatch,
|
|
}),
|
|
[refresh],
|
|
);
|
|
|
|
// Local activation status only (get_activation_status reads the token on
|
|
// disk): safe to run twice under StrictMode.
|
|
useEffect(() => {
|
|
if (enabled) void loadActivationStatus(deps);
|
|
}, [enabled, deps]);
|
|
|
|
const activate = useCallback(() => performActivate(deps), [deps]);
|
|
const deactivate = useCallback((machineId: string) => performDeactivate(deps, machineId), [deps]);
|
|
const toggleList = useCallback(() => {
|
|
const load = shouldLoadOnOpen(state);
|
|
dispatch({ type: "TOGGLE_LIST" });
|
|
if (load) void loadMachines(deps);
|
|
}, [state, deps]);
|
|
|
|
return {
|
|
state,
|
|
activate,
|
|
deactivate,
|
|
toggleList,
|
|
showEmptyState: selectShowEmptyState(state),
|
|
canDeactivate: selectCanDeactivate(state),
|
|
};
|
|
}
|