@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.
- package/README.md +31 -0
- package/llms.txt +43 -0
- package/package.json +38 -0
- package/src/index.ts +67 -0
- package/src/lib/license-service.ts +360 -0
- package/src/lib/licenseClient.ts +76 -0
- package/src/react/EnhancedLicenseGate.tsx +488 -0
- package/src/react/LicenseActivationForm.tsx +365 -0
- package/src/react/LicenseConfigForm.tsx +243 -0
- package/src/react/LicenseGate.tsx +504 -0
- package/src/react/LicenseProvider.tsx +36 -0
- package/src/react/PendingLicenseActivation.tsx +270 -0
- package/src/react/SimpleLicenseActivation.tsx +273 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState, useRef } from 'react';
|
|
3
|
+
import { Upload, Shield, CheckCircle, XCircle, AlertTriangle, Loader2, Eye, EyeOff } from 'lucide-react';
|
|
4
|
+
import { useLicenseUi } from './LicenseProvider';
|
|
5
|
+
|
|
6
|
+
interface LicenseActivationFormProps {
|
|
7
|
+
onActivationSuccess?: (licenseInfo: any) => void;
|
|
8
|
+
onBack?: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ActivationResponse {
|
|
12
|
+
success: boolean;
|
|
13
|
+
message?: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
licenseInfo?: {
|
|
16
|
+
email: string;
|
|
17
|
+
project: string;
|
|
18
|
+
expires_at: string;
|
|
19
|
+
activations: number;
|
|
20
|
+
max_activations: number;
|
|
21
|
+
key: string;
|
|
22
|
+
};
|
|
23
|
+
remaining_days?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function LicenseActivationForm({
|
|
27
|
+
onActivationSuccess,
|
|
28
|
+
onBack
|
|
29
|
+
}: LicenseActivationFormProps) {
|
|
30
|
+
const { serverUrl } = useLicenseUi(); // config .env (createLicenseUiFromEnv)
|
|
31
|
+
const [activationCode, setActivationCode] = useState('');
|
|
32
|
+
const [licenseFile, setLicenseFile] = useState<File | null>(null);
|
|
33
|
+
const [loading, setLoading] = useState(false);
|
|
34
|
+
const [error, setError] = useState<string | null>(null);
|
|
35
|
+
const [success, setSuccess] = useState<string | null>(null);
|
|
36
|
+
const [showCode, setShowCode] = useState(false);
|
|
37
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
38
|
+
|
|
39
|
+
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
40
|
+
const file = event.target.files?.[0];
|
|
41
|
+
if (file) {
|
|
42
|
+
// Vérifier l'extension
|
|
43
|
+
if (!file.name.endsWith('.json')) {
|
|
44
|
+
setError('Veuillez sélectionner un fichier de licence valide (.json)');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Vérifier la taille (max 1MB)
|
|
49
|
+
if (file.size > 1024 * 1024) {
|
|
50
|
+
setError('Le fichier de licence est trop volumineux (max 1MB)');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setLicenseFile(file);
|
|
55
|
+
setError(null);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
const file = event.dataTransfer.files[0];
|
|
62
|
+
if (file) {
|
|
63
|
+
if (!file.name.endsWith('.json')) {
|
|
64
|
+
setError('Veuillez déposer un fichier de licence valide (.json)');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
setLicenseFile(file);
|
|
68
|
+
setError(null);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const validateActivationCode = (code: string): boolean => {
|
|
77
|
+
// Format attendu: XXXX-XXXX-XXXX-XXXX (16 caractères + 3 tirets)
|
|
78
|
+
const codeRegex = /^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
|
|
79
|
+
return codeRegex.test(code.toUpperCase());
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const handleActivation = async () => {
|
|
83
|
+
if (!licenseFile) {
|
|
84
|
+
setError('Veuillez sélectionner un fichier de licence');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!activationCode.trim()) {
|
|
89
|
+
setError('Veuillez entrer votre code d\'activation');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!validateActivationCode(activationCode)) {
|
|
94
|
+
setError('Format de code d\'activation invalide (attendu: XXXX-XXXX-XXXX-XXXX)');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setLoading(true);
|
|
99
|
+
setError(null);
|
|
100
|
+
setSuccess(null);
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
// Lire le fichier de licence
|
|
104
|
+
const licenseContent = await licenseFile.text();
|
|
105
|
+
let licenseData;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
licenseData = JSON.parse(licenseContent);
|
|
109
|
+
} catch {
|
|
110
|
+
setError('Le fichier de licence n\'est pas au format JSON valide');
|
|
111
|
+
setLoading(false);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Préparer les données d'activation
|
|
116
|
+
const formData = new FormData();
|
|
117
|
+
formData.append('activationCode', activationCode.toUpperCase());
|
|
118
|
+
formData.append('licenseData', JSON.stringify(licenseData));
|
|
119
|
+
|
|
120
|
+
// Envoyer la demande d'activation
|
|
121
|
+
const response = await fetch(`${serverUrl}/api/activate`, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
body: formData,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const result: ActivationResponse = await response.json();
|
|
127
|
+
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
throw new Error(result.error || 'Erreur lors de l\'activation');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (result.success) {
|
|
133
|
+
setSuccess('Licence activée avec succès !');
|
|
134
|
+
|
|
135
|
+
// Sauvegarder localement les informations de licence
|
|
136
|
+
if (result.licenseInfo) {
|
|
137
|
+
localStorage.setItem('license_cache', JSON.stringify({
|
|
138
|
+
licenseInfo: result,
|
|
139
|
+
timestamp: new Date().toISOString(),
|
|
140
|
+
valid: true
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
// Sauvegarder aussi le fichier de licence localement pour référence future
|
|
144
|
+
await fetch('/api/license/save-local', {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: {
|
|
147
|
+
'Content-Type': 'application/json',
|
|
148
|
+
},
|
|
149
|
+
body: JSON.stringify({
|
|
150
|
+
licenseData,
|
|
151
|
+
activationCode: activationCode.toUpperCase()
|
|
152
|
+
}),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Callback de succès après un court délai
|
|
157
|
+
setTimeout(() => {
|
|
158
|
+
onActivationSuccess?.(result.licenseInfo);
|
|
159
|
+
}, 1500);
|
|
160
|
+
} else {
|
|
161
|
+
setError(result.message || result.error || 'Échec de l\'activation');
|
|
162
|
+
}
|
|
163
|
+
} catch (err: any) {
|
|
164
|
+
console.error('Erreur d\'activation:', err);
|
|
165
|
+
if (err.name === 'TypeError' && err.message.includes('fetch')) {
|
|
166
|
+
setError('Impossible de se connecter au serveur de licences. Vérifiez votre connexion internet.');
|
|
167
|
+
} else {
|
|
168
|
+
setError(err.message || 'Erreur lors de l\'activation de la licence');
|
|
169
|
+
}
|
|
170
|
+
} finally {
|
|
171
|
+
setLoading(false);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const clearForm = () => {
|
|
176
|
+
setActivationCode('');
|
|
177
|
+
setLicenseFile(null);
|
|
178
|
+
setError(null);
|
|
179
|
+
setSuccess(null);
|
|
180
|
+
if (fileInputRef.current) {
|
|
181
|
+
fileInputRef.current.value = '';
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<div className="max-w-lg mx-auto p-8 bg-white rounded-xl shadow-lg">
|
|
187
|
+
<div className="text-center mb-8">
|
|
188
|
+
<Shield className="w-16 h-16 text-blue-500 mx-auto mb-4" />
|
|
189
|
+
<h2 className="text-2xl font-bold text-gray-800 mb-2">Activer votre licence</h2>
|
|
190
|
+
<p className="text-gray-600">
|
|
191
|
+
Uploadez votre fichier de licence et entrez votre code d'activation
|
|
192
|
+
</p>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
{/* Messages d'état */}
|
|
196
|
+
{error && (
|
|
197
|
+
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
|
198
|
+
<div className="flex items-center">
|
|
199
|
+
<XCircle className="w-5 h-5 text-red-500 mr-2 flex-shrink-0" />
|
|
200
|
+
<p className="text-red-700 text-sm">{error}</p>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
{success && (
|
|
206
|
+
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
|
207
|
+
<div className="flex items-center">
|
|
208
|
+
<CheckCircle className="w-5 h-5 text-green-500 mr-2 flex-shrink-0" />
|
|
209
|
+
<p className="text-green-700 text-sm">{success}</p>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{/* Zone d'upload de fichier */}
|
|
215
|
+
<div className="mb-6">
|
|
216
|
+
<label className="block text-sm font-medium text-gray-700 mb-3">
|
|
217
|
+
Fichier de licence *
|
|
218
|
+
</label>
|
|
219
|
+
<div
|
|
220
|
+
onDrop={handleDrop}
|
|
221
|
+
onDragOver={handleDragOver}
|
|
222
|
+
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
|
223
|
+
licenseFile
|
|
224
|
+
? 'border-green-300 bg-green-50'
|
|
225
|
+
: 'border-gray-300 hover:border-blue-400 hover:bg-blue-50'
|
|
226
|
+
}`}
|
|
227
|
+
onClick={() => fileInputRef.current?.click()}
|
|
228
|
+
>
|
|
229
|
+
<input
|
|
230
|
+
ref={fileInputRef}
|
|
231
|
+
type="file"
|
|
232
|
+
accept=".json"
|
|
233
|
+
onChange={handleFileSelect}
|
|
234
|
+
className="hidden"
|
|
235
|
+
/>
|
|
236
|
+
|
|
237
|
+
{licenseFile ? (
|
|
238
|
+
<div className="flex items-center justify-center">
|
|
239
|
+
<CheckCircle className="w-8 h-8 text-green-500 mr-3" />
|
|
240
|
+
<div className="text-left">
|
|
241
|
+
<p className="text-sm font-medium text-green-700">{licenseFile.name}</p>
|
|
242
|
+
<p className="text-xs text-green-600">
|
|
243
|
+
{(licenseFile.size / 1024).toFixed(1)} KB
|
|
244
|
+
</p>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
) : (
|
|
248
|
+
<div>
|
|
249
|
+
<Upload className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
|
250
|
+
<p className="text-sm text-gray-600 mb-2">
|
|
251
|
+
<span className="font-medium">Cliquez pour sélectionner</span> ou glissez votre fichier de licence
|
|
252
|
+
</p>
|
|
253
|
+
<p className="text-xs text-gray-400">
|
|
254
|
+
Formats supportés: .json (max 1MB)
|
|
255
|
+
</p>
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
{/* Code d'activation */}
|
|
262
|
+
<div className="mb-6">
|
|
263
|
+
<label className="block text-sm font-medium text-gray-700 mb-3">
|
|
264
|
+
Code d'activation *
|
|
265
|
+
</label>
|
|
266
|
+
<div className="relative">
|
|
267
|
+
<input
|
|
268
|
+
type={showCode ? 'text' : 'password'}
|
|
269
|
+
value={activationCode}
|
|
270
|
+
onChange={(e) => {
|
|
271
|
+
// Auto-format with dashes
|
|
272
|
+
let value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
273
|
+
if (value.length > 4) value = value.slice(0, 4) + '-' + value.slice(4);
|
|
274
|
+
if (value.length > 9) value = value.slice(0, 9) + '-' + value.slice(9);
|
|
275
|
+
if (value.length > 14) value = value.slice(0, 14) + '-' + value.slice(14);
|
|
276
|
+
if (value.length > 19) value = value.slice(0, 19);
|
|
277
|
+
setActivationCode(value);
|
|
278
|
+
}}
|
|
279
|
+
placeholder="XXXX-XXXX-XXXX-XXXX"
|
|
280
|
+
maxLength={19}
|
|
281
|
+
className="w-full px-4 py-3 pr-12 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-center tracking-widest"
|
|
282
|
+
/>
|
|
283
|
+
<button
|
|
284
|
+
type="button"
|
|
285
|
+
onClick={() => setShowCode(!showCode)}
|
|
286
|
+
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
287
|
+
>
|
|
288
|
+
{showCode ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
|
289
|
+
</button>
|
|
290
|
+
</div>
|
|
291
|
+
<p className="text-xs text-gray-500 mt-2">
|
|
292
|
+
Entrez le code fourni avec votre licence (format: XXXX-XXXX-XXXX-XXXX)
|
|
293
|
+
</p>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
{/* Informations importantes */}
|
|
297
|
+
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
|
298
|
+
<div className="flex items-start">
|
|
299
|
+
<AlertTriangle className="w-5 h-5 text-blue-500 mr-2 mt-0.5 flex-shrink-0" />
|
|
300
|
+
<div>
|
|
301
|
+
<h4 className="text-sm font-medium text-blue-800 mb-2">Où trouver vos éléments de licence ?</h4>
|
|
302
|
+
<div className="text-xs text-blue-700 space-y-1">
|
|
303
|
+
<p>• <strong>Fichier de licence :</strong> Téléchargé depuis votre espace client sur licences.amia.fr</p>
|
|
304
|
+
<p>• <strong>Code d'activation :</strong> Fourni par email lors de l'achat de votre licence</p>
|
|
305
|
+
<p>• <strong>Connexion requise :</strong> Internet nécessaire pour l'activation initiale</p>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
{/* Boutons d'action */}
|
|
312
|
+
<div className="space-y-3">
|
|
313
|
+
<button
|
|
314
|
+
onClick={handleActivation}
|
|
315
|
+
disabled={!licenseFile || !activationCode.trim() || loading}
|
|
316
|
+
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 flex items-center justify-center"
|
|
317
|
+
>
|
|
318
|
+
{loading ? (
|
|
319
|
+
<>
|
|
320
|
+
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
|
321
|
+
Activation en cours...
|
|
322
|
+
</>
|
|
323
|
+
) : (
|
|
324
|
+
'Activer la licence'
|
|
325
|
+
)}
|
|
326
|
+
</button>
|
|
327
|
+
|
|
328
|
+
<div className="flex space-x-3">
|
|
329
|
+
<button
|
|
330
|
+
onClick={clearForm}
|
|
331
|
+
disabled={loading}
|
|
332
|
+
className="flex-1 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 transition-colors"
|
|
333
|
+
>
|
|
334
|
+
Effacer
|
|
335
|
+
</button>
|
|
336
|
+
|
|
337
|
+
{onBack && (
|
|
338
|
+
<button
|
|
339
|
+
onClick={onBack}
|
|
340
|
+
disabled={loading}
|
|
341
|
+
className="flex-1 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 transition-colors"
|
|
342
|
+
>
|
|
343
|
+
Retour
|
|
344
|
+
</button>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
{/* Lien vers le site de licences */}
|
|
350
|
+
<div className="mt-6 pt-6 border-t border-gray-200 text-center">
|
|
351
|
+
<p className="text-xs text-gray-500 mb-2">
|
|
352
|
+
Vous n'avez pas encore de licence ?
|
|
353
|
+
</p>
|
|
354
|
+
<a
|
|
355
|
+
href={serverUrl}
|
|
356
|
+
target="_blank"
|
|
357
|
+
rel="noopener noreferrer"
|
|
358
|
+
className="text-sm text-blue-500 hover:text-blue-700 font-medium"
|
|
359
|
+
>
|
|
360
|
+
Visiter licences.amia.fr →
|
|
361
|
+
</a>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
);
|
|
365
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { Save, RefreshCw, AlertCircle, CheckCircle, Users } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
interface LicenseInfo {
|
|
7
|
+
valid: boolean;
|
|
8
|
+
license?: {
|
|
9
|
+
key: string;
|
|
10
|
+
email: string;
|
|
11
|
+
project: string;
|
|
12
|
+
expires_at: string;
|
|
13
|
+
activations: any[];
|
|
14
|
+
max_activations: number;
|
|
15
|
+
};
|
|
16
|
+
remaining_days?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function LicenseConfigForm() {
|
|
20
|
+
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
|
|
21
|
+
const [maxActivations, setMaxActivations] = useState<number>(4);
|
|
22
|
+
const [loading, setLoading] = useState(true);
|
|
23
|
+
const [saving, setSaving] = useState(false);
|
|
24
|
+
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
25
|
+
|
|
26
|
+
// Charger les informations de licence actuelles
|
|
27
|
+
const fetchLicenseInfo = async () => {
|
|
28
|
+
try {
|
|
29
|
+
setLoading(true);
|
|
30
|
+
const response = await fetch('/api/license/verify');
|
|
31
|
+
const data = await response.json();
|
|
32
|
+
setLicenseInfo(data);
|
|
33
|
+
|
|
34
|
+
if (data.license) {
|
|
35
|
+
setMaxActivations(data.license.max_activations || 4);
|
|
36
|
+
}
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error('Erreur lors du chargement de la licence:', error);
|
|
39
|
+
setMessage({ type: 'error', text: 'Erreur lors du chargement des informations de licence' });
|
|
40
|
+
} finally {
|
|
41
|
+
setLoading(false);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
fetchLicenseInfo();
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const handleSave = async () => {
|
|
50
|
+
setSaving(true);
|
|
51
|
+
setMessage(null);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const response = await fetch('/api/admin/license/update-max-activations', {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: {
|
|
57
|
+
'Content-Type': 'application/json',
|
|
58
|
+
},
|
|
59
|
+
body: JSON.stringify({
|
|
60
|
+
max_activations: maxActivations
|
|
61
|
+
})
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const result = await response.json();
|
|
65
|
+
|
|
66
|
+
if (result.success) {
|
|
67
|
+
setMessage({ type: 'success', text: 'Configuration mise à jour avec succès' });
|
|
68
|
+
await fetchLicenseInfo(); // Recharger les informations
|
|
69
|
+
} else {
|
|
70
|
+
setMessage({ type: 'error', text: result.error || 'Erreur lors de la mise à jour' });
|
|
71
|
+
}
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error('Erreur lors de la sauvegarde:', error);
|
|
74
|
+
setMessage({ type: 'error', text: 'Erreur réseau lors de la sauvegarde' });
|
|
75
|
+
} finally {
|
|
76
|
+
setSaving(false);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
if (loading) {
|
|
81
|
+
return (
|
|
82
|
+
<div className="flex justify-center items-center py-12">
|
|
83
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="space-y-6">
|
|
90
|
+
{/* Message de statut */}
|
|
91
|
+
{message && (
|
|
92
|
+
<div className={`p-4 rounded-lg border flex items-center gap-3 ${
|
|
93
|
+
message.type === 'success'
|
|
94
|
+
? 'bg-green-50 border-green-200 text-green-800'
|
|
95
|
+
: 'bg-red-50 border-red-200 text-red-800'
|
|
96
|
+
}`}>
|
|
97
|
+
{message.type === 'success' ? (
|
|
98
|
+
<CheckCircle className="w-5 h-5" />
|
|
99
|
+
) : (
|
|
100
|
+
<AlertCircle className="w-5 h-5" />
|
|
101
|
+
)}
|
|
102
|
+
<span>{message.text}</span>
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
{/* Informations licence actuelle */}
|
|
107
|
+
{licenseInfo && licenseInfo.license && (
|
|
108
|
+
<div className="bg-gray-50 rounded-lg p-6">
|
|
109
|
+
<h3 className="font-semibold text-gray-900 mb-4">Informations Licence Actuelle</h3>
|
|
110
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
|
111
|
+
<div>
|
|
112
|
+
<span className="font-medium text-gray-700">Email:</span>
|
|
113
|
+
<span className="ml-2 text-gray-900">{licenseInfo.license.email}</span>
|
|
114
|
+
</div>
|
|
115
|
+
<div>
|
|
116
|
+
<span className="font-medium text-gray-700">Projet:</span>
|
|
117
|
+
<span className="ml-2 text-gray-900">{licenseInfo.license.project}</span>
|
|
118
|
+
</div>
|
|
119
|
+
<div>
|
|
120
|
+
<span className="font-medium text-gray-700">Clé:</span>
|
|
121
|
+
<span className="ml-2 text-gray-900 font-mono text-xs">{licenseInfo.license.key}</span>
|
|
122
|
+
</div>
|
|
123
|
+
<div>
|
|
124
|
+
<span className="font-medium text-gray-700">Expire le:</span>
|
|
125
|
+
<span className="ml-2 text-gray-900">
|
|
126
|
+
{new Date(licenseInfo.license.expires_at).toLocaleDateString()}
|
|
127
|
+
</span>
|
|
128
|
+
</div>
|
|
129
|
+
<div>
|
|
130
|
+
<span className="font-medium text-gray-700">Postes actifs:</span>
|
|
131
|
+
<span className="ml-2 text-gray-900">
|
|
132
|
+
{licenseInfo.license.activations.length} / {licenseInfo.license.max_activations}
|
|
133
|
+
</span>
|
|
134
|
+
</div>
|
|
135
|
+
<div>
|
|
136
|
+
<span className="font-medium text-gray-700">Jours restants:</span>
|
|
137
|
+
<span className={`ml-2 font-medium ${
|
|
138
|
+
(licenseInfo.remaining_days || 0) < 30 ? 'text-yellow-600' : 'text-green-600'
|
|
139
|
+
}`}>
|
|
140
|
+
{licenseInfo.remaining_days || 0} jours
|
|
141
|
+
</span>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{/* Configuration du nombre maximum de postes */}
|
|
148
|
+
<div className="space-y-4">
|
|
149
|
+
<div>
|
|
150
|
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
151
|
+
Nombre maximum de postes autorisés
|
|
152
|
+
</label>
|
|
153
|
+
<div className="flex items-center gap-4">
|
|
154
|
+
<div className="flex-1 max-w-xs">
|
|
155
|
+
<input
|
|
156
|
+
type="number"
|
|
157
|
+
min="1"
|
|
158
|
+
max="10"
|
|
159
|
+
value={maxActivations}
|
|
160
|
+
onChange={(e) => setMaxActivations(Math.max(1, Math.min(10, parseInt(e.target.value) || 1)))}
|
|
161
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
162
|
+
/>
|
|
163
|
+
</div>
|
|
164
|
+
<div className="text-sm text-gray-500">
|
|
165
|
+
<Users className="w-4 h-4 inline mr-1" />
|
|
166
|
+
postes maximum
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
<p className="text-sm text-gray-600 mt-2">
|
|
170
|
+
Cette valeur détermine combien de postes clients peuvent être activés simultanément avec cette licence.
|
|
171
|
+
Recommandé: 4 postes pour un usage normal.
|
|
172
|
+
</p>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
{/* Indicateur d'utilisation */}
|
|
176
|
+
{licenseInfo && licenseInfo.license && (
|
|
177
|
+
<div className="bg-blue-50 rounded-lg p-4">
|
|
178
|
+
<h4 className="font-medium text-blue-900 mb-2">Utilisation actuelle</h4>
|
|
179
|
+
<div className="flex items-center gap-4">
|
|
180
|
+
<div className="flex-1">
|
|
181
|
+
<div className="w-full bg-blue-200 rounded-full h-2">
|
|
182
|
+
<div
|
|
183
|
+
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
|
184
|
+
style={{
|
|
185
|
+
width: `${Math.min(100, (licenseInfo.license.activations.length / maxActivations) * 100)}%`
|
|
186
|
+
}}
|
|
187
|
+
></div>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
<div className="text-sm text-blue-700 font-medium">
|
|
191
|
+
{licenseInfo.license.activations.length} / {maxActivations}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
<p className="text-sm text-blue-600 mt-2">
|
|
195
|
+
{maxActivations - licenseInfo.license.activations.length} postes disponibles
|
|
196
|
+
</p>
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
{/* Actions */}
|
|
201
|
+
<div className="flex gap-4 pt-4">
|
|
202
|
+
<button
|
|
203
|
+
onClick={handleSave}
|
|
204
|
+
disabled={saving}
|
|
205
|
+
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
|
|
206
|
+
>
|
|
207
|
+
{saving ? (
|
|
208
|
+
<RefreshCw className="w-4 h-4 animate-spin" />
|
|
209
|
+
) : (
|
|
210
|
+
<Save className="w-4 h-4" />
|
|
211
|
+
)}
|
|
212
|
+
{saving ? 'Sauvegarde...' : 'Sauvegarder'}
|
|
213
|
+
</button>
|
|
214
|
+
|
|
215
|
+
<button
|
|
216
|
+
onClick={fetchLicenseInfo}
|
|
217
|
+
disabled={loading}
|
|
218
|
+
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 flex items-center gap-2"
|
|
219
|
+
>
|
|
220
|
+
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
|
221
|
+
Actualiser
|
|
222
|
+
</button>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{/* Avertissement */}
|
|
226
|
+
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
|
227
|
+
<div className="flex items-start gap-3">
|
|
228
|
+
<AlertCircle className="w-5 h-5 text-yellow-600 mt-0.5" />
|
|
229
|
+
<div className="text-sm text-yellow-800">
|
|
230
|
+
<p className="font-medium mb-1">Important:</p>
|
|
231
|
+
<ul className="space-y-1 list-disc list-inside">
|
|
232
|
+
<li>Cette modification affecte le nombre total de postes pouvant utiliser l'application</li>
|
|
233
|
+
<li>Les postes actuellement connectés ne seront pas déconnectés</li>
|
|
234
|
+
<li>La nouvelle limite s'appliquera aux nouvelles connexions</li>
|
|
235
|
+
<li>En cas de problème, contactez le support technique</li>
|
|
236
|
+
</ul>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
}
|