490 lines
18 KiB
TypeScript
490 lines
18 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { listApiKeys, createApiKey, revokeApiKey } from '@/lib/api/api-keys';
|
|
import type { ApiKeyDto, CreateApiKeyResultDto } from '@/lib/api/api-keys';
|
|
import { useSubscription } from '@/lib/context/subscription-context';
|
|
import {
|
|
Key,
|
|
Plus,
|
|
Trash2,
|
|
Copy,
|
|
Check,
|
|
AlertTriangle,
|
|
Clock,
|
|
X,
|
|
ShieldCheck,
|
|
Lock,
|
|
} from 'lucide-react';
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function formatDate(iso: string | null): string {
|
|
if (!iso) return '—';
|
|
return new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(iso));
|
|
}
|
|
|
|
function keyStatusBadge(key: ApiKeyDto) {
|
|
if (!key.isActive) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
|
|
Révoquée
|
|
</span>
|
|
);
|
|
}
|
|
if (key.expiresAt && new Date(key.expiresAt) < new Date()) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
|
|
<Clock className="w-3 h-3" />
|
|
Expirée
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
|
|
Active
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ─── Copy button ─────────────────────────────────────────────────────────────
|
|
|
|
function CopyButton({ text }: { text: string }) {
|
|
const [copied, setCopied] = useState(false);
|
|
const handleCopy = async () => {
|
|
await navigator.clipboard.writeText(text);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
};
|
|
return (
|
|
<button
|
|
onClick={handleCopy}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors"
|
|
>
|
|
{copied ? (
|
|
<>
|
|
<Check className="w-3.5 h-3.5 text-green-500" />
|
|
<span className="text-green-600">Copié</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Copy className="w-3.5 h-3.5 text-gray-400" />
|
|
<span className="text-gray-600">Copier</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ─── Creation success modal ──────────────────────────────────────────────────
|
|
|
|
function CreatedKeyModal({
|
|
result,
|
|
onClose,
|
|
}: {
|
|
result: CreateApiKeyResultDto;
|
|
onClose: () => void;
|
|
}) {
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-6 border-b">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-green-100 flex items-center justify-center">
|
|
<ShieldCheck className="w-5 h-5 text-green-600" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900">Clé API créée</h2>
|
|
<p className="text-sm text-gray-500">{result.name}</p>
|
|
</div>
|
|
</div>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Warning */}
|
|
<div className="mx-6 mt-6 p-4 bg-amber-50 border border-amber-200 rounded-xl flex gap-3">
|
|
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
|
<p className="text-sm text-amber-800">
|
|
<strong>Copiez cette clé maintenant.</strong> Elle ne sera plus jamais affichée après
|
|
la fermeture de cette fenêtre.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Key */}
|
|
<div className="p-6">
|
|
<label className="block text-xs font-medium text-gray-500 mb-2 uppercase tracking-wide">
|
|
Clé API complète
|
|
</label>
|
|
<div className="flex items-center gap-2 p-3 bg-gray-950 rounded-xl border border-gray-800">
|
|
<code className="flex-1 text-xs font-mono text-green-400 break-all">
|
|
{result.fullKey}
|
|
</code>
|
|
<CopyButton text={result.fullKey} />
|
|
</div>
|
|
<p className="mt-3 text-xs text-gray-500">
|
|
Stockez-la dans vos variables d'environnement ou un gestionnaire de secrets.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-6 pt-0">
|
|
<button
|
|
onClick={onClose}
|
|
className="w-full py-2.5 bg-[#10183A] hover:bg-[#1a2550] text-white text-sm font-medium rounded-xl transition-colors"
|
|
>
|
|
J'ai copié ma clé, fermer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Create key form modal ───────────────────────────────────────────────────
|
|
|
|
function CreateKeyModal({
|
|
onSuccess,
|
|
onClose,
|
|
}: {
|
|
onSuccess: (result: CreateApiKeyResultDto) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const [name, setName] = useState('');
|
|
const [expiresAt, setExpiresAt] = useState('');
|
|
const queryClient = useQueryClient();
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: createApiKey,
|
|
onSuccess: result => {
|
|
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
|
|
onSuccess(result);
|
|
},
|
|
});
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
mutation.mutate({
|
|
name: name.trim(),
|
|
...(expiresAt ? { expiresAt: new Date(expiresAt).toISOString() } : {}),
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-6 border-b">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center">
|
|
<Key className="w-5 h-5 text-blue-600" />
|
|
</div>
|
|
<h2 className="text-lg font-semibold text-gray-900">Nouvelle clé API</h2>
|
|
</div>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
|
{/* Name */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
|
Nom de la clé <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
onChange={e => setName(e.target.value)}
|
|
placeholder="ex: Intégration ERP Production"
|
|
maxLength={100}
|
|
required
|
|
className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#34CCCD] focus:border-transparent"
|
|
/>
|
|
<p className="mt-1 text-xs text-gray-400">{name.length}/100 caractères</p>
|
|
</div>
|
|
|
|
{/* Expiry */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
|
Date d'expiration{' '}
|
|
<span className="text-gray-400 font-normal">(optionnel)</span>
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={expiresAt}
|
|
onChange={e => setExpiresAt(e.target.value)}
|
|
min={new Date().toISOString().split('T')[0]}
|
|
className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#34CCCD] focus:border-transparent"
|
|
/>
|
|
<p className="mt-1 text-xs text-gray-400">
|
|
Si vide, la clé n'expire jamais.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{mutation.isError && (
|
|
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
|
|
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
|
Une erreur est survenue. Veuillez réessayer.
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-3 pt-2">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="flex-1 py-2.5 border border-gray-200 text-gray-700 text-sm font-medium rounded-xl hover:bg-gray-50 transition-colors"
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={!name.trim() || mutation.isPending}
|
|
className="flex-1 py-2.5 bg-[#10183A] hover:bg-[#1a2550] disabled:opacity-50 text-white text-sm font-medium rounded-xl transition-colors"
|
|
>
|
|
{mutation.isPending ? 'Création...' : 'Créer la clé'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Revoke confirm modal ────────────────────────────────────────────────────
|
|
|
|
function RevokeConfirmModal({
|
|
apiKey,
|
|
onConfirm,
|
|
onClose,
|
|
}: {
|
|
apiKey: ApiKeyDto;
|
|
onConfirm: () => void;
|
|
onClose: () => void;
|
|
}) {
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md">
|
|
<div className="p-6">
|
|
<div className="w-12 h-12 rounded-xl bg-red-100 flex items-center justify-center mx-auto mb-4">
|
|
<Trash2 className="w-6 h-6 text-red-600" />
|
|
</div>
|
|
<h2 className="text-lg font-semibold text-gray-900 text-center mb-2">
|
|
Révoquer cette clé ?
|
|
</h2>
|
|
<p className="text-sm text-gray-600 text-center mb-1">
|
|
<strong className="text-gray-900">{apiKey.name}</strong>
|
|
</p>
|
|
<p className="text-sm text-gray-500 text-center">
|
|
Cette action est <strong>immédiate et irréversible</strong>. Toute requête utilisant
|
|
cette clé sera refusée.
|
|
</p>
|
|
</div>
|
|
<div className="px-6 pb-6 flex gap-3">
|
|
<button
|
|
onClick={onClose}
|
|
className="flex-1 py-2.5 border border-gray-200 text-gray-700 text-sm font-medium rounded-xl hover:bg-gray-50 transition-colors"
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
onClick={onConfirm}
|
|
className="flex-1 py-2.5 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-xl transition-colors"
|
|
>
|
|
Révoquer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
|
|
|
export default function ApiKeysPage() {
|
|
const { hasFeature } = useSubscription();
|
|
const queryClient = useQueryClient();
|
|
const hasApiAccess = hasFeature('api_access');
|
|
|
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
const [createdKey, setCreatedKey] = useState<CreateApiKeyResultDto | null>(null);
|
|
const [revokeTarget, setRevokeTarget] = useState<ApiKeyDto | null>(null);
|
|
|
|
const { data: apiKeys, isLoading } = useQuery({
|
|
queryKey: ['api-keys'],
|
|
queryFn: listApiKeys,
|
|
enabled: hasApiAccess,
|
|
});
|
|
|
|
const revokeMutation = useMutation({
|
|
mutationFn: revokeApiKey,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
|
|
setRevokeTarget(null);
|
|
},
|
|
});
|
|
|
|
// Plan upsell screen
|
|
if (!hasApiAccess) {
|
|
return (
|
|
<div className="max-w-lg mx-auto mt-16 text-center">
|
|
<div className="w-16 h-16 rounded-2xl bg-gray-100 flex items-center justify-center mx-auto mb-6">
|
|
<Lock className="w-8 h-8 text-gray-400" />
|
|
</div>
|
|
<h1 className="text-2xl font-bold text-gray-900 mb-3">Accès API</h1>
|
|
<p className="text-gray-600 mb-8">
|
|
L'accès programmatique à l'API Xpeditis est disponible sur les plans{' '}
|
|
<strong>Gold</strong> et <strong>Platinium</strong> uniquement.
|
|
</p>
|
|
<a
|
|
href="/pricing"
|
|
className="inline-flex items-center gap-2 px-6 py-3 bg-[#10183A] hover:bg-[#1a2550] text-white text-sm font-medium rounded-xl transition-colors"
|
|
>
|
|
Voir les plans
|
|
</a>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const activeKeys = apiKeys?.filter(k => k.isActive) ?? [];
|
|
|
|
return (
|
|
<>
|
|
{/* Modals */}
|
|
{showCreateModal && (
|
|
<CreateKeyModal
|
|
onSuccess={result => {
|
|
setShowCreateModal(false);
|
|
setCreatedKey(result);
|
|
}}
|
|
onClose={() => setShowCreateModal(false)}
|
|
/>
|
|
)}
|
|
{createdKey && (
|
|
<CreatedKeyModal result={createdKey} onClose={() => setCreatedKey(null)} />
|
|
)}
|
|
{revokeTarget && (
|
|
<RevokeConfirmModal
|
|
apiKey={revokeTarget}
|
|
onConfirm={() => revokeMutation.mutate(revokeTarget.id)}
|
|
onClose={() => setRevokeTarget(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Page header */}
|
|
<div className="flex items-start justify-between mb-8">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Clés API</h1>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
Gérez les clés d'accès programmatique à l'API Xpeditis.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowCreateModal(true)}
|
|
disabled={activeKeys.length >= 20}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-[#10183A] hover:bg-[#1a2550] disabled:opacity-50 text-white text-sm font-medium rounded-xl transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Nouvelle clé
|
|
</button>
|
|
</div>
|
|
|
|
{/* Info banner */}
|
|
<div className="mb-6 p-4 bg-blue-50 border border-blue-100 rounded-xl flex gap-3">
|
|
<ShieldCheck className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
|
<div className="text-sm text-blue-800">
|
|
<p className="font-medium mb-0.5">Comment utiliser vos clés API</p>
|
|
<p>
|
|
Ajoutez l'en-tête{' '}
|
|
<code className="px-1.5 py-0.5 bg-blue-100 rounded text-blue-900 font-mono text-xs">
|
|
X-API-Key: xped_live_...
|
|
</code>{' '}
|
|
à chaque requête HTTP.{' '}
|
|
<a
|
|
href="/dashboard/docs?section=authentication"
|
|
className="font-medium underline underline-offset-2"
|
|
>
|
|
Voir la documentation
|
|
</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Keys list */}
|
|
<div className="bg-white border border-gray-200 rounded-2xl overflow-hidden">
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-16">
|
|
<div className="w-8 h-8 border-4 border-[#34CCCD] border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
) : !apiKeys || apiKeys.length === 0 ? (
|
|
<div className="py-16 text-center">
|
|
<Key className="w-10 h-10 text-gray-300 mx-auto mb-3" />
|
|
<p className="text-gray-500 text-sm">Aucune clé API pour le moment.</p>
|
|
<button
|
|
onClick={() => setShowCreateModal(true)}
|
|
className="mt-4 text-sm font-medium text-[#10183A] hover:underline"
|
|
>
|
|
Créer votre première clé
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-100">
|
|
{/* Table header */}
|
|
<div className="grid grid-cols-[2fr_1.5fr_1fr_1fr_auto] gap-4 px-6 py-3 bg-gray-50 text-xs font-medium text-gray-500 uppercase tracking-wide">
|
|
<span>Nom / Préfixe</span>
|
|
<span>Dernière utilisation</span>
|
|
<span>Expiration</span>
|
|
<span>Statut</span>
|
|
<span />
|
|
</div>
|
|
|
|
{apiKeys.map(key => (
|
|
<div
|
|
key={key.id}
|
|
className="grid grid-cols-[2fr_1.5fr_1fr_1fr_auto] gap-4 items-center px-6 py-4"
|
|
>
|
|
{/* Name + prefix */}
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-900">{key.name}</p>
|
|
<code className="text-xs font-mono text-gray-400">{key.keyPrefix}…</code>
|
|
</div>
|
|
|
|
{/* Last used */}
|
|
<span className="text-sm text-gray-600">{formatDate(key.lastUsedAt)}</span>
|
|
|
|
{/* Expiry */}
|
|
<span className="text-sm text-gray-600">{formatDate(key.expiresAt)}</span>
|
|
|
|
{/* Status */}
|
|
<div>{keyStatusBadge(key)}</div>
|
|
|
|
{/* Actions */}
|
|
<button
|
|
onClick={() => setRevokeTarget(key)}
|
|
disabled={!key.isActive || revokeMutation.isPending}
|
|
title="Révoquer cette clé"
|
|
className="p-2 text-gray-400 hover:text-red-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-red-50"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Quota */}
|
|
{apiKeys && apiKeys.length > 0 && (
|
|
<p className="mt-4 text-xs text-gray-400 text-right">
|
|
{activeKeys.length} / 20 clés actives utilisées
|
|
</p>
|
|
)}
|
|
</>
|
|
);
|
|
}
|