- Add auth_commands.rs: OAuth2 PKCE flow (start_oauth, handle_auth_callback, refresh_auth_token, get_account_info, check_subscription_status, logout) - Add deep-link handler in lib.rs for simpl-resultat://auth/callback - Add AccountCard.tsx + useAuth hook + authService.ts - Add machine activation commands (activate, deactivate, list, get_activation_status) - Extend LicenseCard with machine management UI - get_edition() now checks account subscription for Premium detection - Daily subscription status check (refresh token if last check > 24h) - Configure CSP for API/auth endpoints - Configure tauri-plugin-deep-link for desktop - Update i18n (FR/EN), changelogs, and architecture docs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
import { useState, useEffect, useCallback } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react";
|
|
import { useLicense } from "../../hooks/useLicense";
|
|
import {
|
|
MachineInfo,
|
|
ActivationStatus,
|
|
activateMachine,
|
|
deactivateMachine,
|
|
listActivatedMachines,
|
|
getActivationStatus,
|
|
} from "../../services/licenseService";
|
|
|
|
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 [showMachines, setShowMachines] = useState(false);
|
|
const [machines, setMachines] = useState<MachineInfo[]>([]);
|
|
const [activation, setActivation] = useState<ActivationStatus | null>(null);
|
|
const [machineLoading, setMachineLoading] = useState(false);
|
|
const [deactivatingId, setDeactivatingId] = useState<string | null>(null);
|
|
const [machineError, setMachineError] = useState<string | null>(null);
|
|
|
|
const hasLicense = state.edition !== "free";
|
|
|
|
const loadActivation = useCallback(async () => {
|
|
if (!hasLicense) return;
|
|
try {
|
|
const status = await getActivationStatus();
|
|
setActivation(status);
|
|
} catch {
|
|
// Ignore — activation status is best-effort
|
|
}
|
|
}, [hasLicense]);
|
|
|
|
const loadMachines = useCallback(async () => {
|
|
setMachineLoading(true);
|
|
setMachineError(null);
|
|
try {
|
|
const list = await listActivatedMachines();
|
|
setMachines(list);
|
|
} catch (e) {
|
|
setMachineError(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setMachineLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadActivation();
|
|
}, [loadActivation]);
|
|
|
|
const handleActivate = async () => {
|
|
setMachineLoading(true);
|
|
setMachineError(null);
|
|
try {
|
|
await activateMachine();
|
|
await loadActivation();
|
|
} catch (e) {
|
|
setMachineError(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setMachineLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDeactivate = async (machineId: string) => {
|
|
setDeactivatingId(machineId);
|
|
try {
|
|
await deactivateMachine(machineId);
|
|
await loadActivation();
|
|
await loadMachines();
|
|
} catch (e) {
|
|
setMachineError(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setDeactivatingId(null);
|
|
}
|
|
};
|
|
|
|
const toggleMachines = async () => {
|
|
const next = !showMachines;
|
|
setShowMachines(next);
|
|
if (next && machines.length === 0) {
|
|
await loadMachines();
|
|
}
|
|
};
|
|
|
|
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>
|
|
)}
|
|
|
|
{hasLicense && (
|
|
<div className="border-t border-[var(--border)] pt-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-medium flex items-center gap-2">
|
|
<Monitor size={16} />
|
|
{t("license.machines.title")}
|
|
</h3>
|
|
<div className="flex items-center gap-2">
|
|
{activation && !activation.is_activated && (
|
|
<button
|
|
type="button"
|
|
onClick={handleActivate}
|
|
disabled={machineLoading}
|
|
className="flex items-center gap-1 px-3 py-1 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-xs disabled:opacity-50"
|
|
>
|
|
{machineLoading && <Loader2 size={12} className="animate-spin" />}
|
|
{t("license.activate")}
|
|
</button>
|
|
)}
|
|
{activation?.is_activated && (
|
|
<span className="flex items-center gap-1 text-xs text-[var(--positive)]">
|
|
<CheckCircle size={12} />
|
|
{t("license.machines.activated")}
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={toggleMachines}
|
|
className="p-1 hover:bg-[var(--border)] rounded transition-colors"
|
|
>
|
|
{showMachines ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{machineError && (
|
|
<div className="flex items-start gap-2 text-xs text-[var(--negative)]">
|
|
<AlertCircle size={14} className="mt-0.5 shrink-0" />
|
|
<p>{machineError}</p>
|
|
</div>
|
|
)}
|
|
|
|
{showMachines && (
|
|
<div className="space-y-2">
|
|
{machineLoading && machines.length === 0 && (
|
|
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
|
|
<Loader2 size={12} className="animate-spin" />
|
|
{t("common.loading")}
|
|
</div>
|
|
)}
|
|
{!machineLoading && machines.length === 0 && (
|
|
<p className="text-xs text-[var(--muted-foreground)]">
|
|
{t("license.machines.noMachines")}
|
|
</p>
|
|
)}
|
|
{machines.map((m) => {
|
|
const isThis = activation?.machine_id === m.machine_id;
|
|
return (
|
|
<div
|
|
key={m.machine_id}
|
|
className="flex items-center justify-between px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm"
|
|
>
|
|
<div>
|
|
<span className="font-medium">
|
|
{m.machine_name || m.machine_id.slice(0, 12)}
|
|
</span>
|
|
{isThis && (
|
|
<span className="ml-2 text-xs text-[var(--positive)]">
|
|
({t("license.machines.thisMachine")})
|
|
</span>
|
|
)}
|
|
<p className="text-xs text-[var(--muted-foreground)]">
|
|
{new Date(m.activated_at).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDeactivate(m.machine_id)}
|
|
disabled={deactivatingId === m.machine_id}
|
|
className="flex items-center gap-1 px-2 py-1 text-xs border border-[var(--border)] rounded hover:bg-[var(--border)] transition-colors disabled:opacity-50"
|
|
>
|
|
{deactivatingId === m.machine_id && (
|
|
<Loader2 size={10} className="animate-spin" />
|
|
)}
|
|
{t("license.machines.deactivate")}
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|