Files
office_translator/frontend/src/app/dashboard/api-keys/page.tsx
sepehr eda6821632
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m43s
i18n: fix missing keys and translate all non-admin frontend strings
- Add 12 missing i18n keys (t() was returning the literal key string) to
  all 13 locales: dashboard.topbar.premiumAccess,
  dashboard.translate.complete.toastOkDesc,
  dashboard.translate.progress.{connectionLost,processingFallback},
  glossaries.card.{term,created}, glossaries.termEditor.{addTerm,maxReached},
  login.google.{connecting,errorFailed,errorGeneric}, login.orContinueWith
- Add 6 FR-drift keys (landing.pricing.{free,enterprise}.{name,desc,cta})
- Add ~120 new i18n keys covering site header/footer, file-uploader,
  checkout success, dashboard pages, translate page, provider selector
  themes, language selector, translation complete, api-keys, services,
  settings, pricing (~1800 new key/locale pairs)
- Wrap hardcoded French/English in components with t() calls
- Convert LLM_THEMES/CLASSIC_THEMES/FALLBACK_PROVIDERS maps from
  hardcoded constants to t()-driven factories
- Admin pages intentionally left untouched per request

Files: 15 components/pages + src/lib/i18n.tsx
Typecheck: passes (tsc --noEmit exit 0)
2026-06-14 12:45:12 +02:00

