add function to s3
This commit is contained in:
145
frontend/src/api/objectStorage.ts
Normal file
145
frontend/src/api/objectStorage.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
952
frontend/src/pages/UsersAndKeys.tsx
Normal file
952
frontend/src/pages/UsersAndKeys.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user