add function to s3

This commit is contained in:
2026-01-10 05:36:15 +00:00
parent 7b91e0fd24
commit 8a3ff6a12c
19 changed files with 3715 additions and 134 deletions

View File

@@ -0,0 +1,145 @@
import apiClient from './client'
export interface Bucket {
name: string
creation_date: string
size: number
objects: number
access_policy: 'private' | 'public-read' | 'public-read-write'
}
export const objectStorageApi = {
listBuckets: async (): Promise<Bucket[]> => {
const response = await apiClient.get<{ buckets: Bucket[] }>('/object-storage/buckets')
return response.data.buckets || []
},
getBucket: async (name: string): Promise<Bucket> => {
const response = await apiClient.get<Bucket>(`/object-storage/buckets/${encodeURIComponent(name)}`)
return response.data
},
createBucket: async (name: string): Promise<void> => {
await apiClient.post('/object-storage/buckets', { name })
},
deleteBucket: async (name: string): Promise<void> => {
await apiClient.delete(`/object-storage/buckets/${encodeURIComponent(name)}`)
},
// Setup endpoints
getAvailableDatasets: async (): Promise<PoolDatasetInfo[]> => {
const response = await apiClient.get<{ pools: PoolDatasetInfo[] }>('/object-storage/setup/datasets')
return response.data.pools || []
},
getCurrentSetup: async (): Promise<CurrentSetup | null> => {
const response = await apiClient.get<{ configured: boolean; setup?: SetupResponse }>('/object-storage/setup/current')
if (!response.data.configured || !response.data.setup) {
return null
}
return {
dataset_path: response.data.setup.dataset_path,
mount_point: response.data.setup.mount_point,
}
},
setupObjectStorage: async (poolName: string, datasetName: string, createNew: boolean): Promise<SetupResponse> => {
const response = await apiClient.post<SetupResponse>('/object-storage/setup', {
pool_name: poolName,
dataset_name: datasetName,
create_new: createNew,
})
return response.data
},
updateObjectStorage: async (poolName: string, datasetName: string, createNew: boolean): Promise<SetupResponse> => {
const response = await apiClient.put<SetupResponse>('/object-storage/setup', {
pool_name: poolName,
dataset_name: datasetName,
create_new: createNew,
})
return response.data
},
// User management
listUsers: async (): Promise<User[]> => {
const response = await apiClient.get<{ users: User[] }>('/object-storage/users')
return response.data.users || []
},
createUser: async (data: CreateUserRequest): Promise<void> => {
await apiClient.post('/object-storage/users', data)
},
deleteUser: async (accessKey: string): Promise<void> => {
await apiClient.delete(`/object-storage/users/${encodeURIComponent(accessKey)}`)
},
// Service account (access key) management
listServiceAccounts: async (): Promise<ServiceAccount[]> => {
const response = await apiClient.get<{ service_accounts: ServiceAccount[] }>('/object-storage/service-accounts')
return response.data.service_accounts || []
},
createServiceAccount: async (data: CreateServiceAccountRequest): Promise<ServiceAccount> => {
const response = await apiClient.post<ServiceAccount>('/object-storage/service-accounts', data)
return response.data
},
deleteServiceAccount: async (accessKey: string): Promise<void> => {
await apiClient.delete(`/object-storage/service-accounts/${encodeURIComponent(accessKey)}`)
},
}
export interface PoolDatasetInfo {
pool_id: string
pool_name: string
datasets: DatasetInfo[]
}
export interface DatasetInfo {
id: string
name: string
full_name: string
mount_point: string
type: string
used_bytes: number
available_bytes: number
}
export interface SetupResponse {
dataset_path: string
mount_point: string
message: string
}
export interface CurrentSetup {
dataset_path: string
mount_point: string
}
export interface User {
access_key: string
status: 'enabled' | 'disabled'
created_at: string
}
export interface ServiceAccount {
access_key: string
secret_key?: string // Only returned on creation
parent_user: string
expiration?: string
created_at: string
}
export interface CreateUserRequest {
access_key: string
secret_key: string
}
export interface CreateServiceAccountRequest {
parent_user: string
policy?: string
expiration?: string // ISO 8601 format
}

View File

@@ -84,5 +84,9 @@ export const systemAPI = {
const response = await apiClient.get<{ data: NetworkDataPoint[] }>(`/system/network/throughput?duration=${duration}`)
return response.data.data || []
},
getManagementIPAddress: async (): Promise<string> => {
const response = await apiClient.get<{ ip_address: string }>('/system/management-ip')
return response.data.ip_address
},
}

View File

