Files
garage-ui/frontend/src/pages/AccessControl.tsx
T

1193 lines
50 KiB
TypeScript

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 (
<div className="space-y-1.5">
<label className="text-[12px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
{label}
</label>
<div className="flex items-stretch gap-2">
<button
type="button"
onClick={copy}
disabled={loading || !value}
title="Click to copy"
className={cn(
'flex-1 min-w-0 rounded-md border border-[var(--border)] bg-[var(--surface-sunken)]',
'px-3 py-2 text-left text-[13.5px] transition-colors hover:bg-[var(--accent)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]',
'disabled:cursor-not-allowed disabled:opacity-70 disabled:hover:bg-[var(--surface-sunken)]',
mono && 'font-mono',
breakAll ? 'break-all' : 'truncate',
)}
>
{loading ? (
<span className="inline-flex items-center gap-2 text-[var(--muted-foreground)]">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading
</span>
) : (
display
)}
</button>
{maskable && (
<Button
variant="secondary"
size="icon"
onClick={() => setRevealed((r) => !r)}
aria-label={revealed ? 'Hide' : 'Reveal'}
disabled={loading || !value}
>
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
)}
<Button variant="secondary" size="icon" onClick={copy} aria-label={`Copy ${label}`} disabled={loading || !value}>
{copied ? <Check className="h-4 w-4 text-[var(--primary)]" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
);
}
export function AccessControl() {
const [keys, setKeys] = useState<AccessKey[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedKey, setSelectedKey] = useState<AccessKey | null>(null);
const [newKeyName, setNewKeyName] = useState('');
// Create key with permissions state
const [createAvailableBuckets, setCreateAvailableBuckets] = useState<Bucket[]>([]);
const [createSelectedBucket, setCreateSelectedBucket] = useState<string>('');
const [createPermissionRead, setCreatePermissionRead] = useState(false);
const [createPermissionWrite, setCreatePermissionWrite] = useState(false);
const [createPermissionOwner, setCreatePermissionOwner] = useState(false);
const [createGrantPermissions, setCreateGrantPermissions] = useState(false);
const [newlyCreatedKey, setNewlyCreatedKey] = useState<AccessKey | null>(null);
// Edit permissions state
const [editPermissionsDialogOpen, setEditPermissionsDialogOpen] = useState(false);
const [editingKey, setEditingKey] = useState<AccessKey | null>(null);
const [availableBuckets, setAvailableBuckets] = useState<Bucket[]>([]);
const [selectedBucket, setSelectedBucket] = useState<string>('');
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<AccessKey | null>(null);
const [keyStatus, setKeyStatus] = useState<'active' | 'inactive'>('active');
const [expirationDate, setExpirationDate] = useState<string>('');
const [neverExpires, setNeverExpires] = useState(true);
// Secret key dialog state
const [secretKeyDialogOpen, setSecretKeyDialogOpen] = useState(false);
const [revealedSecretKey, setRevealedSecretKey] = useState<string>('');
const [isLoadingSecretKey, setIsLoadingSecretKey] = useState(false);
// Key details dialog state
const [keyDetailsDialogOpen, setKeyDetailsDialogOpen] = useState(false);
const [viewingKey, setViewingKey] = useState<AccessKey | null>(null);
const [detailsSecretKey, setDetailsSecretKey] = useState<string>('');
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 (
<div>
<PageHeader title="Access control" subtitle="Access keys and per-bucket permissions" />
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
<Tabs defaultValue="keys">
<TabsContent value="keys" className="space-y-4 sm:space-y-6 mt-4 sm:mt-6">
{/* Stats */}
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Keys</CardTitle>
<Key className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{keys.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Keys</CardTitle>
<ShieldCheck className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{keys.filter((k) => k.status === 'active').length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Inactive Keys</CardTitle>
<ShieldX className="h-4 w-4 text-red-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{keys.filter((k) => k.status === 'inactive').length}
</div>
</CardContent>
</Card>
</div>
{/* Toolbar */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div className="relative flex-1 max-w-full sm:max-w-xs">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search keys..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
<Button onClick={handleOpenCreateDialog} className="w-full sm:w-auto">
<Plus className="h-4 w-4" />
Create Key
</Button>
</div>
{/* Keys Table */}
<div className="border rounded-lg overflow-visible">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="hidden sm:table-cell">Access Key ID</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden md:table-cell">Created</TableHead>
<TableHead className="hidden md:table-cell">Permissions</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-12">
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Loading API keys...</span>
</div>
</TableCell>
</TableRow>
) : filteredKeys.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-12 text-muted-foreground">
{searchQuery ? 'No keys found matching your search' : 'No API keys yet'}
</TableCell>
</TableRow>
) : (
filteredKeys.map((key) => (
<TableRow
key={key.accessKeyId}
onClick={() => handleRowClick(key)}
className="cursor-pointer hover:bg-muted/50"
>
<TableCell className="font-medium truncate max-w-[150px]">{key.name}</TableCell>
<TableCell className="hidden sm:table-cell">
<div className="flex items-center gap-2">
<code
className="text-xs bg-muted px-2 py-1 rounded truncate max-w-[150px] block cursor-pointer hover:bg-muted/80 transition-colors"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(key.accessKeyId);
toast.success('Access Key ID copied to clipboard');
}}
>
{key.accessKeyId}
</code>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(key.accessKeyId);
toast.success('Access Key ID copied to clipboard');
}}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</TableCell>
<TableCell>
<Badge variant={key.status === 'active' ? 'primary' : 'neutral'}>
{key.status}
</Badge>
</TableCell>
<TableCell className="hidden md:table-cell">{formatDate(key.createdAt)}</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex flex-wrap gap-1">
{key.permissions.slice(0, 2).map((perm, idx) => (
<Badge key={idx} variant="neutral" className="text-xs">
{perm.bucketName}: {formatPermissions(perm)}
</Badge>
))}
{key.permissions.length > 2 && (
<Badge variant="neutral" className="text-xs">
+{key.permissions.length - 2} more
</Badge>
)}
{key.permissions.length === 0 && (
<span className="text-xs text-muted-foreground">No permissions</span>
)}
</div>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleRevealSecretKey(key)}>
<Key className="h-4 w-4" />
View Secret Key
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleOpenEditPermissions(key)}>
<Edit className="h-4 w-4" />
Edit Permissions
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleOpenSettings(key)}>
{key.status === 'active' ? (
<>
<ShieldX className="h-4 w-4" />
Manage Status
</>
) : (
<>
<ShieldCheck className="h-4 w-4" />
Manage Status
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => {
setSelectedKey(key);
setDeleteDialogOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</TabsContent>
</Tabs>
</div>
{/* Create Key Dialog */}
<Dialog open={createDialogOpen} onOpenChange={handleCloseCreateDialog} size="form">
<DialogContent>
{newlyCreatedKey ? (
<>
<DialogHeader>
<IconTile icon={<ShieldCheck />} tone="primary" size="md" />
<div className="min-w-0 flex-1">
<DialogTitle>API key created</DialogTitle>
<DialogDescription>
Copy your secret access key now this is the only time it will be shown.
</DialogDescription>
</div>
</DialogHeader>
<DialogBody className="space-y-5">
<div className="space-y-1.5">
<label className="text-[12px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
Key name
</label>
<div className="text-[14px] font-medium">{newlyCreatedKey.name}</div>
</div>
<CredentialField label="Access Key ID" value={newlyCreatedKey.accessKeyId} />
<CredentialField
label="Secret Access Key"
value={newlyCreatedKey.secretKey || ''}
breakAll
/>
<div className="flex gap-3 rounded-lg border border-[var(--accent-primary-border)] bg-[var(--accent-primary-soft)] px-3.5 py-3">
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-[var(--primary)] mt-0.5" />
<div className="space-y-0.5">
<p className="text-[13.5px] font-medium text-[var(--foreground)]">
Save this key now
</p>
<p className="text-[12.5px] leading-[1.5] text-[var(--muted-foreground)]">
The secret access key cannot be retrieved again. If lost, you'll need to create a new key.
</p>
</div>
</div>
</DialogBody>
<DialogFooter>
<Button onClick={handleCloseCreateDialog}>Done</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<IconTile icon={<KeyRound />} tone="primary" size="md" />
<div className="min-w-0 flex-1">
<DialogTitle>Create API key</DialogTitle>
<DialogDescription>
Generate a new access key pair. You can optionally grant bucket permissions in the same step.
</DialogDescription>
</div>
</DialogHeader>
<DialogBody className="space-y-6">
<div className="space-y-1.5">
<label htmlFor="new-key-name" className="text-[13px] font-medium">
Key name
</label>
<Input
id="new-key-name"
placeholder="e.g. backups-prod"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
autoFocus
/>
<p className="text-[12.5px] text-[var(--muted-foreground)]">
A friendly name to identify this key in the console.
</p>
</div>
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
<label className="flex cursor-pointer items-start gap-3">
<Checkbox
id="grant-permissions-on-create"
checked={createGrantPermissions}
className="mt-0.5"
onCheckedChange={(checked) => {
setCreateGrantPermissions(checked as boolean);
if (!checked) {
setCreateSelectedBucket('');
setCreatePermissionRead(false);
setCreatePermissionWrite(false);
setCreatePermissionOwner(false);
}
}}
/>
<div className="flex-1">
<div className="text-[13.5px] font-medium">Grant bucket permissions now</div>
<p className="mt-0.5 text-[12.5px] text-[var(--muted-foreground)]">
Optional — you can also do this later from the key's edit menu.
</p>
</div>
</label>
{createGrantPermissions && (
<div className="space-y-4 border-t border-[var(--border)] pt-4">
<div className="space-y-1.5">
<label className="text-[13px] font-medium">Bucket</label>
<Select
value={createSelectedBucket}
onChange={(value) => setCreateSelectedBucket(value)}
>
<SelectOption value="">Select a bucket</SelectOption>
{createAvailableBuckets.map((bucket) => (
<SelectOption key={bucket.name} value={bucket.name}>
{bucket.name}
</SelectOption>
))}
</Select>
</div>
{createSelectedBucket && (
<div className="space-y-1.5">
<label className="text-[13px] font-medium">Permissions</label>
<div className="divide-y divide-[var(--border)] rounded-md border border-[var(--border)]">
{[
{
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) => (
<label
key={p.id}
htmlFor={p.id}
className="flex cursor-pointer items-start gap-3 px-3.5 py-3 transition-colors hover:bg-[var(--accent)]"
>
<Checkbox
id={p.id}
checked={p.checked}
className="mt-0.5"
onCheckedChange={(checked) => p.setChecked(checked as boolean)}
/>
<div className="flex-1">
<div className="text-[13.5px] font-medium">{p.label}</div>
<p className="mt-0.5 font-mono text-[12px] text-[var(--muted-foreground)]">
{p.desc}
</p>
</div>
</label>
))}
</div>
</div>
)}
</div>
)}
</div>
</DialogBody>
<DialogFooter>
<Button variant="secondary" onClick={handleCloseCreateDialog}>
Cancel
</Button>
<Button onClick={handleCreateKey} disabled={!newKeyName}>
Create key
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
{/* Delete Key Dialog */}
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title={`Delete "${selectedKey?.name ?? ''}"?`}
description="Applications using this key will lose access immediately."
confirmLabel="Delete key"
onConfirm={handleDeleteKey}
/>
{/* Secret Key Dialog */}
<Dialog open={secretKeyDialogOpen} onOpenChange={setSecretKeyDialogOpen} size="form">
<DialogContent>
<DialogHeader>
<IconTile icon={<KeyRound />} tone="primary" size="md" />
<div className="min-w-0 flex-1">
<DialogTitle className="truncate">{selectedKey?.name || 'Access key'}</DialogTitle>
<DialogDescription>
Reveal and copy this key's credentials. The secret is fetched on demand.
</DialogDescription>
</div>
</DialogHeader>
<DialogBody className="space-y-4">
<CredentialField
label="Access Key ID"
value={selectedKey?.accessKeyId || ''}
breakAll
/>
<CredentialField
label="Secret Access Key"
value={revealedSecretKey}
breakAll
maskable
loading={isLoadingSecretKey}
/>
</DialogBody>
<DialogFooter>
<Button onClick={() => setSecretKeyDialogOpen(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Key Settings Dialog */}
<Dialog open={settingsDialogOpen} onOpenChange={setSettingsDialogOpen} size="form">
<DialogContent>
<DialogHeader>
<IconTile icon={<ShieldCheck />} tone="primary" size="md" />
<div className="min-w-0 flex-1">
<DialogTitle className="truncate">Key settings · {settingsKey?.name}</DialogTitle>
<DialogDescription>Manage activation and expiration for this access key.</DialogDescription>
</div>
</DialogHeader>
<DialogBody className="space-y-6">
<div className="space-y-2">
<label className="text-[12px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
Status
</label>
<div className="grid grid-cols-2 gap-2">
{(['active', 'inactive'] as const).map((s) => {
const selected = keyStatus === s;
return (
<button
key={s}
type="button"
onClick={() => setKeyStatus(s)}
className={cn(
'flex items-center justify-center gap-2 rounded-md border px-3 py-2.5 text-[13.5px] font-medium transition-colors',
selected
? 'border-[var(--primary)] bg-[var(--accent-primary-soft)] text-[var(--foreground)]'
: 'border-[var(--border)] text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)]',
)}
>
{s === 'active' ? <ShieldCheck className="h-4 w-4" /> : <ShieldX className="h-4 w-4" />}
{s[0].toUpperCase() + s.slice(1)}
</button>
);
})}
</div>
<p className="text-[12.5px] text-[var(--muted-foreground)]">
Inactive keys cannot be used for authentication.
</p>
</div>
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
<label className="flex cursor-pointer items-start gap-3">
<Checkbox
id="never-expires"
checked={neverExpires}
className="mt-0.5"
onCheckedChange={(checked) => setNeverExpires(checked as boolean)}
/>
<div className="flex-1">
<div className="text-[13.5px] font-medium">Never expires</div>
<p className="mt-0.5 text-[12.5px] text-[var(--muted-foreground)]">
Turn off to set an automatic expiration date.
</p>
</div>
</label>
{!neverExpires && (
<div className="space-y-1.5 border-t border-[var(--border)] pt-4">
<label className="text-[13px] font-medium">Expiration date &amp; time</label>
<Input
type="datetime-local"
value={expirationDate}
onChange={(e) => setExpirationDate(e.target.value)}
className="w-full"
/>
<p className="text-[12.5px] text-[var(--muted-foreground)]">
The key will become inactive after this moment.
</p>
</div>
)}
</div>
</DialogBody>
<DialogFooter>
<Button variant="secondary" onClick={() => setSettingsDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSaveKeySettings}>Save settings</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Key Details Dialog */}
<Dialog open={keyDetailsDialogOpen} onOpenChange={setKeyDetailsDialogOpen} size="form">
<DialogContent>
<DialogHeader>
<IconTile icon={<KeyRound />} tone="primary" size="md" />
<div className="min-w-0 flex-1">
<DialogTitle className="truncate">{viewingKey?.name || 'API key'}</DialogTitle>
<DialogDescription>View credentials and bucket permissions for this key.</DialogDescription>
</div>
</DialogHeader>
<DialogBody className="space-y-5">
{/* Meta strip */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 rounded-lg border border-[var(--border)] bg-[var(--surface-sunken)] px-4 py-3">
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
Status
</span>
<Badge variant={viewingKey?.status === 'active' ? 'success' : 'neutral'}>
{viewingKey?.status}
</Badge>
</div>
<div className="flex items-center gap-2 text-[13px] text-[var(--muted-foreground)]">
<Calendar className="h-3.5 w-3.5" />
<span className="text-[11px] font-medium uppercase tracking-[0.06em]">Created</span>
<span className="text-[var(--foreground)]">
{viewingKey && formatDate(viewingKey.createdAt)}
</span>
</div>
{viewingKey?.expiration && (
<div className="flex items-center gap-2 text-[13px] text-[var(--muted-foreground)]">
<Calendar className="h-3.5 w-3.5" />
<span className="text-[11px] font-medium uppercase tracking-[0.06em]">Expires</span>
<span className="text-[var(--foreground)]">{formatDate(viewingKey.expiration)}</span>
</div>
)}
</div>
{/* Credentials */}
<div className="space-y-4">
<CredentialField
label="Access Key ID"
value={viewingKey?.accessKeyId || ''}
breakAll
/>
<CredentialField
label="Secret Access Key"
value={detailsSecretKey}
breakAll
maskable
loading={isLoadingDetailsSecretKey}
/>
</div>
{/* Bucket Permissions */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[12px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
Bucket permissions
</label>
{viewingKey && viewingKey.permissions.length > 0 && (
<span className="text-[12px] text-[var(--muted-foreground)]">
{viewingKey.permissions.length} bucket
{viewingKey.permissions.length === 1 ? '' : 's'}
</span>
)}
</div>
{viewingKey && viewingKey.permissions.length > 0 ? (
<div className="divide-y divide-[var(--border)] overflow-hidden rounded-lg border border-[var(--border)]">
{viewingKey.permissions.map((perm, idx) => (
<div
key={idx}
className="flex items-center justify-between gap-3 px-3.5 py-2.5"
>
<div className="flex min-w-0 items-center gap-2.5">
<Database className="h-3.5 w-3.5 flex-shrink-0 text-[var(--muted-foreground)]" />
<span className="truncate font-mono text-[13px]">{perm.bucketName}</span>
</div>
<div className="flex flex-shrink-0 gap-1">
{perm.read && <Badge variant="neutral">Read</Badge>}
{perm.write && <Badge variant="neutral">Write</Badge>}
{perm.owner && <Badge variant="warning">Owner</Badge>}
</div>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-dashed border-[var(--border)] px-4 py-6 text-center">
<p className="text-[13px] text-[var(--muted-foreground)]">
No bucket permissions yet.
</p>
</div>
)}
</div>
</DialogBody>
<DialogFooter>
<Button
variant="secondary"
onClick={() => {
setKeyDetailsDialogOpen(false);
if (viewingKey) {
handleOpenEditPermissions(viewingKey);
}
}}
>
<Edit className="h-4 w-4" />
Edit permissions
</Button>
<Button onClick={() => setKeyDetailsDialogOpen(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Permissions Dialog */}
<Dialog open={editPermissionsDialogOpen} onOpenChange={setEditPermissionsDialogOpen} size="form">
<DialogContent>
<DialogHeader>
<IconTile icon={<Edit />} tone="primary" size="md" />
<div className="min-w-0 flex-1">
<DialogTitle className="truncate">Bucket permissions · {editingKey?.name}</DialogTitle>
<DialogDescription>
Select a bucket, then toggle the scopes this key should have on it.
</DialogDescription>
</div>
</DialogHeader>
<DialogBody className="space-y-5">
<div className="space-y-1.5">
<label className="text-[13px] font-medium">Bucket</label>
<Select value={selectedBucket} onChange={(value) => handleBucketChange(value)}>
<SelectOption value="">Select a bucket…</SelectOption>
{availableBuckets.map((bucket) => (
<SelectOption key={bucket.name} value={bucket.name}>
{bucket.name}
</SelectOption>
))}
</Select>
</div>
{selectedBucket && (
<>
<div className="space-y-1.5">
<label className="text-[13px] font-medium">Permissions</label>
<div className="divide-y divide-[var(--border)] rounded-md border border-[var(--border)]">
{[
{
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) => (
<label
key={p.id}
htmlFor={p.id}
className="flex cursor-pointer items-start gap-3 px-3.5 py-3 transition-colors hover:bg-[var(--accent)]"
>
<Checkbox
id={p.id}
checked={p.checked}
className="mt-0.5"
onCheckedChange={(checked) => p.setChecked(checked as boolean)}
/>
<div className="flex-1">
<div className="text-[13.5px] font-medium">{p.label}</div>
<p className="mt-0.5 font-mono text-[12px] text-[var(--muted-foreground)]">
{p.desc}
</p>
</div>
</label>
))}
</div>
</div>
{editingKey && (() => {
const current = editingKey.permissions.find(
(perm) => perm.bucketName === selectedBucket || perm.bucketId === selectedBucket,
);
const hasAny = current && (current.read || current.write || current.owner);
return (
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-sunken)] px-3.5 py-3">
<div className="text-[11px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
Currently granted
</div>
{hasAny ? (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{current!.read && <Badge variant="neutral">Read</Badge>}
{current!.write && <Badge variant="neutral">Write</Badge>}
{current!.owner && <Badge variant="warning">Owner</Badge>}
</div>
) : (
<p className="mt-1 text-[12.5px] text-[var(--muted-foreground)]">
No permissions on this bucket yet.
</p>
)}
</div>
);
})()}
</>
)}
{editingKey && editingKey.permissions.length > 0 && (
<div className="space-y-1.5">
<label className="text-[12px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
All bucket permissions for this key
</label>
<div className="max-h-48 divide-y divide-[var(--border)] overflow-y-auto rounded-md border border-[var(--border)]">
{editingKey.permissions.map((perm, idx) => (
<div key={idx} className="flex items-center justify-between gap-3 px-3.5 py-2.5">
<span className="truncate font-mono text-[13px]">{perm.bucketName}</span>
<div className="flex flex-shrink-0 gap-1">
{perm.read && <Badge variant="neutral">R</Badge>}
{perm.write && <Badge variant="neutral">W</Badge>}
{perm.owner && <Badge variant="warning">O</Badge>}
</div>
</div>
))}
</div>
</div>
)}
</DialogBody>
<DialogFooter>
<Button variant="secondary" onClick={() => setEditPermissionsDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleGrantBucketPermission} disabled={!selectedBucket || savingPermissions}>
{savingPermissions && <Loader2 className="h-4 w-4 animate-spin" />}
{savingPermissions ? 'Saving...' : 'Save permissions'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}