import {useEffect, useMemo, useState} from 'react'; import {cn} from '@/lib/utils'; import {PageHeader} from '@/components/ui/page-header'; import {Button} from '@/components/ui/button'; import {Input} from '@/components/ui/input'; import {Badge} from '@/components/ui/badge'; import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table'; import { Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { IconTile } from '@/components/ui/icon-tile'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import {Tabs, TabsContent} from '@/components/ui/tabs'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Checkbox} from '@/components/ui/checkbox'; import {Select, SelectOption} from '@/components/ui/select'; import {accessApi, bucketsApi} from '@/lib/api'; import {formatDate} from '@/lib/utils'; import type {AccessKey, Bucket, BucketPermission} from '@/types'; import {AlertTriangle, Calendar, Check, Copy, Database, Edit, Eye, EyeOff, Key, KeyRound, Loader2, MoreVertical, Plus, Search, ShieldCheck, ShieldX, Trash2,} from 'lucide-react'; import {toast} from 'sonner'; function CredentialField({ label, value, mono = true, breakAll = false, maskable = false, loading = false, }: { label: string; value: string; mono?: boolean; breakAll?: boolean; maskable?: boolean; loading?: boolean; }) { const [copied, setCopied] = useState(false); const [revealed, setRevealed] = useState(!maskable); const copy = () => { if (!value) return; navigator.clipboard.writeText(value); setCopied(true); toast.success(`${label} copied`); setTimeout(() => setCopied(false), 1600); }; const display = loading ? '' : revealed || !maskable ? value : '•'.repeat(Math.min(40, value.length || 40)); return (
{maskable && ( )}
); } export function AccessControl() { const [keys, setKeys] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [isLoading, setIsLoading] = useState(true); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [selectedKey, setSelectedKey] = useState(null); const [newKeyName, setNewKeyName] = useState(''); // Create key with permissions state const [createAvailableBuckets, setCreateAvailableBuckets] = useState([]); const [createSelectedBucket, setCreateSelectedBucket] = useState(''); const [createPermissionRead, setCreatePermissionRead] = useState(false); const [createPermissionWrite, setCreatePermissionWrite] = useState(false); const [createPermissionOwner, setCreatePermissionOwner] = useState(false); const [createGrantPermissions, setCreateGrantPermissions] = useState(false); const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); // Edit permissions state const [editPermissionsDialogOpen, setEditPermissionsDialogOpen] = useState(false); const [editingKey, setEditingKey] = useState(null); const [availableBuckets, setAvailableBuckets] = useState([]); const [selectedBucket, setSelectedBucket] = useState(''); const [permissionRead, setPermissionRead] = useState(false); const [permissionWrite, setPermissionWrite] = useState(false); const [permissionOwner, setPermissionOwner] = useState(false); const [savingPermissions, setSavingPermissions] = useState(false); // Key settings state (activation/expiration) const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); const [settingsKey, setSettingsKey] = useState(null); const [keyStatus, setKeyStatus] = useState<'active' | 'inactive'>('active'); const [expirationDate, setExpirationDate] = useState(''); const [neverExpires, setNeverExpires] = useState(true); // Secret key dialog state const [secretKeyDialogOpen, setSecretKeyDialogOpen] = useState(false); const [revealedSecretKey, setRevealedSecretKey] = useState(''); const [isLoadingSecretKey, setIsLoadingSecretKey] = useState(false); // Key details dialog state const [keyDetailsDialogOpen, setKeyDetailsDialogOpen] = useState(false); const [viewingKey, setViewingKey] = useState(null); const [detailsSecretKey, setDetailsSecretKey] = useState(''); const [isLoadingDetailsSecretKey, setIsLoadingDetailsSecretKey] = useState(false); useEffect(() => { const fetchKeys = async () => { try { setIsLoading(true); const data = await accessApi.listKeys(); setKeys(data); } catch (error) { console.error('Failed to fetch keys:', error); } finally { setIsLoading(false); } }; fetchKeys(); }, []); const filteredKeys = useMemo( () => keys.filter( (key) => key.name.toLowerCase().includes(searchQuery.toLowerCase()) || key.accessKeyId.toLowerCase().includes(searchQuery.toLowerCase()) ), [keys, searchQuery] ); const handleCreateKey = async () => { if (!newKeyName) { toast.error('Please enter a key name'); return; } try { const newKey = await accessApi.createKey(newKeyName); // If user wants to grant permissions inline if (createGrantPermissions && createSelectedBucket) { if (createPermissionRead || createPermissionWrite || createPermissionOwner) { try { await bucketsApi.grantPermission(createSelectedBucket, newKey.accessKeyId, { read: createPermissionRead, write: createPermissionWrite, owner: createPermissionOwner, }); } catch (error) { console.error('Failed to grant permissions:', error); // Continue even if permission grant fails - key is already created } } } // Store the newly created key to show the secret key setNewlyCreatedKey(newKey); // Refresh keys list const data = await accessApi.listKeys(); setKeys(data); toast.success(`API Key "${newKeyName}" created successfully`); } catch (error) { // Error toast is handled by API interceptor console.error('Create key error:', error); } }; const handleCloseCreateDialog = () => { setCreateDialogOpen(false); setNewKeyName(''); setCreateSelectedBucket(''); setCreatePermissionRead(false); setCreatePermissionWrite(false); setCreatePermissionOwner(false); setCreateGrantPermissions(false); setNewlyCreatedKey(null); }; const handleOpenCreateDialog = async () => { setCreateDialogOpen(true); setNewKeyName(''); setCreateSelectedBucket(''); setCreatePermissionRead(false); setCreatePermissionWrite(false); setCreatePermissionOwner(false); setCreateGrantPermissions(false); // Load available buckets try { const buckets = await bucketsApi.list(); setCreateAvailableBuckets(buckets); } catch (error) { console.error('Failed to load buckets:', error); } }; const handleDeleteKey = async () => { if (!selectedKey) return; try { await accessApi.deleteKey(selectedKey.accessKeyId); const keyName = selectedKey.name; setDeleteDialogOpen(false); setSelectedKey(null); // Refresh keys list const data = await accessApi.listKeys(); setKeys(data); toast.success(`API Key "${keyName}" deleted successfully`); } catch (error) { // Error toast is handled by API interceptor console.error('Delete key error:', error); } }; const handleOpenSettings = (key: AccessKey) => { setSettingsKey(key); setKeyStatus(key.status); setSettingsDialogOpen(true); // Set expiration date if it exists if (key.expiration) { const expDate = new Date(key.expiration); // Format as YYYY-MM-DDTHH:mm for datetime-local input const formattedDate = expDate.toISOString().slice(0, 16); setExpirationDate(formattedDate); setNeverExpires(false); } else { setExpirationDate(''); setNeverExpires(true); } }; const handleSaveKeySettings = async () => { if (!settingsKey) return; try { const updates: { status?: string; expiration?: string } = {}; updates.status = keyStatus; if (!neverExpires && expirationDate) { updates.expiration = new Date(expirationDate).toISOString(); } else if (neverExpires) { // Clear expiration by setting status to active updates.status = 'active'; } await accessApi.updateKey(settingsKey.accessKeyId, updates); // Refresh keys list const data = await accessApi.listKeys(); setKeys(data); setSettingsDialogOpen(false); toast.success(`Key settings updated successfully`); } catch (error) { // Error toast is handled by API interceptor console.error('Update key settings error:', error); } }; const handleRevealSecretKey = async (key: AccessKey) => { setSelectedKey(key); setIsLoadingSecretKey(true); setSecretKeyDialogOpen(true); setRevealedSecretKey(''); try { const secretKey = await accessApi.getSecretKey(key.accessKeyId); setRevealedSecretKey(secretKey); } catch (error) { console.error('Failed to fetch secret key:', error); setSecretKeyDialogOpen(false); } finally { setIsLoadingSecretKey(false); } }; const handleOpenEditPermissions = async (key: AccessKey) => { setEditingKey(key); setEditPermissionsDialogOpen(true); setSelectedBucket(''); setPermissionRead(false); setPermissionWrite(false); setPermissionOwner(false); // Load available buckets try { const buckets = await bucketsApi.list(); setAvailableBuckets(buckets); } catch (error) { console.error('Failed to load buckets:', error); } }; const handleBucketChange = (bucketName: string) => { setSelectedBucket(bucketName); if (!bucketName || !editingKey) { // Reset permissions if no bucket selected setPermissionRead(false); setPermissionWrite(false); setPermissionOwner(false); return; } // Find if this key already has permissions on the selected bucket const bucketPermission = editingKey.permissions.find( perm => perm.bucketName === bucketName || perm.bucketId === bucketName ); if (bucketPermission) { // Set the checkboxes to reflect current permissions setPermissionRead(bucketPermission.read); setPermissionWrite(bucketPermission.write); setPermissionOwner(bucketPermission.owner); } else { // No permissions set yet, reset checkboxes setPermissionRead(false); setPermissionWrite(false); setPermissionOwner(false); } }; const handleGrantBucketPermission = async () => { if (!editingKey || !selectedBucket) { toast.error('Please select a bucket'); return; } if (!permissionRead && !permissionWrite && !permissionOwner) { toast.error('Please select at least one permission'); return; } setSavingPermissions(true); try { // Call backend API to grant bucket permissions await bucketsApi.grantPermission(selectedBucket, editingKey.accessKeyId, { read: permissionRead, write: permissionWrite, owner: permissionOwner, }); toast.success(`Permissions granted on bucket "${selectedBucket}" successfully`); setEditPermissionsDialogOpen(false); setSelectedBucket(''); setPermissionRead(false); setPermissionWrite(false); setPermissionOwner(false); // Refresh keys list to update permissions const data = await accessApi.listKeys(); setKeys(data); } catch (error) { // Error toast is handled by API interceptor console.error('Grant permission error:', error); } finally { setSavingPermissions(false); } }; // Helper function to format permission flags as a readable string const formatPermissions = (perm: BucketPermission): string => { const perms = []; if (perm.read) perms.push('Read'); if (perm.write) perms.push('Write'); if (perm.owner) perms.push('Owner'); return perms.join(', ') || 'None'; }; const handleRowClick = async (key: AccessKey) => { setViewingKey(key); setKeyDetailsDialogOpen(true); setDetailsSecretKey(''); setIsLoadingDetailsSecretKey(true); // Fetch the secret key immediately try { const secretKey = await accessApi.getSecretKey(key.accessKeyId); setDetailsSecretKey(secretKey); } catch (error) { console.error('Failed to fetch secret key:', error); } finally { setIsLoadingDetailsSecretKey(false); } }; return (
{/* Stats */}
Total Keys
{keys.length}
Active Keys
{keys.filter((k) => k.status === 'active').length}
Inactive Keys
{keys.filter((k) => k.status === 'inactive').length}
{/* Toolbar */}
setSearchQuery(e.target.value)} className="pl-8" />
{/* Keys Table */}
Name Access Key ID Status Created Permissions {isLoading ? (
Loading API keys...
) : filteredKeys.length === 0 ? ( {searchQuery ? 'No keys found matching your search' : 'No API keys yet'} ) : ( filteredKeys.map((key) => ( handleRowClick(key)} className="cursor-pointer hover:bg-muted/50" > {key.name}
{ e.stopPropagation(); navigator.clipboard.writeText(key.accessKeyId); toast.success('Access Key ID copied to clipboard'); }} > {key.accessKeyId}
{key.status} {formatDate(key.createdAt)}
{key.permissions.slice(0, 2).map((perm, idx) => ( {perm.bucketName}: {formatPermissions(perm)} ))} {key.permissions.length > 2 && ( +{key.permissions.length - 2} more )} {key.permissions.length === 0 && ( No permissions )}
e.stopPropagation()}> handleRevealSecretKey(key)}> View Secret Key handleOpenEditPermissions(key)}> Edit Permissions handleOpenSettings(key)}> {key.status === 'active' ? ( <> Manage Status ) : ( <> Manage Status )} { setSelectedKey(key); setDeleteDialogOpen(true); }} > Delete
)) )}
{/* Create Key Dialog */} {newlyCreatedKey ? ( <> } tone="primary" size="md" />
API key created Copy your secret access key now — this is the only time it will be shown.
{newlyCreatedKey.name}

Save this key now

The secret access key cannot be retrieved again. If lost, you'll need to create a new key.

) : ( <> } tone="primary" size="md" />
Create API key Generate a new access key pair. You can optionally grant bucket permissions in the same step.
setNewKeyName(e.target.value)} autoFocus />