240 lines
8.8 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { Zap, Plus, AlertCircle, Clock } from 'lucide-react';
import { useUser } from '@/app/dashboard/useUser';
import { useApiKeys } from './useApiKeys';
import { MAX_API_KEYS, type ApiKey } from './types';
import { ProUpgradePrompt } from './ProUpgradePrompt';
import { ApiKeyTable } from './ApiKeyTable';
import { GenerateKeyDialog } from './GenerateKeyDialog';
import { RevokeKeyDialog } from './RevokeKeyDialog';
import { WebhookSnippet } from './WebhookSnippet';
import { useToast } from '@/components/ui/toast';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
export default function ApiKeysPage() {
const { t } = useI18n();
const { data: user, isLoading: isLoadingUser } = useUser();
const {
keys,
total,
isLoading: isLoadingKeys,
isGenerating,
isRevoking,
generateKey,
revokeKey,
errorDetails,
parseGenerateError,
parseRevokeError,
} = useApiKeys();
const { toast } = useToast();
const [generateDialogOpen, setGenerateDialogOpen] = useState(false);
const [revokeDialogOpen, setRevokeDialogOpen] = useState(false);
const [keyToRevoke, setKeyToRevoke] = useState<{ id: string; name: string } | null>(null);
const [apiError, setApiError] = useState<string | null>(null);
const isPro = user?.tier === 'pro';
const maxKeysReached = total >= MAX_API_KEYS;
const isLoading = isLoadingUser || isLoadingKeys;
// Handle API errors with specific error codes
useEffect(() => {
if (errorDetails?.code === 'PRO_FEATURE_REQUIRED') {
setApiError(null);
} else if (errorDetails?.code === 'API_KEY_LIMIT_REACHED') {
setApiError(t('apiKeys.limitReachedDesc'));
} else if (errorDetails) {
setApiError(errorDetails.message);
} else {
setApiError(null);
}
}, [errorDetails]); // eslint-disable-line react-hooks/exhaustive-deps
const handleRevokeClick = (key: ApiKey) => {
setKeyToRevoke({ id: key.id, name: key.name });
setRevokeDialogOpen(true);
};
const handleRevokeConfirm = async () => {
if (!keyToRevoke) return;
try {
await revokeKey(keyToRevoke.id);
setRevokeDialogOpen(false);
setKeyToRevoke(null);
toast({
title: t('apiKeys.keyRevoked'),
description: t('apiKeys.keyRevokedDesc'),
});
} catch (error) {
const revokeError = parseRevokeError();
if (revokeError?.code === 'API_KEY_NOT_FOUND') {
toast({
variant: 'destructive',
title: t('apiKeys.keyNotFound'),
description: t('apiKeys.keyNotFoundDesc'),
});
} else {
toast({
variant: 'destructive',
title: t('apiKeys.error'),
description: revokeError?.message || t('apiKeys.revokeError'),
});
}
}
};
const handleGenerateKey = async (name?: string) => {
try {
const result = await generateKey(name);
return result;
} catch (error) {
const genError = parseGenerateError();
if (genError?.code === 'API_KEY_LIMIT_REACHED') {
toast({
variant: 'destructive',
title: t('apiKeys.limitReached'),
description: t('apiKeys.limitReachedDesc'),
});
} else if (genError?.code === 'PRO_FEATURE_REQUIRED') {
toast({
variant: 'destructive',
title: t('apiKeys.proRequired'),
description: t('apiKeys.proRequiredDesc'),
});
} else {
toast({
variant: 'destructive',
title: t('apiKeys.error'),
description: genError?.message || t('apiKeys.generateError'),
});
}
throw error;
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-8 w-8 border-4 border-brand-muted border-t-brand-accent mx-auto"></div>
<p className="text-[9px] font-black text-brand-dark/40 dark:text-white/40 uppercase tracking-widest">{t('apiKeys.loading')}</p>
</div>
</div>
);
}
if (!isPro) {
return <ProUpgradePrompt />;
}
return (
<div className="max-w-4xl mx-auto w-full p-6 lg:p-8 space-y-8">
{/* ── Editorial Header ───────────────────────────────────── */}
<div className="mb-4">
<span className="accent-pill mb-4 block w-fit">{t('apiKeys.sectionTitle')}</span>
<h1 className="text-5xl font-black uppercase tracking-tighter mb-4 leading-none text-brand-dark dark:text-white">
{t('apiKeys.title')}
</h1>
<p className="text-brand-dark/40 dark:text-white/40 font-medium">{t('apiKeys.subtitle')}</p>
</div>
{/* API Error */}
{apiError && (
<div className="flex items-start gap-3 p-4 rounded-xl border border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10 text-sm text-red-600 dark:text-red-400">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<span className="flex-1">{apiError}</span>
</div>
)}
{/* ── Main Card ──────────────────────────────────────────── */}
<div className="editorial-card p-10 lg:p-12 bg-white dark:bg-[#141414] border-none shadow-editorial">
{/* Section header */}
<div className="flex items-center gap-4 mb-10 text-brand-accent">
<div className="w-12 h-12 bg-brand-muted dark:bg-white/10 rounded-2xl flex items-center justify-center text-brand-accent">
<Zap size={24} />
</div>
<div>
<h3 className="text-[11px] font-black uppercase tracking-[0.3em] text-brand-dark dark:text-white">
{t('apiKeys.sectionTitle')}
</h3>
<p className="text-[9px] text-brand-dark/30 dark:text-white/30 font-bold uppercase tracking-widest mt-1">
{t('apiKeys.sectionDesc')}
</p>
</div>
</div>
{/* Key count + generate button */}
<div className="flex items-center justify-between mb-8">
<div>
<p className="text-[11px] font-black uppercase tracking-widest text-brand-dark dark:text-white">
{t('apiKeys.keysUsed', { total, max: MAX_API_KEYS })}
</p>
<p className="text-[9px] text-brand-dark/30 dark:text-white/30 font-black uppercase tracking-widest mt-1">
{maxKeysReached ? (
<span className="text-amber-600 dark:text-amber-400">{t('apiKeys.maxReached')}</span>
) : (
t(MAX_API_KEYS - total !== 1 ? 'apiKeys.canGeneratePlural' : 'apiKeys.canGenerate', { count: MAX_API_KEYS - total })
)}
</p>
</div>
<button
onClick={() => setGenerateDialogOpen(true)}
disabled={maxKeysReached || isGenerating}
className="premium-button px-8 py-3 text-[9px] uppercase tracking-widest !rounded-xl flex items-center gap-2 disabled:opacity-50"
>
<Plus size={14} />
{t('apiKeys.generateNew')}
</button>
</div>
{/* Production key display area */}
<div className="flex items-center justify-between p-8 bg-brand-muted dark:bg-white/5 rounded-3xl border border-black/5 dark:border-white/10 mb-8">
<div>
<p className="text-[9px] text-brand-dark/40 dark:text-white/40 font-black uppercase tracking-[0.3em] mb-2">
{t('apiKeys.sectionTitle')}
</p>
<p className="text-sm font-mono text-brand-dark dark:text-white">
{keys.length > 0 ? `${keys[0].key_prefix}************************************` : t('apiKeys.noKeysGenerated')}
</p>
</div>
<div className="flex gap-3">
<button className="p-3 bg-white dark:bg-white/10 rounded-xl text-brand-dark/40 dark:text-white/40 hover:text-brand-dark dark:hover:text-white border border-black/5 dark:border-white/10 transition-all">
<Clock size={16} />
</button>
</div>
</div>
{/* Key table */}
<ApiKeyTable
keys={keys}
onRevoke={handleRevokeClick}
isRevoking={isRevoking}
/>
</div>
{/* Webhook snippet */}
<WebhookSnippet />
{/* Dialogs */}
<GenerateKeyDialog
open={generateDialogOpen}
onOpenChange={setGenerateDialogOpen}
onGenerate={handleGenerateKey}
isGenerating={isGenerating}
maxKeysReached={maxKeysReached}
/>
<RevokeKeyDialog
open={revokeDialogOpen}
onOpenChange={setRevokeDialogOpen}
onConfirm={handleRevokeConfirm}
isRevoking={isRevoking}
keyName={keyToRevoke?.name}
/>
</div>
);
}