feat: license UI card in settings (#47) #57

Merged
maximus merged 2 commits from issue-47-license-ui-card into issue-46-license-commands-entitlements 2026-04-10 13:55:00 +00:00
8 changed files with 296 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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 (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<KeyRound size={18} />
{t("license.title")}
</h2>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-[var(--muted-foreground)]">
{t("license.currentEdition")}
</p>
<p className="text-base font-medium">
{editionLabel}
{state.edition !== "free" && (
<CheckCircle size={16} className="inline ml-2 text-[var(--positive)]" />
)}
</p>
</div>
{state.info && state.info.expires_at > 0 && (
<div className="text-right">
<p className="text-xs text-[var(--muted-foreground)]">
{t("license.expiresAt")}
</p>
<p className="text-sm">{formatExpiry(state.info.expires_at)}</p>
</div>
)}
</div>
{state.status === "error" && state.error && (
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<p>{state.error}</p>
</div>
)}
{!showInput && (
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => setShowInput(true)}
className="px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm"
>
{t("license.enterKey")}
</button>
{state.edition === "free" && (
<button
type="button"
onClick={handlePurchase}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm"
>
<ExternalLink size={14} />
{t("license.purchase")}
</button>
)}
</div>
)}
{showInput && (
<form onSubmit={handleSubmit} className="space-y-3">
<input
type="text"
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
placeholder={t("license.keyPlaceholder")}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
autoFocus
/>
<div className="flex gap-2">
<button
type="submit"
disabled={state.status === "validating" || !keyInput.trim()}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm disabled:opacity-50"
>
{state.status === "validating" && <Loader2 size={14} className="animate-spin" />}
{t("license.activate")}
</button>
<button
type="button"
onClick={() => {
setShowInput(false);
setKeyInput("");
}}
className="px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm"
>
{t("common.cancel")}
</button>
</div>
</form>
)}
</div>
);
}

98
src/hooks/useLicense.ts Normal file
View file

@ -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 };
}

View file

@ -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"
}
}
}

View file

@ -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"
}
}
}

View file

@ -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() {
<PageHelp helpKey="settings" />
</div>
{/* License card */}
<LicenseCard />
{/* About card */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
<div className="flex items-center gap-4">

View file

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