xpeditis2.0/apps/frontend/app/[locale]/dashboard/settings/api-keys/page.tsx
2026-05-12 21:01:52 +02:00

458 lines
16 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslations, useLocale } from 'next-intl';
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';
import { PageHeader } from '@/components/ui/PageHeader';
function CopyButton({ text }: { text: string }) {
const t = useTranslations('dashboard.apiKeys.copy');
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">{t('copied')}</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5 text-gray-400" />
<span className="text-gray-600">{t('copy')}</span>
</>
)}
</button>
);
}
function CreatedKeyModal({
result,
onClose,
}: {
result: CreateApiKeyResultDto;
onClose: () => void;
}) {
const t = useTranslations('dashboard.apiKeys.createdModal');
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">
<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">{t('title')}</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>
<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>{t('warning')}</strong> {t('warningRest')}
</p>
</div>
<div className="p-6">
<label className="block text-xs font-medium text-gray-500 mb-2 uppercase tracking-wide">
{t('fullKey')}
</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">{t('storeHint')}</p>
</div>
<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"
>
{t('close')}
</button>
</div>
</div>
</div>
);
}
function CreateKeyModal({
onSuccess,
onClose,
}: {
onSuccess: (result: CreateApiKeyResultDto) => void;
onClose: () => void;
}) {
const t = useTranslations('dashboard.apiKeys.createModal');
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">
<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">{t('title')}</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">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
{t('name')} <span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('namePlaceholder')}
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">{t('nameCount', { count: name.length })}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
{t('expiry')} <span className="text-gray-400 font-normal">{t('optional')}</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">{t('expiryHint')}</p>
</div>
{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" />
{t('errorGeneric')}
</div>
)}
<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"
>
{t('cancel')}
</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 ? t('creating') : t('create')}
</button>
</div>
</form>
</div>
</div>
);
}
function RevokeConfirmModal({
apiKey,
onConfirm,
onClose,
}: {
apiKey: ApiKeyDto;
onConfirm: () => void;
onClose: () => void;
}) {
const t = useTranslations('dashboard.apiKeys.revokeModal');
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">{t('title')}</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">
{t('description')} <strong>{t('descriptionEmphasis')}</strong>
{t('descriptionRest')}
</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"
>
{t('cancel')}
</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"
>
{t('confirm')}
</button>
</div>
</div>
</div>
);
}
export default function ApiKeysPage() {
const t = useTranslations('dashboard.apiKeys');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
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);
},
});
const formatDate = (iso: string | null): string => {
if (!iso) return '—';
return new Intl.DateTimeFormat(dateLocale, { dateStyle: 'medium' }).format(new Date(iso));
};
const 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">
{t('status.revoked')}
</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" />
{t('status.expired')}
</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">
{t('status.active')}
</span>
);
};
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">{t('noAccess.title')}</h1>
<p className="text-gray-600 mb-8">
{t.rich('noAccess.description', {
gold: () => <strong>{t('noAccess.gold')}</strong>,
platinium: () => <strong>{t('noAccess.platinium')}</strong>,
})}
</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"
>
{t('noAccess.viewPlans')}
</a>
</div>
);
}
const activeKeys = apiKeys?.filter(k => k.isActive) ?? [];
return (
<>
{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)}
/>
)}
<PageHeader
title={t('title')}
description={t('description')}
actions={
<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" />
{t('newKey')}
</button>
}
/>
<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">{t('infoTitle')}</p>
<p>
{t.rich('infoBody', {
code: () => (
<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>
),
link: () => (
<a
href="/dashboard/docs?section=authentication"
className="font-medium underline underline-offset-2"
>
{t('viewDocs')}
</a>
),
})}
</p>
</div>
</div>
<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">{t('noKeys')}</p>
<button
onClick={() => setShowCreateModal(true)}
className="mt-4 text-sm font-medium text-[#10183A] hover:underline"
>
{t('createFirst')}
</button>
</div>
) : (
<div className="divide-y divide-gray-100">
<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>{t('table.name')}</span>
<span>{t('table.lastUsed')}</span>
<span>{t('table.expiry')}</span>
<span>{t('table.status')}</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"
>
<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>
<span className="text-sm text-gray-600">{formatDate(key.lastUsedAt)}</span>
<span className="text-sm text-gray-600">{formatDate(key.expiresAt)}</span>
<div>{keyStatusBadge(key)}</div>
<button
onClick={() => setRevokeTarget(key)}
disabled={!key.isActive || revokeMutation.isPending}
title={t('revoke')}
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>
{apiKeys && apiKeys.length > 0 && (
<p className="mt-4 text-xs text-gray-400 text-right">
{t('quota', { active: activeKeys.length, max: 20 })}
</p>
)}
</>
);
}