Simpl-Resultat/src/services/licenseService.ts
le king fu 7d667f7ca3
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m40s
PR Check — Rust / rust (pull_request) Successful in 8m52s
fix(license): call /v1 routes with product, name server refusals, localize purchase URL
- Activation, deactivation and verify POST to /v1/licenses/* with
  "product":"simpl-resultat" instead of the legacy /licenses/* paths,
  which cost a 308 on every call.
- New LicenseError enum, serialized as {code} like FetchPriceError and
  read from error.code of the /v1 envelope. A 200 {valid:false} from
  verify is now a coded refusal instead of an empty machine list, and an
  HTML error page on a non-2xx maps to server instead of
  invalid_response. Deactivation is idempotent: no_active_activation
  counts as done.
- The three calls live in *_inner functions without AppHandle, tested
  against a mockito server that only declares the /v1 routes.
- useLicenseMachines hook with an exported reducer. The activation error
  survives the list load it triggers (machine_limit_reached opens the
  list), a deactivation removes the machine locally without re-running
  verify (4 requests on the machine-limit path, under the server's
  5/min/IP limit), and refresh() follows every successful activation or
  deactivation.
- license.errors.* in FR and EN; purchaseUrl() opens
  /{fr|en}/produits/simpl-resultat.

test: reproduces #270. Each original defect, reintroduced on the final
tree, turns tests red: legacy activate path 5, missing product 2,
envelope read as a string 3, verify valid:false as an empty list 2,
strict body parse on a non-2xx 2, untrimmed key 1, list load clearing
the activation error 4, verify re-run after deactivation 2, empty state
despite a list error 1, no refresh after activation 3, old purchase
URL 4.

Fixes #270

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 09:15:52 -04:00

163 lines
5.2 KiB
TypeScript

import { invoke } from "@tauri-apps/api/core";
export type Edition = "free" | "base" | "premium";
export interface LicenseInfo {
edition: Edition;
email: string;
features: string[];
machine_limit: number;
issued_at: number;
expires_at: number;
}
export async function validateLicenseKey(key: string): Promise<LicenseInfo> {
return invoke<LicenseInfo>("validate_license_key", { key });
}
export async function storeLicense(key: string): Promise<LicenseInfo> {
return invoke<LicenseInfo>("store_license", { key });
}
export async function readLicense(): Promise<LicenseInfo | null> {
return invoke<LicenseInfo | null>("read_license");
}
export async function getEdition(): Promise<Edition> {
return invoke<Edition>("get_edition");
}
export async function getMachineId(): Promise<string> {
return invoke<string>("get_machine_id");
}
export async function checkEntitlement(feature: string): Promise<boolean> {
return invoke<boolean>("check_entitlement", { feature });
}
export interface MachineInfo {
machine_id: string;
machine_name: string | null;
activated_at: string;
last_seen_at: string;
}
export interface ActivationStatus {
is_activated: boolean;
machine_id: string;
}
// -----------------------------------------------------------------------------
// License server errors (issue #270)
// -----------------------------------------------------------------------------
//
// The network commands (activate_machine, deactivate_machine,
// list_activated_machines) reject with a JSON string serialized from the Rust
// `LicenseError` enum (license_commands.rs), same pattern as fetch_price:
// {"code":"license_revoked"} | {"code":"rate_limit","retry_after_s":42}
// | {"code":"server","status":502} | ...
// `unknown` is the client-side fallback for anything that is not such a string.
export type LicenseErrorCode =
| "license_not_found"
| "license_revoked"
| "license_expired"
| "machine_limit_reached"
| "no_active_activation"
| "invalid_input"
| "rate_limit"
| "network"
| "invalid_response"
| "server"
| "no_license_stored"
| "storage"
| "internal"
| "unknown";
export interface LicenseError {
code: LicenseErrorCode;
i18nKey: string;
/** Interpolation values for `i18nKey` — only `rate_limit` carries one. */
params?: { seconds: number };
}
export const LICENSE_ERROR_I18N_MAP: Record<LicenseErrorCode, string> = {
license_not_found: "license.errors.licenseNotFound",
license_revoked: "license.errors.licenseRevoked",
license_expired: "license.errors.licenseExpired",
machine_limit_reached: "license.errors.machineLimitReached",
no_active_activation: "license.errors.noActiveActivation",
invalid_input: "license.errors.invalidInput",
rate_limit: "license.errors.rateLimit",
network: "license.errors.network",
invalid_response: "license.errors.invalidResponse",
server: "license.errors.server",
no_license_stored: "license.errors.noLicenseStored",
storage: "license.errors.storage",
internal: "license.errors.internal",
unknown: "license.errors.unknown",
};
/** Wait shown when a 429 carries no usable retry_after (the server window is 60 s). */
const DEFAULT_RETRY_AFTER_S = 60;
function isLicenseErrorCode(code: string): code is LicenseErrorCode {
return Object.prototype.hasOwnProperty.call(LICENSE_ERROR_I18N_MAP, code);
}
/**
* Parse the string-serialized Rust `LicenseError` into a typed error with its
* i18n key. `invoke` rejects with the `Err(String)` value of the command.
*/
export function parseLicenseError(e: unknown): LicenseError {
if (typeof e === "string") {
try {
const j = JSON.parse(e) as Record<string, unknown> | null;
if (j && typeof j.code === "string" && isLicenseErrorCode(j.code)) {
const code = j.code;
if (code === "rate_limit") {
const raw = j.retry_after_s;
const seconds =
typeof raw === "number" && Number.isFinite(raw) && raw >= 1
? Math.ceil(raw)
: DEFAULT_RETRY_AFTER_S;
return { code, i18nKey: LICENSE_ERROR_I18N_MAP.rate_limit, params: { seconds } };
}
return { code, i18nKey: LICENSE_ERROR_I18N_MAP[code] };
}
} catch {
// Not a serialized LicenseError — fall through to `unknown`.
}
}
return { code: "unknown", i18nKey: LICENSE_ERROR_I18N_MAP.unknown };
}
export type LicenseResult<T> = { ok: true; value: T } | { ok: false; error: LicenseError };
async function callLicenseCommand<T>(
command: string,
args?: Record<string, unknown>,
): Promise<LicenseResult<T>> {
try {
const value = await invoke<T>(command, args);
return { ok: true, value };
} catch (e) {
return { ok: false, error: parseLicenseError(e) };
}
}
export function activateMachine(): Promise<LicenseResult<void>> {
return callLicenseCommand<void>("activate_machine");
}
export function deactivateMachine(machineId: string): Promise<LicenseResult<void>> {
return callLicenseCommand<void>("deactivate_machine", { machineId });
}
export function listActivatedMachines(): Promise<LicenseResult<MachineInfo[]>> {
return callLicenseCommand<MachineInfo[]>("list_activated_machines");
}
export async function getActivationStatus(): Promise<ActivationStatus> {
return invoke<ActivationStatus>("get_activation_status");
}