@@ -1,13 +1,15 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { formatBytes } from '@/lib/format'
import { objectStorageApi, PoolDatasetInfo, CurrentSetup } from '@/api/objectStorage'
import UsersAndKeys from './UsersAndKeys'
import { systemAPI } from '@/api/system'
import {
Folder,
Share2,
Globe,
Search,
Plus,
MoreVertical,
CheckCircle2,
HardDrive,
Database,
@@ -18,73 +20,182 @@ import {
Settings,
Users,
Activity,
Filter
Filter,
RefreshCw,
Trash2,
AlertCircle
} from 'lucide-react'
// Mock data - will be replaced with API calls
const MOCK_BUCKETS = [
{
id: '1',
name: 'backup-archive-01',
type: 'immutable',
usage: 4.2 * 1024 * 1024 * 1024 * 1024, // 4.2 TB in bytes
usagePercent: 75,
objects: 14200,
accessPolicy: 'private',
created: '2023-10-24',
color: 'blue',
},
{
id: '2',
name: 'daily-snapshots',
type: 'standard',
usage: 120 * 1024 * 1024 * 1024, // 120 GB
usagePercent: 15,
objects: 400,
accessPolicy: 'private',
created: '2023-11-01',
color: 'purple',
},
{
id: '3',
name: 'public-assets',
type: 'standard',
usage: 500 * 1024 * 1024 * 1024, // 500 GB
usagePercent: 30,
objects: 12050,
accessPolicy: 'public-read',
created: '2023-12-15',
color: 'orange',
},
{
id: '4',
name: 'logs-retention',
type: 'archive',
usage: 2.1 * 1024 * 1024 * 1024 * 1024, // 2.1 TB
usagePercent: 55,
objects: 850221,
accessPolicy: 'private',
created: '2024-01-10',
color: 'blue',
},
]
const S3_ENDPOINT = 'https://s3.appliance.local:9000'
const S3_PORT = 9000
export default function ObjectStorage() {
const [activeTab, setActiveTab] = useState<'buckets' | 'users' | 'monitoring' | 'settings'>('buckets')
const [searchQuery, setSearchQuery] = useState('')
const [currentPage, setCurrentPage] = useState(1)
const [showSetupModal, setShowSetupModal] = useState(false)
const [selectedPool, setSelectedPool] = useState<string>('')
const [selectedDataset, setSelectedDataset] = useState<string>('')
const [createNewDataset, setCreateNewDataset] = useState(false)
const [newDatasetName, setNewDatasetName] = useState('')
const [isRefreshingBuckets, setIsRefreshingBuckets] = useState(false)
const [showCreateBucketModal, setShowCreateBucketModal] = useState(false)
const [newBucketName, setNewBucketName] = useState('')
const [deleteConfirmBucket, setDeleteConfirmBucket] = useState<string | null>(null)
const itemsPerPage = 10
const queryClient = useQueryClient()
// Mock queries - replace with real API calls
const { data: buckets = MOCK_BUCKETS } = useQuery({
queryKey: ['object-storage-buckets'],
queryFn: async () => MOCK_BUCKETS,
// Fetch management IP address
const { data: managementIP = 'localhost' } = useQuery<string>({
queryKey: ['system-management-ip'],
queryFn: systemAPI.getManagementIPAddress,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
retry: 2,
})
// Construct S3 endpoint with management IP
const S3_ENDPOINT = `http://${managementIP}:${S3_PORT}`
// Fetch buckets from API
const { data: buckets = [], isLoading: bucketsLoading } = useQuery({
queryKey: ['object-storage-buckets'],
queryFn: objectStorageApi.listBuckets,
refetchInterval: 5000, // Auto-refresh every 5 seconds
staleTime: 0,
})
// Fetch available datasets for setup
const { data: availableDatasets = [] } = useQuery<PoolDatasetInfo[]>({
queryKey: ['object-storage-setup-datasets'],
queryFn: objectStorageApi.getAvailableDatasets,
enabled: showSetupModal, // Only fetch when modal is open
})
// Fetch current setup
const { data: currentSetup } = useQuery<CurrentSetup | null>({
queryKey: ['object-storage-current-setup'],
queryFn: objectStorageApi.getCurrentSetup,
})
// Setup mutation
const setupMutation = useMutation({
mutationFn: ({ poolName, datasetName, createNew }: { poolName: string; datasetName: string; createNew: boolean }) =>
currentSetup
? objectStorageApi.updateObjectStorage(poolName, datasetName, createNew)
: objectStorageApi.setupObjectStorage(poolName, datasetName, createNew),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['object-storage-current-setup'] })
queryClient.invalidateQueries({ queryKey: ['object-storage-buckets'] })
setShowSetupModal(false)
if (currentSetup) {
alert(`Object storage dataset updated successfully!\n\n${data.message}\n\n⚠ IMPORTANT: Existing data in the previous dataset is NOT automatically migrated. You may need to manually migrate data or restart MinIO service to use the new dataset.`)
} else {
alert('Object storage setup completed successfully!')
}
},
onError: (error: any) => {
alert(error.response?.data?.error || `Failed to ${currentSetup ? 'update' : 'setup'} object storage`)
},
})
// Create bucket mutation with optimistic update
const createBucketMutation = useMutation({
mutationFn: (bucketName: string) => objectStorageApi.createBucket(bucketName),
onMutate: async (bucketName: string) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['object-storage-buckets'] })
// Snapshot the previous value
const previousBuckets = queryClient.getQueryData(['object-storage-buckets'])
// Optimistically add the new bucket to the list
queryClient.setQueryData(['object-storage-buckets'], (old: any[] = []) => {
const newBucket = {
name: bucketName,
creation_date: new Date().toISOString(),
size: 0,
objects: 0,
access_policy: 'private' as const,
}
return [...old, newBucket]
})
// Close modal immediately
setShowCreateBucketModal(false)
setNewBucketName('')
// Return a context object with the snapshotted value
return { previousBuckets }
},
onError: (error: any, _bucketName: string, context: any) => {
// If the mutation fails, use the context returned from onMutate to roll back
if (context?.previousBuckets) {
queryClient.setQueryData(['object-storage-buckets'], context.previousBuckets)
}
// Reopen modal on error
setShowCreateBucketModal(true)
alert(error.response?.data?.error || 'Failed to create bucket')
},
onSuccess: () => {
// Refetch to ensure we have the latest data from server
queryClient.invalidateQueries({ queryKey: ['object-storage-buckets'] })
alert('Bucket created successfully!')
},
onSettled: () => {
// Always refetch after error or success to ensure consistency
queryClient.invalidateQueries({ queryKey: ['object-storage-buckets'] })
},
})
// Delete bucket mutation with optimistic update
const deleteBucketMutation = useMutation({
mutationFn: (bucketName: string) => objectStorageApi.deleteBucket(bucketName),
onMutate: async (bucketName: string) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['object-storage-buckets'] })
// Snapshot the previous value
const previousBuckets = queryClient.getQueryData(['object-storage-buckets'])
// Optimistically update to the new value
queryClient.setQueryData(['object-storage-buckets'], (old: any[] = []) =>
old.filter((bucket: any) => bucket.name !== bucketName)
)
// Return a context object with the snapshotted value
return { previousBuckets }
},
onError: (error: any, _bucketName: string, context: any) => {
// If the mutation fails, use the context returned from onMutate to roll back
if (context?.previousBuckets) {
queryClient.setQueryData(['object-storage-buckets'], context.previousBuckets)
}
alert(error.response?.data?.error || 'Failed to delete bucket')
},
onSuccess: () => {
// Refetch to ensure we have the latest data
queryClient.invalidateQueries({ queryKey: ['object-storage-buckets'] })
},
onSettled: () => {
// Always refetch after error or success to ensure consistency
queryClient.invalidateQueries({ queryKey: ['object-storage-buckets'] })
},
})
// Transform buckets from API to UI format
const transformedBuckets = buckets.map((bucket, index) => ({
id: bucket.name,
name: bucket.name,
type: 'standard' as const, // Default type, can be enhanced later
usage: bucket.size,
usagePercent: 0, // Will be calculated if we have quota info
objects: bucket.objects,
accessPolicy: bucket.access_policy,
created: bucket.creation_date,
color: index % 3 === 0 ? 'blue' : index % 3 === 1 ? 'purple' : 'orange',
}))
// Filter buckets by search query
const filteredBuckets = buckets.filter(bucket =>
const filteredBuckets = transformedBuckets.filter(bucket =>
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
)
@@ -96,23 +207,38 @@ export default function ObjectStorage() {
)
// Calculate totals
const totalUsage = buckets.reduce((sum, b) => sum + b.usage, 0)
const totalObjects = buckets.reduce((sum, b) => sum + b.objects, 0)
const totalUsage = transformedBuckets.reduce((sum, b) => sum + b.usage, 0)
const totalObjects = transformedBuckets.reduce((sum, b) => sum + b.objects, 0)
// Copy endpoint to clipboard
const copyEndpoint = () => {
navigator.clipboard.writeText(S3_ENDPOINT)
// You could add a toast notification here
const copyEndpoint = async () => {
try {
await navigator.clipboard.writeText(S3_ENDPOINT)
alert('Endpoint copied to clipboard!')
} catch (error) {
console.error('Failed to copy endpoint:', error)
// Fallback: select text
const textArea = document.createElement('textarea')
textArea.value = S3_ENDPOINT
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
document.body.appendChild(textArea)
textArea.select()
try {
document.execCommand('copy')
alert('Endpoint copied to clipboard!')
} catch (err) {
alert(`Failed to copy. Endpoint: ${S3_ENDPOINT}`)
}
document.body.removeChild(textArea)
}
}
// Get bucket icon
const getBucketIcon = (bucket: typeof MOCK_BUCKETS[0]) => {
if (bucket.accessPolicy === 'public-read') {
const getBucketIcon = (bucket: typeof transformedBuckets[0]) => {
if (bucket.accessPolicy === 'public-read' || bucket.accessPolicy === 'public-read-write') {
return <Globe className="text-orange-500" size={20} />
}
if (bucket.type === 'immutable') {
return <Folder className="text-blue-500" size={20} />
}
return <Share2 className="text-purple-500" size={20} />
}
@@ -138,6 +264,13 @@ export default function ObjectStorage() {
// Get access policy badge
const getAccessPolicyBadge = (policy: string) => {
if (policy === 'public-read-write') {
return (
<span className="inline-flex items-center rounded-full bg-red-500/10 px-2.5 py-0.5 text-xs font-medium text-red-500 border border-red-500/20">
Public Read/Write
</span>
)
}
if (policy === 'public-read') {
return (
<span className="inline-flex items-center rounded-full bg-orange-500/10 px-2.5 py-0.5 text-xs font-medium text-orange-500 border border-orange-500/20">
@@ -158,6 +291,30 @@ export default function ObjectStorage() {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
// Copy to clipboard helper
const copyToClipboard = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text)
alert(`${label} copied to clipboard!`)
} catch (error) {
console.error('Failed to copy:', error)
// Fallback: select text
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
document.body.appendChild(textArea)
textArea.select()
try {
document.execCommand('copy')
alert(`${label} copied to clipboard!`)
} catch (err) {
alert(`Failed to copy. ${label}: ${text}`)
}
document.body.removeChild(textArea)
}
}
return (
<div className="flex-1 overflow-hidden flex flex-col bg-background-dark">
{/* Main Content */}
@@ -174,6 +331,46 @@ export default function ObjectStorage() {
</p>
</div>
<div className="flex gap-3">
<button
onClick={async () => {
setIsRefreshingBuckets(true)
try {
await queryClient.invalidateQueries({ queryKey: ['object-storage-buckets'] })
await queryClient.refetchQueries({ queryKey: ['object-storage-buckets'] })
// Small delay to show feedback
await new Promise(resolve => setTimeout(resolve, 300))
alert('Buckets refreshed successfully!')
} catch (error) {
console.error('Failed to refresh buckets:', error)
alert('Failed to refresh buckets. Please try again.')
} finally {
setIsRefreshingBuckets(false)
}
}}
disabled={bucketsLoading || isRefreshingBuckets}
className="flex h-10 items-center justify-center rounded-lg border border-border-dark px-4 text-white text-sm font-medium hover:bg-[#233648] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Refresh buckets list"
>
<RefreshCw className={`mr-2 ${isRefreshingBuckets ? 'animate-spin' : ''}`} size={20} />
{isRefreshingBuckets ? 'Refreshing...' : 'Refresh Buckets'}
</button>
{!currentSetup ? (
<button
onClick={() => setShowSetupModal(true)}
className="flex h-10 items-center justify-center rounded-lg bg-primary px-4 text-white text-sm font-medium hover:bg-blue-600 transition-colors"
>
<Plus className="mr-2" size={20} />
Setup Object Storage
</button>
) : (
<button
onClick={() => setShowSetupModal(true)}
className="flex h-10 items-center justify-center rounded-lg bg-orange-500 px-4 text-white text-sm font-medium hover:bg-orange-600 transition-colors"
>
<Settings className="mr-2" size={20} />
Change Dataset
</button>
)}
<button className="flex h-10 items-center justify-center rounded-lg border border-border-dark px-4 text-white text-sm font-medium hover:bg-[#233648] transition-colors">
<FileText className="mr-2" size={20} />
Documentation
@@ -332,7 +529,10 @@ export default function ObjectStorage() {
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<button className="flex items-center justify-center gap-2 bg-primary hover:bg-blue-600 text-white px-5 py-2.5 rounded-lg text-sm font-bold transition-colors w-full md:w-auto shadow-lg shadow-blue-900/20">
<button
onClick={() => setShowCreateBucketModal(true)}
className="flex items-center justify-center gap-2 bg-primary hover:bg-blue-600 text-white px-5 py-2.5 rounded-lg text-sm font-bold transition-colors w-full md:w-auto shadow-lg shadow-blue-900/20"
>
<Plus size={20} />
Create Bucket
</button>
@@ -368,8 +568,21 @@ export default function ObjectStorage() {
</tr>
</thead>
<tbody className="divide-y divide-border-dark">
{paginatedBuckets.map((bucket) => (
<tr key={bucket.id} className="group hover:bg-[#233648] transition-colors">
{bucketsLoading ? (
<tr className="bg-[#151d26]">
<td colSpan={6} className="py-8 px-6 text-center text-text-secondary text-sm">
Loading buckets...
</td>
</tr>
) : paginatedBuckets.length === 0 ? (
<tr className="bg-[#151d26]">
<td colSpan={6} className="py-8 px-6 text-center text-text-secondary text-sm">
No buckets found
</td>
</tr>
) : (
paginatedBuckets.map((bucket) => (
<tr key={bucket.id} className="group hover:bg-[#233648] transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-3">
<div
@@ -405,13 +618,38 @@ export default function ObjectStorage() {
<td className="px-6 py-4 whitespace-nowrap">
<p className="text-text-secondary text-sm">{formatDate(bucket.created)}</p>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<button className="text-text-secondary hover:text-white transition-colors p-2 rounded hover:bg-white/5">
<MoreVertical size={18} />
</button>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => copyToClipboard(bucket.name, 'Bucket Name')}
className="px-3 py-1.5 text-xs font-medium text-white bg-[#233648] hover:bg-[#2b4055] border border-border-dark rounded-lg transition-colors flex items-center gap-1.5"
title="Copy Bucket Name"
>
<Copy size={14} />
Copy Name
</button>
<button
onClick={() => copyToClipboard(`${S3_ENDPOINT}/${bucket.name}`, 'Bucket Endpoint')}
className="px-3 py-1.5 text-xs font-medium text-white bg-[#233648] hover:bg-[#2b4055] border border-border-dark rounded-lg transition-colors flex items-center gap-1.5"
title="Copy Bucket Endpoint"
>
<LinkIcon size={14} />
Copy Endpoint
</button>
<button
onClick={() => setDeleteConfirmBucket(bucket.name)}
disabled={deleteBucketMutation.isPending}
className="px-3 py-1.5 text-xs font-medium text-red-400 bg-red-500/10 hover:bg-red-500/20 border border-red-500/20 rounded-lg transition-colors flex items-center gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
title="Delete Bucket"
>
<Trash2 size={14} />
Delete
</button>
</div>
</td>
</tr>
))}
))
)}
</tbody>
</table>
</div>
@@ -445,12 +683,7 @@ export default function ObjectStorage() {
</>
)}
{activeTab === 'users' && (
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-8 text-center">
<Users className="mx-auto mb-4 text-text-secondary" size={48} />
<p className="text-text-secondary text-sm">Users & Keys management coming soon</p>
</div>
)}
{activeTab === 'users' && <UsersAndKeys S3_ENDPOINT={S3_ENDPOINT} />}
{activeTab === 'monitoring' && (
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-8 text-center">
@@ -469,6 +702,256 @@ export default function ObjectStorage() {
</div>
<div className="h-12 w-full shrink-0"></div>
</main>
{/* Create Bucket Modal */}
{showCreateBucketModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center justify-between mb-6">
<h2 className="text-white text-xl font-bold">Create New Bucket</h2>
<button
onClick={() => {
setShowCreateBucketModal(false)
setNewBucketName('')
}}
className="text-text-secondary hover:text-white transition-colors"
>
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-white text-sm font-medium mb-2">Bucket Name</label>
<input
type="text"
value={newBucketName}
onChange={(e) => setNewBucketName(e.target.value)}
placeholder="e.g., my-bucket"
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm focus:ring-1 focus:ring-primary focus:border-primary outline-none"
onKeyDown={(e) => {
if (e.key === 'Enter' && newBucketName.trim()) {
createBucketMutation.mutate(newBucketName.trim())
}
}}
autoFocus
/>
<p className="text-text-secondary text-xs mt-2">
Bucket names must be unique and follow S3 naming conventions (lowercase, numbers, hyphens only)
</p>
</div>
<div className="flex gap-3 justify-end pt-4">
<button
onClick={() => {
setShowCreateBucketModal(false)
setNewBucketName('')
}}
className="px-4 py-2 text-white text-sm font-medium rounded-lg border border-border-dark hover:bg-[#233648] transition-colors"
>
Cancel
</button>
<button
onClick={() => {
if (newBucketName.trim()) {
createBucketMutation.mutate(newBucketName.trim())
} else {
alert('Please enter a bucket name')
}
}}
disabled={createBucketMutation.isPending || !newBucketName.trim()}
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{createBucketMutation.isPending ? 'Creating...' : 'Create Bucket'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Setup Modal */}
{showSetupModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-white text-xl font-bold">
{currentSetup ? 'Change Object Storage Dataset' : 'Setup Object Storage'}
</h2>
<button
onClick={() => setShowSetupModal(false)}
className="text-text-secondary hover:text-white transition-colors"
>
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-white text-sm font-medium mb-2">Select Pool</label>
<select
value={selectedPool}
onChange={(e) => {
setSelectedPool(e.target.value)
setSelectedDataset('')
}}
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm focus:ring-1 focus:ring-primary focus:border-primary outline-none"
>
<option value="">-- Select Pool --</option>
{availableDatasets.map((pool) => (
<option key={pool.pool_id} value={pool.pool_name}>
{pool.pool_name}
</option>
))}
</select>
</div>
<div>
<label className="flex items-center text-white text-sm font-medium mb-2">
<input
type="checkbox"
checked={createNewDataset}
onChange={(e) => setCreateNewDataset(e.target.checked)}
className="mr-2"
/>
Create New Dataset
</label>
</div>
{createNewDataset ? (
<div>
<label className="block text-white text-sm font-medium mb-2">Dataset Name</label>
<input
type="text"
value={newDatasetName}
onChange={(e) => setNewDatasetName(e.target.value)}
placeholder="e.g., object-storage"
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm focus:ring-1 focus:ring-primary focus:border-primary outline-none"
/>
</div>
) : (
<div>
<label className="block text-white text-sm font-medium mb-2">Select Dataset</label>
<select
value={selectedDataset}
onChange={(e) => setSelectedDataset(e.target.value)}
disabled={!selectedPool}
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm focus:ring-1 focus:ring-primary focus:border-primary outline-none disabled:opacity-50"
>
<option value="">-- Select Dataset --</option>
{selectedPool &&
availableDatasets
.find((p) => p.pool_name === selectedPool)
?.datasets.map((ds) => (
<option key={ds.id} value={ds.name}>
{ds.name} ({formatBytes(ds.available_bytes, 1)} available)
</option>
))}
</select>
</div>
)}
{currentSetup && (
<div className="bg-orange-500/10 border border-orange-500/20 rounded-lg p-4 space-y-2">
<p className="text-orange-400 text-sm font-medium">Current Configuration:</p>
<p className="text-orange-300 text-sm">
Dataset: <span className="font-mono">{currentSetup.dataset_path}</span>
</p>
<p className="text-orange-300 text-sm">
Mount Point: <span className="font-mono">{currentSetup.mount_point}</span>
</p>
<p className="text-orange-400 text-xs mt-2">
Warning: Changing the dataset will update MinIO configuration. Existing data in the current dataset will NOT be automatically migrated. Make sure to backup or migrate data before changing.
</p>
</div>
)}
<div className="flex gap-3 justify-end pt-4">
<button
onClick={() => setShowSetupModal(false)}
className="px-4 py-2 bg-[#233648] hover:bg-[#2b4055] text-white text-sm font-medium rounded-lg border border-border-dark transition-colors"
>
Cancel
</button>
<button
onClick={() => {
if (!selectedPool) {
alert('Please select a pool')
return
}
if (createNewDataset && !newDatasetName) {
alert('Please enter a dataset name')
return
}
if (!createNewDataset && !selectedDataset) {
alert('Please select a dataset')
return
}
setupMutation.mutate({
poolName: selectedPool,
datasetName: createNewDataset ? newDatasetName : selectedDataset,
createNew: createNewDataset,
})
}}
disabled={setupMutation.isPending}
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
>
{setupMutation.isPending
? currentSetup
? 'Updating...'
: 'Setting up...'
: currentSetup
? 'Update Dataset'
: 'Setup'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Delete Bucket Confirmation Dialog */}
{deleteConfirmBucket && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-red-500/10 rounded-lg">
<AlertCircle className="text-red-400" size={24} />
</div>
<div>
<h2 className="text-white text-lg font-bold">Delete Bucket</h2>
<p className="text-text-secondary text-sm">This action cannot be undone</p>
</div>
</div>
<div className="mb-6">
<p className="text-white text-sm mb-2">
Are you sure you want to delete bucket <span className="font-mono font-semibold text-primary">{deleteConfirmBucket}</span>?
</p>
<p className="text-text-secondary text-xs">
All objects in this bucket will be permanently deleted. This action cannot be undone.
</p>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => setDeleteConfirmBucket(null)}
className="px-4 py-2 text-white text-sm font-medium rounded-lg border border-border-dark hover:bg-[#233648] transition-colors"
>
Cancel
</button>
<button
onClick={() => {
deleteBucketMutation.mutate(deleteConfirmBucket)
setDeleteConfirmBucket(null)
}}
disabled={deleteBucketMutation.isPending}
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<Trash2 size={16} />
{deleteBucketMutation.isPending ? 'Deleting...' : 'Delete Bucket'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,952 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { objectStorageApi, User, ServiceAccount, CreateUserRequest, CreateServiceAccountRequest } from '@/api/objectStorage'
import {
Users,
Key,
UserPlus,
KeyRound,
Eye,
EyeOff,
AlertCircle,
Trash2,
Copy,
RefreshCw,
CheckCircle2
} from 'lucide-react'
interface UsersAndKeysProps {
S3_ENDPOINT: string
}
export default function UsersAndKeys({ S3_ENDPOINT }: UsersAndKeysProps) {
const [activeSubTab, setActiveSubTab] = useState<'users' | 'keys'>('users')
const [showCreateUserModal, setShowCreateUserModal] = useState(false)
const [newUserAccessKey, setNewUserAccessKey] = useState('')
const [newUserSecretKey, setNewUserSecretKey] = useState('')
const [showCreateKeyModal, setShowCreateKeyModal] = useState(false)
const [newKeyParentUser, setNewKeyParentUser] = useState('')
const [newKeyPolicy, setNewKeyPolicy] = useState('')
const [newKeyExpiration, setNewKeyExpiration] = useState('')
const [createdKey, setCreatedKey] = useState<ServiceAccount | null>(null)
const [showSecretKey, setShowSecretKey] = useState(false)
const [deleteConfirmUser, setDeleteConfirmUser] = useState<string | null>(null)
const [deleteConfirmKey, setDeleteConfirmKey] = useState<string | null>(null)
const [isRefreshingUsers, setIsRefreshingUsers] = useState(false)
const [isRefreshingKeys, setIsRefreshingKeys] = useState(false)
const queryClient = useQueryClient()
// Fetch users
const { data: users = [], isLoading: usersLoading } = useQuery<User[]>({
queryKey: ['object-storage-users'],
queryFn: objectStorageApi.listUsers,
refetchInterval: 10000, // Auto-refresh every 10 seconds
})
// Fetch service accounts (keys)
const { data: serviceAccounts = [], isLoading: keysLoading } = useQuery<ServiceAccount[]>({
queryKey: ['object-storage-service-accounts'],
queryFn: objectStorageApi.listServiceAccounts,
refetchInterval: 10000, // Auto-refresh every 10 seconds
})
// Create user mutation with optimistic update
const createUserMutation = useMutation({
mutationFn: (data: CreateUserRequest) => objectStorageApi.createUser(data),
onMutate: async (data: CreateUserRequest) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['object-storage-users'] })
// Snapshot the previous value
const previousUsers = queryClient.getQueryData(['object-storage-users'])
// Optimistically add the new user to the list
queryClient.setQueryData(['object-storage-users'], (old: User[] = []) => {
const newUser: User = {
access_key: data.access_key,
status: 'enabled',
created_at: new Date().toISOString(),
}
return [...old, newUser]
})
// Close modal immediately
setShowCreateUserModal(false)
setNewUserAccessKey('')
setNewUserSecretKey('')
// Return a context object with the snapshotted value
return { previousUsers }
},
onError: (error: any, _data: CreateUserRequest, context: any) => {
// If the mutation fails, use the context returned from onMutate to roll back
if (context?.previousUsers) {
queryClient.setQueryData(['object-storage-users'], context.previousUsers)
}
// Reopen modal on error
setShowCreateUserModal(true)
alert(error.response?.data?.error || 'Failed to create user')
},
onSuccess: () => {
// Refetch to ensure we have the latest data from server
queryClient.invalidateQueries({ queryKey: ['object-storage-users'] })
alert('User created successfully!')
},
onSettled: () => {
// Always refetch after error or success to ensure consistency
queryClient.invalidateQueries({ queryKey: ['object-storage-users'] })
},
})
// Delete user mutation
const deleteUserMutation = useMutation({
mutationFn: (accessKey: string) => objectStorageApi.deleteUser(accessKey),
onMutate: async (accessKey: string) => {
await queryClient.cancelQueries({ queryKey: ['object-storage-users'] })
const previousUsers = queryClient.getQueryData(['object-storage-users'])
queryClient.setQueryData(['object-storage-users'], (old: User[] = []) =>
old.filter((user) => user.access_key !== accessKey)
)
return { previousUsers }
},
onError: (error: any, _accessKey: string, context: any) => {
if (context?.previousUsers) {
queryClient.setQueryData(['object-storage-users'], context.previousUsers)
}
alert(error.response?.data?.error || 'Failed to delete user')
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['object-storage-users'] })
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['object-storage-users'] })
},
})
// Create service account mutation with optimistic update
const createServiceAccountMutation = useMutation({
mutationFn: (data: CreateServiceAccountRequest) => objectStorageApi.createServiceAccount(data),
onMutate: async (_data: CreateServiceAccountRequest) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['object-storage-service-accounts'] })
// Snapshot the previous value
const previousAccounts = queryClient.getQueryData(['object-storage-service-accounts'])
// Close modal immediately (we'll show created key modal after success)
setShowCreateKeyModal(false)
setNewKeyParentUser('')
setNewKeyPolicy('')
setNewKeyExpiration('')
// Return a context object with the snapshotted value
return { previousAccounts }
},
onError: (error: any, _data: CreateServiceAccountRequest, context: any) => {
// If the mutation fails, use the context returned from onMutate to roll back
if (context?.previousAccounts) {
queryClient.setQueryData(['object-storage-service-accounts'], context.previousAccounts)
}
// Reopen modal on error
setShowCreateKeyModal(true)
alert(error.response?.data?.error || 'Failed to create access key')
},
onSuccess: (data: ServiceAccount) => {
// Optimistically add the new service account to the list
queryClient.setQueryData(['object-storage-service-accounts'], (old: ServiceAccount[] = []) => {
return [...old, data]
})
// Show created key modal with secret key
setCreatedKey(data)
// Refetch to ensure we have the latest data from server
queryClient.invalidateQueries({ queryKey: ['object-storage-service-accounts'] })
},
onSettled: () => {
// Always refetch after error or success to ensure consistency
queryClient.invalidateQueries({ queryKey: ['object-storage-service-accounts'] })
},
})
// Delete service account mutation
const deleteServiceAccountMutation = useMutation({
mutationFn: (accessKey: string) => objectStorageApi.deleteServiceAccount(accessKey),
onMutate: async (accessKey: string) => {
await queryClient.cancelQueries({ queryKey: ['object-storage-service-accounts'] })
const previousAccounts = queryClient.getQueryData(['object-storage-service-accounts'])
queryClient.setQueryData(['object-storage-service-accounts'], (old: ServiceAccount[] = []) =>
old.filter((account) => account.access_key !== accessKey)
)
return { previousAccounts }
},
onError: (error: any, _accessKey: string, context: any) => {
if (context?.previousAccounts) {
queryClient.setQueryData(['object-storage-service-accounts'], context.previousAccounts)
}
alert(error.response?.data?.error || 'Failed to delete access key')
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['object-storage-service-accounts'] })
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['object-storage-service-accounts'] })
},
})
// Refresh users
const handleRefreshUsers = async () => {
setIsRefreshingUsers(true)
try {
await queryClient.invalidateQueries({ queryKey: ['object-storage-users'] })
await queryClient.refetchQueries({ queryKey: ['object-storage-users'] })
setTimeout(() => {
alert('Users refreshed successfully!')
}, 300)
} catch (error) {
alert('Failed to refresh users')
} finally {
setIsRefreshingUsers(false)
}
}
// Refresh keys
const handleRefreshKeys = async () => {
setIsRefreshingKeys(true)
try {
await queryClient.invalidateQueries({ queryKey: ['object-storage-service-accounts'] })
await queryClient.refetchQueries({ queryKey: ['object-storage-service-accounts'] })
setTimeout(() => {
alert('Access keys refreshed successfully!')
}, 300)
} catch (error) {
alert('Failed to refresh access keys')
} finally {
setIsRefreshingKeys(false)
}
}
// Format date
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
// Copy to clipboard
const copyToClipboard = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text)
alert(`${label} copied to clipboard!`)
} catch (error) {
console.error('Failed to copy:', error)
// Fallback
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
document.body.appendChild(textArea)
textArea.select()
try {
document.execCommand('copy')
alert(`${label} copied to clipboard!`)
} catch (err) {
alert(`Failed to copy. ${label}: ${text}`)
}
document.body.removeChild(textArea)
}
}
return (
<div className="space-y-6">
{/* Sub-tabs for Users and Keys */}
<div className="flex items-center gap-2 border-b border-border-dark">
<button
onClick={() => setActiveSubTab('users')}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeSubTab === 'users'
? 'text-primary border-b-2 border-primary'
: 'text-text-secondary hover:text-white'
}`}
>
<div className="flex items-center gap-2">
<Users size={18} />
<span>Users ({users.length})</span>
</div>
</button>
<button
onClick={() => setActiveSubTab('keys')}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeSubTab === 'keys'
? 'text-primary border-b-2 border-primary'
: 'text-text-secondary hover:text-white'
}`}
>
<div className="flex items-center gap-2">
<Key size={18} />
<span>Access Keys ({serviceAccounts.length})</span>
</div>
</button>
</div>
{/* Users Tab */}
{activeSubTab === 'users' && (
<div className="space-y-4">
{/* Header with actions */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-white text-xl font-bold">IAM Users</h2>
<p className="text-text-secondary text-sm mt-1">
Manage MinIO IAM users for accessing object storage
</p>
</div>
<div className="flex gap-2">
<button
onClick={handleRefreshUsers}
disabled={isRefreshingUsers}
className="px-4 py-2 bg-[#233648] hover:bg-[#2b4055] text-white text-sm font-medium rounded-lg border border-border-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<RefreshCw size={16} className={isRefreshingUsers ? 'animate-spin' : ''} />
{isRefreshingUsers ? 'Refreshing...' : 'Refresh'}
</button>
<button
onClick={() => setShowCreateUserModal(true)}
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors flex items-center gap-2"
>
<UserPlus size={16} />
Create User
</button>
</div>
</div>
{/* Users Table */}
{usersLoading ? (
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-8 text-center">
<p className="text-text-secondary text-sm">Loading users...</p>
</div>
) : users.length === 0 ? (
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-8 text-center">
<Users className="mx-auto mb-4 text-text-secondary" size={48} />
<p className="text-text-secondary text-sm mb-4">No users found</p>
<button
onClick={() => setShowCreateUserModal(true)}
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors"
>
Create First User
</button>
</div>
) : (
<div className="bg-[#1c2936] border border-border-dark rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-[#16202a] border-b border-border-dark">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Access Key
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-text-secondary uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-border-dark">
{users.map((user) => (
<tr key={user.access_key} className="hover:bg-[#233648] transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<Users size={16} className="text-primary" />
<span className="text-white font-mono text-sm">{user.access_key}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
user.status === 'enabled'
? 'bg-green-500/10 text-green-500 border border-green-500/20'
: 'bg-red-500/10 text-red-500 border border-red-500/20'
}`}
>
{user.status === 'enabled' ? (
<>
<CheckCircle2 size={12} className="mr-1" />
Enabled
</>
) : (
'Disabled'
)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-text-secondary text-sm">
{formatDate(user.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center justify-end gap-2">
<button
onClick={async () => {
await copyToClipboard(user.access_key, 'Access Key')
}}
className="px-3 py-1.5 text-xs font-medium text-white bg-[#233648] hover:bg-[#2b4055] border border-border-dark rounded-lg transition-colors flex items-center gap-1.5"
title="Copy Access Key"
>
<Copy size={14} />
Copy
</button>
<button
onClick={() => setDeleteConfirmUser(user.access_key)}
disabled={deleteUserMutation.isPending}
className="px-3 py-1.5 text-xs font-medium text-red-400 bg-red-500/10 hover:bg-red-500/20 border border-red-500/20 rounded-lg transition-colors flex items-center gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
title="Delete User"
>
<Trash2 size={14} />
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
{/* Keys Tab */}
{activeSubTab === 'keys' && (
<div className="space-y-4">
{/* Header with actions */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-white text-xl font-bold">Access Keys</h2>
<p className="text-text-secondary text-sm mt-1">
Manage service account access keys for programmatic access
</p>
</div>
<div className="flex gap-2">
<button
onClick={handleRefreshKeys}
disabled={isRefreshingKeys}
className="px-4 py-2 bg-[#233648] hover:bg-[#2b4055] text-white text-sm font-medium rounded-lg border border-border-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<RefreshCw size={16} className={isRefreshingKeys ? 'animate-spin' : ''} />
{isRefreshingKeys ? 'Refreshing...' : 'Refresh'}
</button>
<button
onClick={() => {
if (users.length === 0) {
alert('Please create at least one user before creating access keys')
setActiveSubTab('users')
return
}
setShowCreateKeyModal(true)
}}
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors flex items-center gap-2"
>
<KeyRound size={16} />
Create Access Key
</button>
</div>
</div>
{/* Keys Table */}
{keysLoading ? (
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-8 text-center">
<p className="text-text-secondary text-sm">Loading access keys...</p>
</div>
) : serviceAccounts.length === 0 ? (
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-8 text-center">
<Key className="mx-auto mb-4 text-text-secondary" size={48} />
<p className="text-text-secondary text-sm mb-4">No access keys found</p>
<button
onClick={() => {
if (users.length === 0) {
alert('Please create at least one user before creating access keys')
setActiveSubTab('users')
return
}
setShowCreateKeyModal(true)
}}
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors"
>
Create First Access Key
</button>
</div>
) : (
<div className="bg-[#1c2936] border border-border-dark rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-[#16202a] border-b border-border-dark">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Access Key
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Parent User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Expiration
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-text-secondary uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-border-dark">
{serviceAccounts.map((account) => (
<tr key={account.access_key} className="hover:bg-[#233648] transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<Key size={16} className="text-primary" />
<span className="text-white font-mono text-sm">{account.access_key}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-text-secondary text-sm">
{account.parent_user}
</td>
<td className="px-6 py-4 whitespace-nowrap text-text-secondary text-sm">
{account.expiration ? formatDate(account.expiration) : 'Never'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-text-secondary text-sm">
{formatDate(account.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center justify-end gap-2">
<button
onClick={async () => {
await copyToClipboard(account.access_key, 'Access Key')
}}
className="px-3 py-1.5 text-xs font-medium text-white bg-[#233648] hover:bg-[#2b4055] border border-border-dark rounded-lg transition-colors flex items-center gap-1.5"
title="Copy Access Key"
>
<Copy size={14} />
Copy
</button>
<button
onClick={() => setDeleteConfirmKey(account.access_key)}
disabled={deleteServiceAccountMutation.isPending}
className="px-3 py-1.5 text-xs font-medium text-red-400 bg-red-500/10 hover:bg-red-500/20 border border-red-500/20 rounded-lg transition-colors flex items-center gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
title="Delete Access Key"
>
<Trash2 size={14} />
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
{/* Create User Modal */}
{showCreateUserModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center justify-between mb-6">
<h2 className="text-white text-xl font-bold">Create IAM User</h2>
<button
onClick={() => {
setShowCreateUserModal(false)
setNewUserAccessKey('')
setNewUserSecretKey('')
}}
className="text-text-secondary hover:text-white transition-colors"
>
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-white text-sm font-medium mb-2">Access Key</label>
<input
type="text"
value={newUserAccessKey}
onChange={(e) => setNewUserAccessKey(e.target.value)}
placeholder="e.g., myuser"
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm focus:ring-1 focus:ring-primary focus:border-primary outline-none font-mono"
autoFocus
/>
<p className="text-text-secondary text-xs mt-2">
Access key must be unique and follow MinIO naming conventions
</p>
</div>
<div>
<label className="block text-white text-sm font-medium mb-2">Secret Key</label>
<div className="relative">
<input
type={showSecretKey ? 'text' : 'password'}
value={newUserSecretKey}
onChange={(e) => setNewUserSecretKey(e.target.value)}
placeholder="Enter secret key"
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 pr-10 text-white text-sm focus:ring-1 focus:ring-primary focus:border-primary outline-none font-mono"
/>
<button
type="button"
onClick={() => setShowSecretKey(!showSecretKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-secondary hover:text-white"
>
{showSecretKey ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
<p className="text-text-secondary text-xs mt-2">
Secret key must be at least 8 characters long
</p>
</div>
<div className="flex gap-3 justify-end pt-4">
<button
onClick={() => {
setShowCreateUserModal(false)
setNewUserAccessKey('')
setNewUserSecretKey('')
}}
className="px-4 py-2 text-white text-sm font-medium rounded-lg border border-border-dark hover:bg-[#233648] transition-colors"
>
Cancel
</button>
<button
onClick={() => {
if (!newUserAccessKey.trim()) {
alert('Please enter an access key')
return
}
if (!newUserSecretKey.trim() || newUserSecretKey.length < 8) {
alert('Please enter a secret key (minimum 8 characters)')
return
}
createUserMutation.mutate({
access_key: newUserAccessKey.trim(),
secret_key: newUserSecretKey,
})
}}
disabled={createUserMutation.isPending || !newUserAccessKey.trim() || !newUserSecretKey.trim()}
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{createUserMutation.isPending ? 'Creating...' : 'Create User'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Create Access Key Modal */}
{showCreateKeyModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center justify-between mb-6">
<h2 className="text-white text-xl font-bold">Create Access Key</h2>
<button
onClick={() => {
setShowCreateKeyModal(false)
setNewKeyParentUser('')
setNewKeyPolicy('')
setNewKeyExpiration('')
}}
className="text-text-secondary hover:text-white transition-colors"
>
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-white text-sm font-medium mb-2">Parent User</label>
<select
value={newKeyParentUser}
onChange={(e) => setNewKeyParentUser(e.target.value)}
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm focus:ring-1 focus:ring-primary focus:border-primary outline-none"
autoFocus
>
<option value="">-- Select User --</option>
{users.map((user) => (
<option key={user.access_key} value={user.access_key}>
{user.access_key}
</option>
))}
</select>
<p className="text-text-secondary text-xs mt-2">
Select the IAM user this access key will belong to
</p>
</div>
<div>
<label className="block text-white text-sm font-medium mb-2">
Policy (Optional)
</label>
<textarea
value={newKeyPolicy}
onChange={(e) => setNewKeyPolicy(e.target.value)}
placeholder='{"Version":"2012-10-17","Statement":[...]}'
rows={4}
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm focus:ring-1 focus:ring-primary focus:border-primary outline-none font-mono"
/>
<p className="text-text-secondary text-xs mt-2">
JSON policy document (leave empty for default permissions)
</p>
</div>
<div>
<label className="block text-white text-sm font-medium mb-2">
Expiration (Optional)
</label>
<input
type="datetime-local"
value={newKeyExpiration}
onChange={(e) => setNewKeyExpiration(e.target.value)}
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm focus:ring-1 focus:ring-primary focus:border-primary outline-none"
/>
<p className="text-text-secondary text-xs mt-2">
Leave empty for no expiration
</p>
</div>
<div className="flex gap-3 justify-end pt-4">
<button
onClick={() => {
setShowCreateKeyModal(false)
setNewKeyParentUser('')
setNewKeyPolicy('')
setNewKeyExpiration('')
}}
className="px-4 py-2 text-white text-sm font-medium rounded-lg border border-border-dark hover:bg-[#233648] transition-colors"
>
Cancel
</button>
<button
onClick={() => {
if (!newKeyParentUser.trim()) {
alert('Please select a parent user')
return
}
createServiceAccountMutation.mutate({
parent_user: newKeyParentUser.trim(),
policy: newKeyPolicy.trim() || undefined,
expiration: newKeyExpiration ? new Date(newKeyExpiration).toISOString() : undefined,
})
}}
disabled={createServiceAccountMutation.isPending || !newKeyParentUser.trim()}
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{createServiceAccountMutation.isPending ? 'Creating...' : 'Create Access Key'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Delete User Confirmation Dialog */}
{deleteConfirmUser && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-red-500/10 rounded-lg">
<AlertCircle className="text-red-400" size={24} />
</div>
<div>
<h2 className="text-white text-lg font-bold">Delete User</h2>
<p className="text-text-secondary text-sm">This action cannot be undone</p>
</div>
</div>
<div className="mb-6">
<p className="text-white text-sm mb-2">
Are you sure you want to delete user <span className="font-mono font-semibold text-primary">{deleteConfirmUser}</span>?
</p>
<p className="text-text-secondary text-xs">
All access keys associated with this user will also be deleted. This action cannot be undone.
</p>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => setDeleteConfirmUser(null)}
className="px-4 py-2 text-white text-sm font-medium rounded-lg border border-border-dark hover:bg-[#233648] transition-colors"
>
Cancel
</button>
<button
onClick={() => {
deleteUserMutation.mutate(deleteConfirmUser)
setDeleteConfirmUser(null)
}}
disabled={deleteUserMutation.isPending}
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<Trash2 size={16} />
{deleteUserMutation.isPending ? 'Deleting...' : 'Delete User'}
</button>
</div>
</div>
</div>
)}
{/* Delete Key Confirmation Dialog */}
{deleteConfirmKey && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-red-500/10 rounded-lg">
<AlertCircle className="text-red-400" size={24} />
</div>
<div>
<h2 className="text-white text-lg font-bold">Delete Access Key</h2>
<p className="text-text-secondary text-sm">This action cannot be undone</p>
</div>
</div>
<div className="mb-6">
<p className="text-white text-sm mb-2">
Are you sure you want to delete access key <span className="font-mono font-semibold text-primary">{deleteConfirmKey}</span>?
</p>
<p className="text-text-secondary text-xs">
Applications using this access key will lose access immediately. This action cannot be undone.
</p>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => setDeleteConfirmKey(null)}
className="px-4 py-2 text-white text-sm font-medium rounded-lg border border-border-dark hover:bg-[#233648] transition-colors"
>
Cancel
</button>
<button
onClick={() => {
deleteServiceAccountMutation.mutate(deleteConfirmKey)
setDeleteConfirmKey(null)
}}
disabled={deleteServiceAccountMutation.isPending}
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<Trash2 size={16} />
{deleteServiceAccountMutation.isPending ? 'Deleting...' : 'Delete Key'}
</button>
</div>
</div>
</div>
)}
{/* Created Key Modal (shows secret key once) */}
{createdKey && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[#1c2936] border border-border-dark rounded-lg p-6 max-w-lg w-full mx-4">
<div className="flex items-center justify-between mb-6">
<h2 className="text-white text-xl font-bold">Access Key Created</h2>
<button
onClick={() => {
setCreatedKey(null)
setShowSecretKey(false)
}}
className="text-text-secondary hover:text-white transition-colors"
>
</button>
</div>
<div className="space-y-4">
<div className="bg-orange-500/10 border border-orange-500/20 rounded-lg p-4">
<div className="flex items-start gap-2">
<AlertCircle className="text-orange-400 mt-0.5" size={20} />
<div>
<p className="text-orange-400 text-sm font-medium mb-1">Important</p>
<p className="text-orange-300 text-xs">
Save these credentials now. The secret key will not be shown again.
</p>
</div>
</div>
</div>
<div>
<label className="block text-white text-sm font-medium mb-2">Access Key</label>
<div className="flex items-center gap-2">
<input
type="text"
value={createdKey.access_key}
readOnly
className="flex-1 bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm font-mono"
/>
<button
onClick={() => copyToClipboard(createdKey.access_key, 'Access Key')}
className="px-3 py-2 bg-[#233648] hover:bg-[#2b4055] border border-border-dark rounded-lg text-white text-sm transition-colors"
>
<Copy size={16} />
</button>
</div>
</div>
<div>
<label className="block text-white text-sm font-medium mb-2">Secret Key</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type={showSecretKey ? 'text' : 'password'}
value={createdKey.secret_key || ''}
readOnly
className="w-full bg-[#233648] border border-border-dark rounded-lg px-4 py-2 pr-10 text-white text-sm font-mono"
/>
<button
type="button"
onClick={() => setShowSecretKey(!showSecretKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-secondary hover:text-white"
>
{showSecretKey ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
<button
onClick={() => copyToClipboard(createdKey.secret_key || '', 'Secret Key')}
className="px-3 py-2 bg-[#233648] hover:bg-[#2b4055] border border-border-dark rounded-lg text-white text-sm transition-colors"
>
<Copy size={16} />
</button>
</div>
</div>
<div>
<label className="block text-white text-sm font-medium mb-2">Endpoint</label>
<div className="flex items-center gap-2">
<input
type="text"
value={S3_ENDPOINT}
readOnly
className="flex-1 bg-[#233648] border border-border-dark rounded-lg px-4 py-2 text-white text-sm font-mono"
/>
<button
onClick={() => copyToClipboard(S3_ENDPOINT, 'Endpoint')}
className="px-3 py-2 bg-[#233648] hover:bg-[#2b4055] border border-border-dark rounded-lg text-white text-sm transition-colors"
>
<Copy size={16} />
</button>
</div>
</div>
<div className="flex gap-3 justify-end pt-4">
<button
onClick={() => {
setCreatedKey(null)
setShowSecretKey(false)
}}
className="px-4 py-2 bg-primary hover:bg-blue-600 text-white text-sm font-medium rounded-lg transition-colors"
>
I've Saved These Credentials
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}