A friendly name to identify this key in the console.

{createGrantPermissions && (
{createSelectedBucket && (
{[ { id: 'create-permission-read', label: 'Read', desc: 'GetObject, HeadObject, ListObjects', checked: createPermissionRead, setChecked: setCreatePermissionRead, }, { id: 'create-permission-write', label: 'Write', desc: 'PutObject, DeleteObject', checked: createPermissionWrite, setChecked: setCreatePermissionWrite, }, { id: 'create-permission-owner', label: 'Owner', desc: 'DeleteBucket, PutBucketPolicy', checked: createPermissionOwner, setChecked: setCreatePermissionOwner, }, ].map((p) => ( ))}
)}
)}
)}
{/* Delete Key Dialog */} {/* Secret Key Dialog */} } tone="primary" size="md" />
{selectedKey?.name || 'Access key'} Reveal and copy this key's credentials. The secret is fetched on demand.
{/* Key Settings Dialog */} } tone="primary" size="md" />
Key settings · {settingsKey?.name} Manage activation and expiration for this access key.
{(['active', 'inactive'] as const).map((s) => { const selected = keyStatus === s; return ( ); })}

Inactive keys cannot be used for authentication.

{!neverExpires && (
setExpirationDate(e.target.value)} className="w-full" />

The key will become inactive after this moment.

)}
{/* Key Details Dialog */} } tone="primary" size="md" />
{viewingKey?.name || 'API key'} View credentials and bucket permissions for this key.
{/* Meta strip */}
Status {viewingKey?.status}
Created {viewingKey && formatDate(viewingKey.createdAt)}
{viewingKey?.expiration && (
Expires {formatDate(viewingKey.expiration)}
)}
{/* Credentials */}
{/* Bucket Permissions */}
{viewingKey && viewingKey.permissions.length > 0 && ( {viewingKey.permissions.length} bucket {viewingKey.permissions.length === 1 ? '' : 's'} )}
{viewingKey && viewingKey.permissions.length > 0 ? (
{viewingKey.permissions.map((perm, idx) => (
{perm.bucketName}
{perm.read && Read} {perm.write && Write} {perm.owner && Owner}
))}
) : (

No bucket permissions yet.

)}
{/* Edit Permissions Dialog */} } tone="primary" size="md" />
Bucket permissions · {editingKey?.name} Select a bucket, then toggle the scopes this key should have on it.
{selectedBucket && ( <>
{[ { id: 'edit-permission-read', label: 'Read', desc: 'GetObject, HeadObject, ListObjects', checked: permissionRead, setChecked: setPermissionRead, }, { id: 'edit-permission-write', label: 'Write', desc: 'PutObject, DeleteObject', checked: permissionWrite, setChecked: setPermissionWrite, }, { id: 'edit-permission-owner', label: 'Owner', desc: 'DeleteBucket, PutBucketPolicy', checked: permissionOwner, setChecked: setPermissionOwner, }, ].map((p) => ( ))}
{editingKey && (() => { const current = editingKey.permissions.find( (perm) => perm.bucketName === selectedBucket || perm.bucketId === selectedBucket, ); const hasAny = current && (current.read || current.write || current.owner); return (
Currently granted
{hasAny ? (
{current!.read && Read} {current!.write && Write} {current!.owner && Owner}
) : (

No permissions on this bucket yet.

)}
); })()} )} {editingKey && editingKey.permissions.length > 0 && (
{editingKey.permissions.map((perm, idx) => (
{perm.bucketName}
{perm.read && R} {perm.write && W} {perm.owner && O}
))}
)}
); }