diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md
index e6b4701..6af08d3 100644
--- a/CHANGELOG.fr.md
+++ b/CHANGELOG.fr.md
@@ -4,6 +4,7 @@
### Ajouté
- CI : nouveau workflow `check.yml` qui exécute `cargo check`/`cargo test` et le build frontend sur chaque push de branche et PR, détectant les erreurs avant le merge plutôt qu'au moment de la release (#60)
+- Carte de licence dans les Paramètres : affiche l'édition actuelle (Gratuite/Base/Premium), accepte une clé de licence et redirige vers la page d'achat (#47)
## [0.6.7] - 2026-03-29
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 391185b..c0364c7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
### Added
- CI: new `check.yml` workflow runs `cargo check`/`cargo test` and the frontend build on every branch push and PR, catching errors before merge instead of waiting for the release tag (#60)
+- License card in Settings page: shows the current edition (Free/Base/Premium), accepts a license key, and links to the purchase page (#47)
## [0.6.7] - 2026-03-29
diff --git a/src/components/settings/LicenseCard.tsx b/src/components/settings/LicenseCard.tsx
new file mode 100644
index 0000000..d15a659
--- /dev/null
+++ b/src/components/settings/LicenseCard.tsx
@@ -0,0 +1,128 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { openUrl } from "@tauri-apps/plugin-opener";
+import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink } from "lucide-react";
+import { useLicense } from "../../hooks/useLicense";
+
+const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat";
+
+export default function LicenseCard() {
+ const { t } = useTranslation();
+ const { state, submitKey } = useLicense();
+ const [keyInput, setKeyInput] = useState("");
+ const [showInput, setShowInput] = useState(false);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ const trimmed = keyInput.trim();
+ if (!trimmed) return;
+ const result = await submitKey(trimmed);
+ if (result.ok) {
+ setKeyInput("");
+ setShowInput(false);
+ }
+ };
+
+ const handlePurchase = () => {
+ void openUrl(PURCHASE_URL);
+ };
+
+ const formatExpiry = (timestamp: number) => {
+ return new Date(timestamp * 1000).toLocaleDateString();
+ };
+
+ const editionLabel = t(`license.editions.${state.edition}`);
+
+ return (
+
+
+
+ {t("license.title")}
+
+
+
+
+
+ {t("license.currentEdition")}
+
+
+ {editionLabel}
+ {state.edition !== "free" && (
+
+ )}
+
+
+ {state.info && state.info.expires_at > 0 && (
+
+
+ {t("license.expiresAt")}
+
+
{formatExpiry(state.info.expires_at)}
+
+ )}
+
+
+ {state.status === "error" && state.error && (
+
+ )}
+
+ {!showInput && (
+
+
+ {state.edition === "free" && (
+
+ )}
+
+ )}
+
+ {showInput && (
+
+ )}
+
+ );
+}
diff --git a/src/hooks/useLicense.ts b/src/hooks/useLicense.ts
new file mode 100644
index 0000000..a43b703
--- /dev/null
+++ b/src/hooks/useLicense.ts
@@ -0,0 +1,98 @@
+import { useCallback, useEffect, useReducer } from "react";
+import {
+ Edition,
+ LicenseInfo,
+ checkEntitlement as checkEntitlementCmd,
+ getEdition,
+ readLicense,
+ storeLicense,
+} from "../services/licenseService";
+
+type LicenseStatus = "idle" | "loading" | "ready" | "validating" | "error";
+
+interface LicenseState {
+ status: LicenseStatus;
+ edition: Edition;
+ info: LicenseInfo | null;
+ error: string | null;
+}
+
+type LicenseAction =
+ | { type: "LOAD_START" }
+ | { type: "LOAD_DONE"; edition: Edition; info: LicenseInfo | null }
+ | { type: "VALIDATE_START" }
+ | { type: "VALIDATE_DONE"; info: LicenseInfo }
+ | { type: "ERROR"; error: string };
+
+const initialState: LicenseState = {
+ status: "idle",
+ edition: "free",
+ info: null,
+ error: null,
+};
+
+function reducer(state: LicenseState, action: LicenseAction): LicenseState {
+ switch (action.type) {
+ case "LOAD_START":
+ return { ...state, status: "loading", error: null };
+ case "LOAD_DONE":
+ return {
+ status: "ready",
+ edition: action.edition,
+ info: action.info,
+ error: null,
+ };
+ case "VALIDATE_START":
+ return { ...state, status: "validating", error: null };
+ case "VALIDATE_DONE":
+ return {
+ status: "ready",
+ edition: action.info.edition,
+ info: action.info,
+ error: null,
+ };
+ case "ERROR":
+ return { ...state, status: "error", error: action.error };
+ }
+}
+
+export function useLicense() {
+ const [state, dispatch] = useReducer(reducer, initialState);
+
+ const refresh = useCallback(async () => {
+ dispatch({ type: "LOAD_START" });
+ try {
+ const [edition, info] = await Promise.all([getEdition(), readLicense()]);
+ dispatch({ type: "LOAD_DONE", edition, info });
+ } catch (e) {
+ dispatch({
+ type: "ERROR",
+ error: e instanceof Error ? e.message : String(e),
+ });
+ }
+ }, []);
+
+ const submitKey = useCallback(async (key: string) => {
+ dispatch({ type: "VALIDATE_START" });
+ try {
+ const info = await storeLicense(key);
+ dispatch({ type: "VALIDATE_DONE", info });
+ return { ok: true as const, info };
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e);
+ dispatch({ type: "ERROR", error: message });
+ return { ok: false as const, error: message };
+ }
+ }, []);
+
+ const checkEntitlement = useCallback(
+ (feature: string) => checkEntitlementCmd(feature),
+ [],
+ );
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ return { state, refresh, submitKey, checkEntitlement };
+}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index e7b4011..8f27463 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -846,5 +846,19 @@
"total": "Total",
"darkMode": "Dark mode",
"lightMode": "Light mode"
+ },
+ "license": {
+ "title": "License",
+ "currentEdition": "Current edition",
+ "expiresAt": "Expires on",
+ "enterKey": "Enter a license key",
+ "keyPlaceholder": "SR-BASE-...",
+ "activate": "Activate",
+ "purchase": "Buy Simpl'Result",
+ "editions": {
+ "free": "Free",
+ "base": "Base",
+ "premium": "Premium"
+ }
}
}
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index af3cba4..0f308a0 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -846,5 +846,19 @@
"total": "Total",
"darkMode": "Mode sombre",
"lightMode": "Mode clair"
+ },
+ "license": {
+ "title": "Licence",
+ "currentEdition": "Édition actuelle",
+ "expiresAt": "Expire le",
+ "enterKey": "Entrer une clé de licence",
+ "keyPlaceholder": "SR-BASE-...",
+ "activate": "Activer",
+ "purchase": "Acheter Simpl'Résultat",
+ "editions": {
+ "free": "Gratuite",
+ "base": "Base",
+ "premium": "Premium"
+ }
}
}
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index 6e56b16..2d9a476 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -19,6 +19,7 @@ import { Link } from "react-router-dom";
import { APP_NAME } from "../shared/constants";
import { PageHelp } from "../components/shared/PageHelp";
import DataManagementCard from "../components/settings/DataManagementCard";
+import LicenseCard from "../components/settings/LicenseCard";
import LogViewerCard from "../components/settings/LogViewerCard";
export default function SettingsPage() {
@@ -72,6 +73,9 @@ export default function SettingsPage() {
+ {/* License card */}
+
+
{/* About card */}
diff --git a/src/services/licenseService.ts b/src/services/licenseService.ts
new file mode 100644
index 0000000..73beee9
--- /dev/null
+++ b/src/services/licenseService.ts
@@ -0,0 +1,36 @@
+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 {
+ return invoke("validate_license_key", { key });
+}
+
+export async function storeLicense(key: string): Promise {
+ return invoke("store_license", { key });
+}
+
+export async function readLicense(): Promise {
+ return invoke("read_license");
+}
+
+export async function getEdition(): Promise {
+ return invoke("get_edition");
+}
+
+export async function getMachineId(): Promise {
+ return invoke("get_machine_id");
+}
+
+export async function checkEntitlement(feature: string): Promise {
+ return invoke("check_entitlement", { feature });
+}