- The idempotent-deactivation test now pins the route's other 404: license_not_found stays an error. The previous negative case was a 403 that the deactivate route never returns, so swallowing license_not_found (an unknown key shown as deactivated, the local token deleted) went unnoticed. - A test deactivates this machine and checks that the re-read activation status brings the Activate button back. Removing that re-read used to leave the suite green. - The Activate button stays disabled while the list loads (selectCanActivate), the counterpart of selectCanDeactivate: a verify answer landing after a successful activation can no longer mark as fresh a list that misses this machine. - CHANGELOG: the old /simpl-resultat address is redirected by the site since la-compagnie-maximus#153, so the "Buy" bullet now says the button opens the product page directly, in the app's language. The test comment is moved to the past tense. Proven red on the fixed tree: idempotence swallowing license_not_found 1, no status re-read after deactivation 1, Activate live during a list load 1. Refs #270 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
253 lines
9 KiB
TypeScript
253 lines
9 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;
|
|
}
|
|
|
|
/**
|
|
* The Activate button is live only when no activation and no list reload is running.
|
|
* Same reason as for deactivation: a verify answer landing after a successful
|
|
* activation would mark as fresh a list that no longer includes this machine.
|
|
*/
|
|
export function selectCanActivate(state: LicenseMachinesState): boolean {
|
|
return !state.activating && !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),
|
|
canActivate: selectCanActivate(state),
|
|
};
|
|
}
|