tududi/frontend/components/Profile/tabs/ApiKeysTab.tsx
Chris 75a1e68730
Tc refactor pt1 (#589)
* Refactor ProfileSettings

* Cleanup comments

* Refactor TaskDetails

* Refactor InboxModal

* fixup! Refactor InboxModal

* Fix project layout

* Add visuals to project details

* Refactor projectdetails

* Remake project metrics

* Complete project details refactor

* Fix note issues and enhance view

* Add filters

* Fix project tasks filters

* Add filters to task lists

* Add filters to task lists

* fixup! Add filters to task lists
2025-11-23 21:48:49 +02:00

340 lines
18 KiB
TypeScript

import React from 'react';
import { useTranslation } from 'react-i18next';
import {
ClipboardDocumentListIcon,
KeyIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import type { ApiKeySummary } from '../../../utils/apiKeysService';
interface ApiKeysTabProps {
isActive: boolean;
apiKeys: ApiKeySummary[];
apiKeysLoading: boolean;
generatedApiToken: string | null;
newApiKeyName: string;
newApiKeyExpiration: string;
revokeInFlightId: number | null;
deleteInFlightId: number | null;
pendingDeleteId: number | null;
onCreateApiKey: () => void;
onCopyGeneratedToken: () => void;
onRevokeApiKey: (apiKeyId: number) => void;
onRequestDelete: (apiKey: ApiKeySummary) => void;
onUpdateNewName: (value: string) => void;
onUpdateNewExpiration: (value: string) => void;
getApiKeyStatus: (apiKey: ApiKeySummary) => {
label: string;
className: string;
};
formatDateTime: (value: string | null) => string;
isCreatingApiKey: boolean;
}
const ApiKeysTab: React.FC<ApiKeysTabProps> = ({
isActive,
apiKeys,
apiKeysLoading,
generatedApiToken,
newApiKeyName,
newApiKeyExpiration,
revokeInFlightId,
deleteInFlightId,
pendingDeleteId,
onCreateApiKey,
onCopyGeneratedToken,
onRevokeApiKey,
onRequestDelete,
onUpdateNewName,
onUpdateNewExpiration,
getApiKeyStatus,
formatDateTime,
isCreatingApiKey,
}) => {
const { t } = useTranslation();
if (!isActive) return null;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
<KeyIcon className="w-6 h-6 mr-3 text-indigo-500" />
{t('profile.apiKeys.title', 'API Keys')}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-6">
{t(
'profile.apiKeys.description',
'Generate personal access tokens for integrations or CLI usage. You can revoke or delete keys at any time.'
)}
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('profile.apiKeys.nameLabel', 'Key name')}
</label>
<input
type="text"
value={newApiKeyName}
onChange={(event) =>
onUpdateNewName(event.target.value)
}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
onCreateApiKey();
}
}}
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder={t(
'profile.apiKeys.namePlaceholder',
'e.g. Personal laptop'
)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t(
'profile.apiKeys.expirationLabel',
'Expires on (optional)'
)}
</label>
<input
type="date"
value={newApiKeyExpiration}
onChange={(event) =>
onUpdateNewExpiration(event.target.value)
}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
onCreateApiKey();
}
}}
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="flex items-end">
<button
type="button"
disabled={isCreatingApiKey}
onClick={onCreateApiKey}
className={`w-full inline-flex justify-center items-center px-4 py-2 rounded-md text-white ${
isCreatingApiKey
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-500'
}`}
>
{isCreatingApiKey
? t('common.saving', 'Saving...')
: t(
'profile.apiKeys.generateButton',
'Generate key'
)}
</button>
</div>
</div>
{generatedApiToken && (
<div className="mt-4 p-4 bg-green-50 dark:bg-green-900/40 border border-green-200 dark:border-green-700 rounded-md">
<p className="text-sm text-green-900 dark:text-green-100 mb-2">
{t(
'profile.apiKeys.copyNotice',
'Copy this token now. It will not be shown again.'
)}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<code className="flex-1 bg-white dark:bg-gray-900 rounded px-3 py-2 text-sm font-mono text-gray-800 dark:text-gray-100 overflow-x-auto">
{generatedApiToken}
</code>
<button
type="button"
onClick={onCopyGeneratedToken}
className="inline-flex items-center justify-center px-4 py-2 rounded-md bg-green-600 text-white hover:bg-green-500"
>
<ClipboardDocumentListIcon className="w-5 h-5 mr-2" />
{t('profile.apiKeys.copyButton', 'Copy key')}
</button>
</div>
</div>
)}
<div className="mt-6">
{apiKeysLoading && (
<p className="text-sm text-gray-600 dark:text-gray-300">
{t('profile.apiKeys.loading', 'Loading API keys...')}
</p>
)}
{!apiKeysLoading && apiKeys.length === 0 && (
<p className="text-sm text-gray-600 dark:text-gray-300">
{t(
'profile.apiKeys.empty',
'No API keys yet. Generate one to begin.'
)}
</p>
)}
{!apiKeysLoading && apiKeys.length > 0 && (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
<thead className="bg-gray-100 dark:bg-gray-800">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t(
'profile.apiKeys.table.name',
'Name'
)}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t(
'profile.apiKeys.table.prefix',
'Prefix'
)}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t(
'profile.apiKeys.table.status',
'Status'
)}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t(
'profile.apiKeys.table.lastUsed',
'Last used'
)}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t(
'profile.apiKeys.table.expires',
'Expires'
)}
</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t(
'profile.apiKeys.table.actions',
'Actions'
)}
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{apiKeys.map((key) => {
const status = getApiKeyStatus(key);
return (
<tr key={key.id}>
<td className="px-4 py-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{key.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{t(
'profile.apiKeys.createdAt',
'Created {{date}}',
{
date: formatDateTime(
key.created_at
),
}
)}
</div>
</td>
<td className="px-4 py-3 text-sm font-mono text-gray-800 dark:text-gray-200">
{key.token_prefix}...
</td>
<td className="px-4 py-3 text-sm">
<span
className={status.className}
>
{status.label}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-200">
{formatDateTime(
key.last_used_at
)}
</td>
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-200">
{key.expires_at
? formatDateTime(
key.expires_at
)
: t(
'profile.apiKeys.noExpiry',
'None'
)}
</td>
<td className="px-4 py-3 text-right text-sm">
<div className="flex justify-end space-x-2">
<button
type="button"
onClick={() =>
onRevokeApiKey(
key.id
)
}
disabled={
Boolean(
key.revoked_at
) ||
revokeInFlightId ===
key.id
}
className={`inline-flex items-center px-3 py-1.5 rounded-md border text-xs font-medium ${
key.revoked_at
? 'border-gray-400 text-gray-400 cursor-not-allowed'
: 'border-yellow-600 text-yellow-700 hover:bg-yellow-50'
}`}
>
{key.revoked_at
? t(
'profile.apiKeys.revokedLabel',
'Revoked'
)
: t(
'profile.apiKeys.revokeButton',
'Revoke'
)}
</button>
<button
type="button"
onClick={() =>
onRequestDelete(key)
}
disabled={
deleteInFlightId ===
key.id ||
Boolean(
pendingDeleteId ===
key.id
)
}
className={`inline-flex items-center justify-center px-3 py-1.5 rounded-md border text-xs font-medium ${
deleteInFlightId ===
key.id
? 'border-gray-400 text-gray-400 cursor-not-allowed'
: 'border-red-600 text-red-700 hover:bg-red-50'
}`}
aria-label={t(
'profile.apiKeys.deleteAria',
'Delete API key'
)}
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
};
export default ApiKeysTab;