diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index e6b4701..c5fe501 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -4,6 +4,10 @@ ### 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) + +### Modifié +- Les mises à jour automatiques sont maintenant réservées à l'édition Base ; l'édition Gratuite affiche un message invitant à activer une licence (#48) ## [0.6.7] - 2026-03-29 diff --git a/CHANGELOG.md b/CHANGELOG.md index 391185b..70b1dbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### 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) + +### Changed +- Automatic updates are now gated behind the Base edition entitlement; the Free edition shows an upgrade hint instead of fetching updates (#48) ## [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 && ( +
+ +

{state.error}

+
+ )} + + {!showInput && ( +
+ + {state.edition === "free" && ( + + )} +
+ )} + + {showInput && ( +
+ 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 + /> +
+ + +
+
+ )} +
+ ); +} diff --git a/src/components/shared/ErrorPage.tsx b/src/components/shared/ErrorPage.tsx index 630c95b..0cfbca8 100644 --- a/src/components/shared/ErrorPage.tsx +++ b/src/components/shared/ErrorPage.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { AlertTriangle, ChevronDown, ChevronUp, RefreshCw, Download, Mail, Bug } from "lucide-react"; import { check } from "@tauri-apps/plugin-updater"; +import { invoke } from "@tauri-apps/api/core"; interface ErrorPageProps { error?: string; @@ -10,7 +11,7 @@ interface ErrorPageProps { export default function ErrorPage({ error }: ErrorPageProps) { const { t } = useTranslation(); const [showDetails, setShowDetails] = useState(false); - const [updateStatus, setUpdateStatus] = useState<"idle" | "checking" | "available" | "upToDate" | "error">("idle"); + const [updateStatus, setUpdateStatus] = useState<"idle" | "checking" | "available" | "upToDate" | "notEntitled" | "error">("idle"); const [updateVersion, setUpdateVersion] = useState(null); const [updateError, setUpdateError] = useState(null); @@ -18,6 +19,13 @@ export default function ErrorPage({ error }: ErrorPageProps) { setUpdateStatus("checking"); setUpdateError(null); try { + const allowed = await invoke("check_entitlement", { + feature: "auto-update", + }); + if (!allowed) { + setUpdateStatus("notEntitled"); + return; + } const update = await check(); if (update) { setUpdateStatus("available"); @@ -89,6 +97,11 @@ export default function ErrorPage({ error }: ErrorPageProps) { {t("error.upToDate")}

)} + {updateStatus === "notEntitled" && ( +

+ {t("error.updateNotEntitled")} +

+ )} {updateStatus === "error" && updateError && (

{updateError}

)} 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/hooks/useUpdater.ts b/src/hooks/useUpdater.ts index b694786..9bf9f34 100644 --- a/src/hooks/useUpdater.ts +++ b/src/hooks/useUpdater.ts @@ -1,6 +1,7 @@ import { useReducer, useCallback, useRef } from "react"; import { check, type Update } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; +import { invoke } from "@tauri-apps/api/core"; type UpdateStatus = | "idle" @@ -10,6 +11,7 @@ type UpdateStatus = | "downloading" | "readyToInstall" | "installing" + | "notEntitled" | "error"; interface UpdaterState { @@ -29,6 +31,7 @@ type UpdaterAction = | { type: "DOWNLOAD_PROGRESS"; downloaded: number; contentLength: number | null } | { type: "READY_TO_INSTALL" } | { type: "INSTALLING" } + | { type: "NOT_ENTITLED" } | { type: "ERROR"; error: string }; const initialState: UpdaterState = { @@ -56,6 +59,8 @@ function reducer(state: UpdaterState, action: UpdaterAction): UpdaterState { return { ...state, status: "readyToInstall", error: null }; case "INSTALLING": return { ...state, status: "installing", error: null }; + case "NOT_ENTITLED": + return { ...state, status: "notEntitled", error: null }; case "ERROR": return { ...state, status: "error", error: action.error }; } @@ -68,6 +73,16 @@ export function useUpdater() { const checkForUpdate = useCallback(async () => { dispatch({ type: "CHECK_START" }); try { + // Auto-updates are gated behind the entitlements module (Issue #46/#48). + // The check is centralized server-side via `check_entitlement` so the + // tier→feature mapping lives in one place. + const allowed = await invoke("check_entitlement", { + feature: "auto-update", + }); + if (!allowed) { + dispatch({ type: "NOT_ENTITLED" }); + return; + } const update = await check(); if (update) { updateRef.current = update; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e7b4011..149e8dc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -436,7 +436,8 @@ "installing": "Installing...", "error": "Update failed", "retryButton": "Retry", - "releaseNotes": "What's New" + "releaseNotes": "What's New", + "notEntitled": "Automatic updates are available with the Base edition. Activate a license to enable them." }, "dataManagement": { "title": "Data Management", @@ -827,6 +828,7 @@ "checkUpdate": "Check for updates", "updateAvailable": "Update available: v{{version}}", "upToDate": "The application is up to date", + "updateNotEntitled": "Automatic updates are available with the Base edition.", "contactUs": "Contact us", "contactEmail": "Send an email to", "reportIssue": "Report an issue" @@ -846,5 +848,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..9c0924d 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -436,7 +436,8 @@ "installing": "Installation en cours...", "error": "Erreur lors de la mise à jour", "retryButton": "Réessayer", - "releaseNotes": "Nouveautés" + "releaseNotes": "Nouveautés", + "notEntitled": "Les mises à jour automatiques sont disponibles avec l'édition Base. Activez une licence pour les utiliser." }, "dataManagement": { "title": "Gestion des données", @@ -827,6 +828,7 @@ "checkUpdate": "Vérifier les mises à jour", "updateAvailable": "Mise à jour disponible : v{{version}}", "upToDate": "L'application est à jour", + "updateNotEntitled": "Les mises à jour automatiques sont disponibles avec l'édition Base.", "contactUs": "Nous contacter", "contactEmail": "Envoyez un email à", "reportIssue": "Signaler un problème" @@ -846,5 +848,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..5f91f3c 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 */}
@@ -155,6 +159,14 @@ export default function SettingsPage() {
)} + {/* not entitled (free edition) */} + {state.status === "notEntitled" && ( +
+ +

{t("settings.updates.notEntitled")}

+
+ )} + {/* up to date */} {state.status === "upToDate" && (
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 }); +}