@mostajs/licensing-ui-react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,504 @@
1
+ "use client";
2
+ import { useEffect, useState, useCallback } from "react";
3
+ import { Shield, CheckCircle, XCircle, AlertTriangle, Download, RefreshCw, Loader2 } from "lucide-react";
4
+ import { useLicenseUi } from "./LicenseProvider";
5
+
6
+ interface Props {
7
+ children: React.ReactNode;
8
+ project?: string;
9
+ version?: string;
10
+ gracePeriod?: number; // Jours de grâce offline
11
+ }
12
+
13
+ interface LicenseInfo {
14
+ valid: boolean;
15
+ reason?: string;
16
+ license?: {
17
+ key: string;
18
+ email: string;
19
+ project: string;
20
+ expires_at: string;
21
+ activations: number;
22
+ max_activations: number;
23
+ };
24
+ remaining_days?: number;
25
+ }
26
+
27
+ export default function LicenseGate({
28
+ children,
29
+ project: projectProp,
30
+ version = "1.0.0",
31
+ gracePeriod = 3
32
+ }: Props) {
33
+ const { serverUrl, project: ctxProject } = useLicenseUi(); // config .env (createLicenseUiFromEnv)
34
+ const project = projectProp ?? ctxProject ?? "app";
35
+ const [status, setStatus] = useState<"checking" | "valid" | "invalid" | "requesting" | "expired" | "warning">("checking");
36
+ const [email, setEmail] = useState("");
37
+ const [company, setCompany] = useState("");
38
+ const [error, setError] = useState<string | null>(null);
39
+ const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
40
+ const [retryCount, setRetryCount] = useState(0);
41
+ const [isOffline, setIsOffline] = useState(false);
42
+ const [lastOnlineCheck, setLastOnlineCheck] = useState<Date | null>(null);
43
+
44
+ // Vérification de la connectivité
45
+ const checkConnectivity = useCallback(async () => {
46
+ try {
47
+ const response = await fetch("/api/license/ping", {
48
+ method: "GET",
49
+ cache: "no-cache"
50
+ });
51
+ return response.ok;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }, []);
56
+
57
+ // Vérification offline (cache local + période de grâce)
58
+ const checkOfflineLicense = useCallback(() => {
59
+ const offlineData = localStorage.getItem("license_cache");
60
+ if (!offlineData) return false;
61
+
62
+ try {
63
+ const cached = JSON.parse(offlineData);
64
+ const cacheDate = new Date(cached.timestamp);
65
+ const now = new Date();
66
+ const diffDays = Math.floor((now.getTime() - cacheDate.getTime()) / (1000 * 60 * 60 * 24));
67
+
68
+ if (diffDays <= gracePeriod && cached.valid) {
69
+ setLicenseInfo(cached.licenseInfo);
70
+ setLastOnlineCheck(cacheDate);
71
+ return true;
72
+ }
73
+ } catch (e) {
74
+ console.warn("Cache licence corrompu :", e);
75
+ localStorage.removeItem("license_cache");
76
+ }
77
+ return false;
78
+ }, [gracePeriod]);
79
+
80
+ const verifyLicense = useCallback(async (force = false) => {
81
+ try {
82
+ const isOnline = await checkConnectivity();
83
+
84
+ if (!isOnline && !force) {
85
+ setIsOffline(true);
86
+ if (checkOfflineLicense()) {
87
+ setStatus("warning"); // Mode offline avec licence valide
88
+ return;
89
+ } else {
90
+ setStatus("invalid");
91
+ setError("Aucune connexion et licence locale expirée");
92
+ return;
93
+ }
94
+ }
95
+
96
+ setIsOffline(false);
97
+
98
+ // Essayer d'abord l'API Node.js
99
+ let response = await fetch("/api/license/verify");
100
+ let data: LicenseInfo = await response.json();
101
+
102
+ // Si la vérification Node.js échoue, essayer l'API Python
103
+ if (!data.valid && data.reason === "signature") {
104
+ console.log("🐍 Fallback vers vérification Python...");
105
+ try {
106
+ response = await fetch("/api/license/verify-python");
107
+ if (response.ok) {
108
+ data = await response.json();
109
+ console.log("✅ Vérification Python réussie:", data);
110
+ }
111
+ } catch (error) {
112
+ console.warn("⚠️ Fallback Python échoué:", error);
113
+ }
114
+ }
115
+
116
+ setLicenseInfo(data);
117
+
118
+ if (data.valid) {
119
+ setStatus("valid");
120
+ // Mise à jour du cache local
121
+ localStorage.setItem("license_cache", JSON.stringify({
122
+ licenseInfo: data,
123
+ timestamp: new Date().toISOString(),
124
+ valid: true
125
+ }));
126
+ setLastOnlineCheck(new Date());
127
+ } else {
128
+ setStatus("invalid");
129
+ localStorage.removeItem("license_cache");
130
+
131
+ if (data.reason === "expired") {
132
+ setStatus("expired");
133
+ setError(`Licence expirée depuis ${data.remaining_days ? Math.abs(data.remaining_days) : '?'} jours`);
134
+ } else if (data.reason === "signature") {
135
+ setError("Signature invalide - Fichier de licence corrompu");
136
+ } else if (data.reason === "not_found") {
137
+ setError("Aucune licence trouvée");
138
+ } else {
139
+ setError(data.reason || "Licence invalide");
140
+ }
141
+ }
142
+ } catch (e) {
143
+ console.error("Erreur vérification licence :", e);
144
+
145
+ // Tentative offline en cas d'erreur réseau
146
+ if (checkOfflineLicense()) {
147
+ setStatus("warning");
148
+ setIsOffline(true);
149
+ } else {
150
+ setStatus("invalid");
151
+ setError("Erreur lors de la vérification de licence");
152
+ }
153
+ }
154
+ }, [checkConnectivity, checkOfflineLicense]);
155
+
156
+ useEffect(() => {
157
+ verifyLicense();
158
+
159
+ // Vérification périodique (toutes les 24h)
160
+ const interval = setInterval(() => {
161
+ verifyLicense();
162
+ }, 24 * 60 * 60 * 1000);
163
+
164
+ return () => clearInterval(interval);
165
+ }, [verifyLicense]);
166
+
167
+ const requestLicense = async () => {
168
+ if (!email.trim()) {
169
+ setError("Email requis");
170
+ return;
171
+ }
172
+
173
+ if (!/\S+@\S+\.\S+/.test(email)) {
174
+ setError("Format d'email invalide");
175
+ return;
176
+ }
177
+
178
+ setStatus("requesting");
179
+ setError(null);
180
+
181
+ try {
182
+ const response = await fetch("/api/license/request", {
183
+ method: "POST",
184
+ headers: {
185
+ "Content-Type": "application/json",
186
+ },
187
+ body: JSON.stringify({
188
+ email: email.trim(),
189
+ company: company.trim(),
190
+ project,
191
+ version
192
+ }),
193
+ });
194
+
195
+ const data = await response.json();
196
+
197
+ if (data.success) {
198
+ // Attendre un peu avant la vérification pour laisser le temps au serveur
199
+ setTimeout(() => {
200
+ verifyLicense(true);
201
+ }, 1000);
202
+ } else {
203
+ setError(data.error || "Erreur lors de la demande de licence");
204
+ setStatus("invalid");
205
+ }
206
+ } catch (e) {
207
+ console.error("Erreur demande licence :", e);
208
+ setError("Erreur réseau lors de la demande de licence");
209
+ setStatus("invalid");
210
+ }
211
+ };
212
+
213
+ const handleRetry = () => {
214
+ setRetryCount(prev => prev + 1);
215
+ setError(null);
216
+ verifyLicense(true);
217
+ };
218
+
219
+ const downloadLicense = () => {
220
+ // Trigger download of license file
221
+ const link = document.createElement('a');
222
+ link.href = '/api/license/download';
223
+ link.download = `${project}_licence.json`;
224
+ document.body.appendChild(link);
225
+ link.click();
226
+ document.body.removeChild(link);
227
+ };
228
+
229
+ // États d'affichage
230
+ if (status === "checking") {
231
+ return (
232
+ <div className="flex items-center justify-center min-h-screen bg-gray-50">
233
+ <div className="text-center">
234
+ <Loader2 className="w-8 h-8 animate-spin text-blue-500 mx-auto mb-4" />
235
+ <p className="text-lg text-gray-700">Vérification de la licence...</p>
236
+ <p className="text-sm text-gray-500 mt-2">Connexion au serveur de licences</p>
237
+ </div>
238
+ </div>
239
+ );
240
+ }
241
+
242
+ if (status === "requesting") {
243
+ return (
244
+ <div className="flex items-center justify-center min-h-screen bg-gray-50">
245
+ <div className="text-center">
246
+ <Loader2 className="w-8 h-8 animate-spin text-green-500 mx-auto mb-4" />
247
+ <p className="text-lg text-gray-700">Demande de licence en cours...</p>
248
+ <p className="text-sm text-gray-500 mt-2">Génération de votre licence personnalisée</p>
249
+ </div>
250
+ </div>
251
+ );
252
+ }
253
+
254
+ // Mode warning (offline avec licence valide)
255
+ if (status === "warning") {
256
+ return (
257
+ <div className="relative">
258
+ {/* Bandeau d'avertissement */}
259
+ <div className="bg-yellow-50 border-l-4 border-yellow-400 p-4">
260
+ <div className="flex items-center">
261
+ <AlertTriangle className="w-5 h-5 text-yellow-400 mr-3" />
262
+ <div className="flex-1">
263
+ <p className="text-sm text-yellow-700">
264
+ Mode hors ligne - Licence valide localement
265
+ {lastOnlineCheck && (
266
+ <span className="block text-xs text-yellow-600">
267
+ Dernière vérification : {lastOnlineCheck.toLocaleDateString()}
268
+ </span>
269
+ )}
270
+ </p>
271
+ </div>
272
+ <button
273
+ onClick={() => verifyLicense(true)}
274
+ className="text-yellow-600 hover:text-yellow-800"
275
+ title="Tenter une reconnexion"
276
+ >
277
+ <RefreshCw className="w-4 h-4" />
278
+ </button>
279
+ </div>
280
+ </div>
281
+ {children}
282
+ </div>
283
+ );
284
+ }
285
+
286
+ // Mode licence expirée avec option de renouvellement
287
+ if (status === "expired") {
288
+ return (
289
+ <div className="flex items-center justify-center min-h-screen bg-gray-50">
290
+ <div className="max-w-lg p-8 bg-white rounded-xl shadow-lg border border-red-200">
291
+ <div className="text-center mb-6">
292
+ <XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
293
+ <h2 className="text-2xl font-bold text-red-600 mb-2">Licence expirée</h2>
294
+ <p className="text-gray-600">{error}</p>
295
+ </div>
296
+
297
+ {licenseInfo?.license && (
298
+ <div className="bg-red-50 p-4 rounded-lg mb-6">
299
+ <h3 className="font-semibold text-red-800 mb-2">Informations de licence</h3>
300
+ <div className="text-sm text-red-700 space-y-1">
301
+ <p><strong>Email :</strong> {licenseInfo.license.email}</p>
302
+ <p><strong>Projet :</strong> {licenseInfo.license.project}</p>
303
+ <p><strong>Clé :</strong> {licenseInfo.license.key}</p>
304
+ <p><strong>Expiré le :</strong> {new Date(licenseInfo.license.expires_at).toLocaleDateString()}</p>
305
+ </div>
306
+ </div>
307
+ )}
308
+
309
+ <div className="space-y-4">
310
+ <button
311
+ onClick={() => {
312
+ setEmail(licenseInfo?.license?.email || "");
313
+ setStatus("invalid");
314
+ }}
315
+ className="w-full px-4 py-3 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 font-medium"
316
+ >
317
+ Renouveler la licence
318
+ </button>
319
+ <button
320
+ onClick={handleRetry}
321
+ className="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-500"
322
+ >
323
+ <RefreshCw className="w-4 h-4 inline mr-2" />
324
+ Vérifier à nouveau
325
+ </button>
326
+ </div>
327
+ </div>
328
+ </div>
329
+ );
330
+ }
331
+
332
+ // Formulaire de demande de licence
333
+ if (status === "invalid") {
334
+ return (
335
+ <div className="flex items-center justify-center min-h-screen bg-gray-50">
336
+ <div className="max-w-md p-8 bg-white rounded-xl shadow-lg">
337
+ <div className="text-center mb-6">
338
+ <Shield className="w-16 h-16 text-blue-500 mx-auto mb-4" />
339
+ <h2 className="text-2xl font-bold text-gray-800 mb-2">Activation requise</h2>
340
+ <p className="text-gray-600">Activez {project} avec votre licence</p>
341
+ </div>
342
+
343
+ {error && (
344
+ <div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
345
+ <div className="flex items-center">
346
+ <XCircle className="w-5 h-5 text-red-500 mr-2" />
347
+ <p className="text-red-700 text-sm">{error}</p>
348
+ </div>
349
+ </div>
350
+ )}
351
+
352
+ {isOffline && (
353
+ <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
354
+ <div className="flex items-center">
355
+ <AlertTriangle className="w-5 h-5 text-yellow-500 mr-2" />
356
+ <p className="text-yellow-700 text-sm">Mode hors ligne détecté</p>
357
+ </div>
358
+ </div>
359
+ )}
360
+
361
+ <form onSubmit={(e) => { e.preventDefault(); requestLicense(); }} className="space-y-4">
362
+ <div>
363
+ <label className="block text-sm font-medium text-gray-700 mb-2">
364
+ Email professionnel *
365
+ </label>
366
+ <input
367
+ type="email"
368
+ value={email}
369
+ onChange={(e) => setEmail(e.target.value)}
370
+ placeholder="votre@entreprise.com"
371
+ className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
372
+ required
373
+ />
374
+ </div>
375
+
376
+ <div>
377
+ <label className="block text-sm font-medium text-gray-700 mb-2">
378
+ Entreprise (optionnel)
379
+ </label>
380
+ <input
381
+ type="text"
382
+ value={company}
383
+ onChange={(e) => setCompany(e.target.value)}
384
+ placeholder="Nom de votre entreprise"
385
+ className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
386
+ />
387
+ </div>
388
+
389
+ <div className="bg-blue-50 p-4 rounded-lg">
390
+ <h4 className="font-medium text-blue-800 mb-2">Informations de licence</h4>
391
+ <div className="text-sm text-blue-700 space-y-1">
392
+ <p><strong>Produit :</strong> {project}</p>
393
+ <p><strong>Version :</strong> {version}</p>
394
+ <p><strong>Type :</strong> Licence professionnelle</p>
395
+ </div>
396
+ </div>
397
+
398
+ <button
399
+ type="submit"
400
+ disabled={!email.trim()}
401
+ className="w-full px-4 py-3 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
402
+ >
403
+ Demander une licence
404
+ </button>
405
+
406
+ <div className="text-center space-y-2">
407
+ <button
408
+ type="button"
409
+ onClick={handleRetry}
410
+ className="text-sm text-gray-500 hover:text-gray-700 inline-flex items-center"
411
+ >
412
+ <RefreshCw className="w-4 h-4 mr-1" />
413
+ Vérifier à nouveau ({retryCount + 1})
414
+ </button>
415
+
416
+ {licenseInfo && (
417
+ <button
418
+ type="button"
419
+ onClick={downloadLicense}
420
+ className="block w-full text-sm text-blue-500 hover:text-blue-700"
421
+ >
422
+ <Download className="w-4 h-4 inline mr-1" />
423
+ Télécharger le fichier de licence
424
+ </button>
425
+ )}
426
+ </div>
427
+ </form>
428
+
429
+ <div className="mt-6 pt-6 border-t border-gray-200 text-center">
430
+ <p className="text-xs text-gray-500">
431
+ Une licence sera générée automatiquement et liée à cet appareil.
432
+ <br />
433
+ Contactez le support en cas de problème.
434
+ </p>
435
+ </div>
436
+ </div>
437
+ </div>
438
+ );
439
+ }
440
+
441
+ // Licence valide - Afficher l'application avec bandeau en bas
442
+ return (
443
+ <div className="relative min-h-screen flex flex-col">
444
+ {/* Contenu principal */}
445
+ <div className="flex-1">
446
+ {children}
447
+ </div>
448
+
449
+ {/* Bandeau de licence en bas de page */}
450
+ {licenseInfo?.license && (
451
+ <div className="fixed bottom-0 left-0 right-0 bg-green-50 border-t-4 border-green-400 p-3 text-sm shadow-lg z-50">
452
+ <div className="container mx-auto flex items-center justify-between">
453
+ <div className="flex items-center">
454
+ <CheckCircle className="w-4 h-4 text-green-400 mr-2" />
455
+ <span className="text-green-700">
456
+ <strong>Licence valide</strong> • {licenseInfo.license.email} • {licenseInfo.license.project}
457
+ {licenseInfo.remaining_days !== undefined && (
458
+ <span className={`ml-2 ${licenseInfo.remaining_days < 30 ? 'text-yellow-600' : 'text-green-600'}`}>
459
+ {licenseInfo.remaining_days < 30
460
+ ? `⚠️ Expire dans ${licenseInfo.remaining_days} jours`
461
+ : `✅ ${licenseInfo.remaining_days} jours restants`
462
+ }
463
+ </span>
464
+ )}
465
+ </span>
466
+ </div>
467
+ <div className="flex items-center space-x-3">
468
+ <span className="text-xs text-green-600">
469
+ Clé: {licenseInfo.license.key}
470
+ </span>
471
+ <button
472
+ onClick={() => verifyLicense(true)}
473
+ className="text-green-600 hover:text-green-800 p-1 rounded transition-colors"
474
+ title="Actualiser la licence"
475
+ >
476
+ <RefreshCw className="w-4 h-4" />
477
+ </button>
478
+ <button
479
+ onClick={() => {
480
+ const info = document.getElementById('license-info');
481
+ if (info) info.style.display = info.style.display === 'none' ? 'block' : 'none';
482
+ }}
483
+ className="text-green-600 hover:text-green-800 text-xs px-2 py-1 border border-green-300 rounded"
484
+ title="Voir détails"
485
+ >
486
+ Info
487
+ </button>
488
+ </div>
489
+ </div>
490
+
491
+ {/* Détails de licence (masqués par défaut) */}
492
+ <div id="license-info" className="mt-2 pt-2 border-t border-green-200 text-xs text-green-600" style={{ display: 'none' }}>
493
+ <div className="container mx-auto grid grid-cols-2 md:grid-cols-4 gap-4">
494
+ <div><strong>Activations:</strong> {licenseInfo.license.activations}/{licenseInfo.license.max_activations}</div>
495
+ <div><strong>Expire le:</strong> {new Date(licenseInfo.license.expires_at).toLocaleDateString()}</div>
496
+ <div><strong>Status:</strong> <span className="text-green-600">ACTIVE</span></div>
497
+ <div><strong>Dernière vérif:</strong> {lastOnlineCheck?.toLocaleTimeString() || 'Maintenant'}</div>
498
+ </div>
499
+ </div>
500
+ </div>
501
+ )}
502
+ </div>
503
+ );
504
+ }
@@ -0,0 +1,36 @@
1
+ "use client";
2
+ // @mostajs/licensing-ui — contexte React alimenté par createLicenseUiFromEnv.
3
+ // Centralise la CONFIG .env (serveur, projet, clé publique) + le contrôleur composant
4
+ // @mostajs/licensing. Les composants (LicenseGate, LicenseActivationForm…) le consomment via
5
+ // useLicenseUi() au lieu d'URLs/projet codés en dur. @author Dr Hamid MADANI <drmdh@msn.com>
6
+ import React, { createContext, useContext, useMemo } from "react";
7
+ import { createLicenseUi, licenseUiConfigFromEnv, type LicenseUiOptions } from "../index";
8
+
9
+ export interface LicenseContextValue {
10
+ serverUrl: string;
11
+ project?: string;
12
+ ui: ReturnType<typeof createLicenseUi>;
13
+ }
14
+
15
+ const LicenseCtx = createContext<LicenseContextValue | null>(null);
16
+
17
+ /**
18
+ * Fournit la config de licence à l'arbre React.
19
+ * <LicenseProvider env={publicEnv}> … </LicenseProvider>
20
+ * `env` : objet de variables (NEXT_PUBLIC_LICENSE_* / LICENSE_*) ; à défaut, lit process.env.
21
+ * `config` : surcharge directe (LicenseUiOptions) si fournie.
22
+ */
23
+ export function LicenseProvider({ config, env, children }: { config?: LicenseUiOptions; env?: Record<string, string | undefined>; children: React.ReactNode }) {
24
+ const value = useMemo<LicenseContextValue>(() => {
25
+ const opts = config ?? licenseUiConfigFromEnv(env ?? (typeof process !== "undefined" ? (process.env as any) : {}));
26
+ return { serverUrl: opts.baseUrl, project: opts.project, ui: createLicenseUi(opts) };
27
+ }, [config, env]);
28
+ return <LicenseCtx.Provider value={value}>{children}</LicenseCtx.Provider>;
29
+ }
30
+
31
+ /** Accès à la config + contrôleur de licence (compose @mostajs/licensing). */
32
+ export function useLicenseUi(): LicenseContextValue {
33
+ const v = useContext(LicenseCtx);
34
+ if (!v) throw new Error("useLicenseUi: entourez vos composants de <LicenseProvider> (config via createLicenseUiFromEnv).");
35
+ return v;
36
+ }