Simpl-Resultat/src/hooks/useLicenseMachines.ts
le king fu a051b96c87
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m41s
PR Check — Rust / rust (pull_request) Successful in 8m48s
fix(license): apply the review of #359
- 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>
2026-09-26 20:27:43 -04:00

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),
};
}