Merge pull request 'feat: license system (UI card + auto-update gating) (#47, #48)' (#64) from issue-46-license-commands-entitlements into main
This commit is contained in:
commit
877ace370f
10 changed files with 345 additions and 3 deletions
|
|
@ -4,6 +4,10 @@
|
||||||
|
|
||||||
### Ajouté
|
### 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)
|
- 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
|
## [0.6.7] - 2026-03-29
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@
|
||||||
|
|
||||||
### Added
|
### 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)
|
- 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
|
## [0.6.7] - 2026-03-29
|
||||||
|
|
||||||
|
|
|
||||||
128
src/components/settings/LicenseCard.tsx
Normal file
128
src/components/settings/LicenseCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AlertTriangle, ChevronDown, ChevronUp, RefreshCw, Download, Mail, Bug } from "lucide-react";
|
import { AlertTriangle, ChevronDown, ChevronUp, RefreshCw, Download, Mail, Bug } from "lucide-react";
|
||||||
import { check } from "@tauri-apps/plugin-updater";
|
import { check } from "@tauri-apps/plugin-updater";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
interface ErrorPageProps {
|
interface ErrorPageProps {
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|
@ -10,7 +11,7 @@ interface ErrorPageProps {
|
||||||
export default function ErrorPage({ error }: ErrorPageProps) {
|
export default function ErrorPage({ error }: ErrorPageProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showDetails, setShowDetails] = useState(false);
|
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<string | null>(null);
|
const [updateVersion, setUpdateVersion] = useState<string | null>(null);
|
||||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -18,6 +19,13 @@ export default function ErrorPage({ error }: ErrorPageProps) {
|
||||||
setUpdateStatus("checking");
|
setUpdateStatus("checking");
|
||||||
setUpdateError(null);
|
setUpdateError(null);
|
||||||
try {
|
try {
|
||||||
|
const allowed = await invoke<boolean>("check_entitlement", {
|
||||||
|
feature: "auto-update",
|
||||||
|
});
|
||||||
|
if (!allowed) {
|
||||||
|
setUpdateStatus("notEntitled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const update = await check();
|
const update = await check();
|
||||||
if (update) {
|
if (update) {
|
||||||
setUpdateStatus("available");
|
setUpdateStatus("available");
|
||||||
|
|
@ -89,6 +97,11 @@ export default function ErrorPage({ error }: ErrorPageProps) {
|
||||||
{t("error.upToDate")}
|
{t("error.upToDate")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{updateStatus === "notEntitled" && (
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{t("error.updateNotEntitled")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{updateStatus === "error" && updateError && (
|
{updateStatus === "error" && updateError && (
|
||||||
<p className="text-sm text-[var(--destructive)]">{updateError}</p>
|
<p className="text-sm text-[var(--destructive)]">{updateError}</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
98
src/hooks/useLicense.ts
Normal file
98
src/hooks/useLicense.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useReducer, useCallback, useRef } from "react";
|
import { useReducer, useCallback, useRef } from "react";
|
||||||
import { check, type Update } from "@tauri-apps/plugin-updater";
|
import { check, type Update } from "@tauri-apps/plugin-updater";
|
||||||
import { relaunch } from "@tauri-apps/plugin-process";
|
import { relaunch } from "@tauri-apps/plugin-process";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
type UpdateStatus =
|
type UpdateStatus =
|
||||||
| "idle"
|
| "idle"
|
||||||
|
|
@ -10,6 +11,7 @@ type UpdateStatus =
|
||||||
| "downloading"
|
| "downloading"
|
||||||
| "readyToInstall"
|
| "readyToInstall"
|
||||||
| "installing"
|
| "installing"
|
||||||
|
| "notEntitled"
|
||||||
| "error";
|
| "error";
|
||||||
|
|
||||||
interface UpdaterState {
|
interface UpdaterState {
|
||||||
|
|
@ -29,6 +31,7 @@ type UpdaterAction =
|
||||||
| { type: "DOWNLOAD_PROGRESS"; downloaded: number; contentLength: number | null }
|
| { type: "DOWNLOAD_PROGRESS"; downloaded: number; contentLength: number | null }
|
||||||
| { type: "READY_TO_INSTALL" }
|
| { type: "READY_TO_INSTALL" }
|
||||||
| { type: "INSTALLING" }
|
| { type: "INSTALLING" }
|
||||||
|
| { type: "NOT_ENTITLED" }
|
||||||
| { type: "ERROR"; error: string };
|
| { type: "ERROR"; error: string };
|
||||||
|
|
||||||
const initialState: UpdaterState = {
|
const initialState: UpdaterState = {
|
||||||
|
|
@ -56,6 +59,8 @@ function reducer(state: UpdaterState, action: UpdaterAction): UpdaterState {
|
||||||
return { ...state, status: "readyToInstall", error: null };
|
return { ...state, status: "readyToInstall", error: null };
|
||||||
case "INSTALLING":
|
case "INSTALLING":
|
||||||
return { ...state, status: "installing", error: null };
|
return { ...state, status: "installing", error: null };
|
||||||
|
case "NOT_ENTITLED":
|
||||||
|
return { ...state, status: "notEntitled", error: null };
|
||||||
case "ERROR":
|
case "ERROR":
|
||||||
return { ...state, status: "error", error: action.error };
|
return { ...state, status: "error", error: action.error };
|
||||||
}
|
}
|
||||||
|
|
@ -68,6 +73,16 @@ export function useUpdater() {
|
||||||
const checkForUpdate = useCallback(async () => {
|
const checkForUpdate = useCallback(async () => {
|
||||||
dispatch({ type: "CHECK_START" });
|
dispatch({ type: "CHECK_START" });
|
||||||
try {
|
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<boolean>("check_entitlement", {
|
||||||
|
feature: "auto-update",
|
||||||
|
});
|
||||||
|
if (!allowed) {
|
||||||
|
dispatch({ type: "NOT_ENTITLED" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const update = await check();
|
const update = await check();
|
||||||
if (update) {
|
if (update) {
|
||||||
updateRef.current = update;
|
updateRef.current = update;
|
||||||
|
|
|
||||||
|
|
@ -436,7 +436,8 @@
|
||||||
"installing": "Installing...",
|
"installing": "Installing...",
|
||||||
"error": "Update failed",
|
"error": "Update failed",
|
||||||
"retryButton": "Retry",
|
"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": {
|
"dataManagement": {
|
||||||
"title": "Data Management",
|
"title": "Data Management",
|
||||||
|
|
@ -827,6 +828,7 @@
|
||||||
"checkUpdate": "Check for updates",
|
"checkUpdate": "Check for updates",
|
||||||
"updateAvailable": "Update available: v{{version}}",
|
"updateAvailable": "Update available: v{{version}}",
|
||||||
"upToDate": "The application is up to date",
|
"upToDate": "The application is up to date",
|
||||||
|
"updateNotEntitled": "Automatic updates are available with the Base edition.",
|
||||||
"contactUs": "Contact us",
|
"contactUs": "Contact us",
|
||||||
"contactEmail": "Send an email to",
|
"contactEmail": "Send an email to",
|
||||||
"reportIssue": "Report an issue"
|
"reportIssue": "Report an issue"
|
||||||
|
|
@ -846,5 +848,19 @@
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"darkMode": "Dark mode",
|
"darkMode": "Dark mode",
|
||||||
"lightMode": "Light 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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -436,7 +436,8 @@
|
||||||
"installing": "Installation en cours...",
|
"installing": "Installation en cours...",
|
||||||
"error": "Erreur lors de la mise à jour",
|
"error": "Erreur lors de la mise à jour",
|
||||||
"retryButton": "Réessayer",
|
"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": {
|
"dataManagement": {
|
||||||
"title": "Gestion des données",
|
"title": "Gestion des données",
|
||||||
|
|
@ -827,6 +828,7 @@
|
||||||
"checkUpdate": "Vérifier les mises à jour",
|
"checkUpdate": "Vérifier les mises à jour",
|
||||||
"updateAvailable": "Mise à jour disponible : v{{version}}",
|
"updateAvailable": "Mise à jour disponible : v{{version}}",
|
||||||
"upToDate": "L'application est à jour",
|
"upToDate": "L'application est à jour",
|
||||||
|
"updateNotEntitled": "Les mises à jour automatiques sont disponibles avec l'édition Base.",
|
||||||
"contactUs": "Nous contacter",
|
"contactUs": "Nous contacter",
|
||||||
"contactEmail": "Envoyez un email à",
|
"contactEmail": "Envoyez un email à",
|
||||||
"reportIssue": "Signaler un problème"
|
"reportIssue": "Signaler un problème"
|
||||||
|
|
@ -846,5 +848,19 @@
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"darkMode": "Mode sombre",
|
"darkMode": "Mode sombre",
|
||||||
"lightMode": "Mode clair"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { Link } from "react-router-dom";
|
||||||
import { APP_NAME } from "../shared/constants";
|
import { APP_NAME } from "../shared/constants";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import DataManagementCard from "../components/settings/DataManagementCard";
|
import DataManagementCard from "../components/settings/DataManagementCard";
|
||||||
|
import LicenseCard from "../components/settings/LicenseCard";
|
||||||
import LogViewerCard from "../components/settings/LogViewerCard";
|
import LogViewerCard from "../components/settings/LogViewerCard";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
|
@ -72,6 +73,9 @@ export default function SettingsPage() {
|
||||||
<PageHelp helpKey="settings" />
|
<PageHelp helpKey="settings" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* License card */}
|
||||||
|
<LicenseCard />
|
||||||
|
|
||||||
{/* About card */}
|
{/* About card */}
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|
@ -155,6 +159,14 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* not entitled (free edition) */}
|
||||||
|
{state.status === "notEntitled" && (
|
||||||
|
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
|
||||||
|
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
||||||
|
<p>{t("settings.updates.notEntitled")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* up to date */}
|
{/* up to date */}
|
||||||
{state.status === "upToDate" && (
|
{state.status === "upToDate" && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
|
||||||
36
src/services/licenseService.ts
Normal file
36
src/services/licenseService.ts
Normal 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 });
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue