working on some code

This commit is contained in:
Warp Agent
2025-12-27 16:58:19 +00:00
parent 8677820864
commit 97659421b5
16 changed files with 3318 additions and 151 deletions

View File

@@ -11,6 +11,9 @@ import VTLDetailPage from '@/pages/VTLDetail'
import ISCSITargetsPage from '@/pages/ISCSITargets'
import ISCSITargetDetailPage from '@/pages/ISCSITargetDetail'
import SystemPage from '@/pages/System'
import BackupManagementPage from '@/pages/BackupManagement'
import IAMPage from '@/pages/IAM'
import ProfilePage from '@/pages/Profile'
import Layout from '@/components/Layout'
// Create a client
@@ -55,8 +58,12 @@ function App() {
<Route path="tape/vtl/:id" element={<VTLDetailPage />} />
<Route path="iscsi" element={<ISCSITargetsPage />} />
<Route path="iscsi/:id" element={<ISCSITargetDetailPage />} />
<Route path="backup" element={<BackupManagementPage />} />
<Route path="alerts" element={<AlertsPage />} />
<Route path="system" element={<SystemPage />} />
<Route path="iam" element={<IAMPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="profile/:id" element={<ProfilePage />} />
</Route>
</Routes>
<Toaster />

151
frontend/src/api/iam.ts Normal file
View File

@@ -0,0 +1,151 @@
import apiClient from './client'
export interface User {
id: string
username: string
email: string
full_name: string
is_active: boolean
is_system: boolean
created_at: string
updated_at: string
last_login_at: string | null
roles?: string[]
permissions?: string[]
groups?: string[]
}
export interface Group {
id: string
name: string
description?: string
is_system: boolean
user_count: number
role_count: number
created_at: string
updated_at: string
users?: string[]
roles?: string[]
}
export interface CreateGroupRequest {
name: string
description?: string
}
export interface UpdateGroupRequest {
name?: string
description?: string
}
export interface AddUserToGroupRequest {
user_id: string
}
export interface CreateUserRequest {
username: string
email: string
password: string
full_name?: string
}
export interface UpdateUserRequest {
email?: string
full_name?: string
is_active?: boolean
}
export const iamApi = {
listUsers: async (): Promise<User[]> => {
const response = await apiClient.get<{ users: User[] }>('/iam/users')
return response.data.users || []
},
getUser: async (id: string): Promise<User> => {
const response = await apiClient.get<{
id: string
username: string
email: string
full_name: string
is_active: boolean
is_system: boolean
roles: string[]
permissions: string[]
groups: string[]
created_at: string
updated_at: string
last_login_at: string | null
}>(`/iam/users/${id}`)
return response.data
},
createUser: async (data: CreateUserRequest): Promise<{ id: string; username: string }> => {
const response = await apiClient.post<{ id: string; username: string }>('/iam/users', data)
return response.data
},
updateUser: async (id: string, data: UpdateUserRequest): Promise<void> => {
await apiClient.put(`/iam/users/${id}`, data)
},
deleteUser: async (id: string): Promise<void> => {
await apiClient.delete(`/iam/users/${id}`)
},
// Groups API
listGroups: async (): Promise<Group[]> => {
const response = await apiClient.get<{ groups: Group[] }>('/iam/groups')
return response.data.groups || []
},
getGroup: async (id: string): Promise<Group> => {
const response = await apiClient.get<Group>(`/iam/groups/${id}`)
return response.data
},
createGroup: async (data: CreateGroupRequest): Promise<{ id: string; name: string }> => {
const response = await apiClient.post<{ id: string; name: string }>('/iam/groups', data)
return response.data
},
updateGroup: async (id: string, data: UpdateGroupRequest): Promise<void> => {
await apiClient.put(`/iam/groups/${id}`, data)
},
deleteGroup: async (id: string): Promise<void> => {
await apiClient.delete(`/iam/groups/${id}`)
},
addUserToGroup: async (groupId: string, userId: string): Promise<void> => {
await apiClient.post(`/iam/groups/${groupId}/users`, { user_id: userId })
},
removeUserFromGroup: async (groupId: string, userId: string): Promise<void> => {
await apiClient.delete(`/iam/groups/${groupId}/users/${userId}`)
},
// User role assignment
assignRoleToUser: async (userId: string, roleName: string): Promise<void> => {
await apiClient.post(`/iam/users/${userId}/roles`, { role_name: roleName })
},
removeRoleFromUser: async (userId: string, roleName: string): Promise<void> => {
await apiClient.delete(`/iam/users/${userId}/roles?role_name=${encodeURIComponent(roleName)}`)
},
// User group assignment
assignGroupToUser: async (userId: string, groupName: string): Promise<void> => {
await apiClient.post(`/iam/users/${userId}/groups`, { group_name: groupName })
},
removeGroupFromUser: async (userId: string, groupName: string): Promise<void> => {
await apiClient.delete(`/iam/users/${userId}/groups?group_name=${encodeURIComponent(groupName)}`)
},
// List all available roles
listRoles: async (): Promise<Array<{ name: string; description?: string; is_system: boolean }>> => {
const response = await apiClient.get<{ roles: Array<{ name: string; description?: string; is_system: boolean }> }>('/iam/roles')
return response.data.roles
},
}

View File

@@ -10,7 +10,8 @@ import {
Settings,
Bell,
Server,
Users
Users,
Archive
} from 'lucide-react'
import { useState, useEffect } from 'react'
@@ -44,14 +45,15 @@ export default function Layout() {
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'Storage', href: '/storage', icon: HardDrive },
{ name: 'Tape Libraries', href: '/tape', icon: Database },
{ name: 'iSCSI Targets', href: '/iscsi', icon: Network },
{ name: 'iSCSI Management', href: '/iscsi', icon: Network },
{ name: 'Backup Management', href: '/backup', icon: Archive },
{ name: 'Tasks', href: '/tasks', icon: Settings },
{ name: 'Alerts', href: '/alerts', icon: Bell },
{ name: 'System', href: '/system', icon: Server },
]
if (user?.roles.includes('admin')) {
navigation.push({ name: 'IAM', href: '/iam', icon: Users })
navigation.push({ name: 'User Management', href: '/iam', icon: Users })
}
const isActive = (href: string) => {
@@ -62,7 +64,7 @@ export default function Layout() {
}
return (
<div className="min-h-screen bg-background-dark">
<div className="h-screen bg-background-dark flex overflow-hidden">
{/* Mobile backdrop overlay */}
{sidebarOpen && (
<div
@@ -135,12 +137,15 @@ export default function Layout() {
{/* Footer */}
<div className="p-4 border-t border-border-dark bg-[#0d1419]">
<div className="mb-3 px-2">
<Link
to="/profile"
className="mb-3 px-2 py-2 rounded-lg hover:bg-card-dark transition-colors block"
>
<p className="text-sm font-semibold text-white mb-0.5">{user?.username}</p>
<p className="text-xs text-text-secondary font-mono">
{user?.roles.join(', ').toUpperCase()}
</p>
</div>
</Link>
<button
onClick={handleLogout}
className="w-full flex items-center gap-2 px-4 py-2.5 rounded-lg text-text-secondary hover:bg-card-dark hover:text-white transition-colors border border-border-dark"
@@ -153,9 +158,9 @@ export default function Layout() {
</div>
{/* Main content */}
<div className={`transition-all duration-300 ${sidebarOpen ? 'lg:ml-64' : 'ml-0'} bg-background-dark`}>
<div className={`transition-all duration-300 flex-1 flex flex-col overflow-hidden ${sidebarOpen ? 'lg:ml-64' : 'ml-0'} bg-background-dark`}>
{/* Top bar with burger menu button */}
<div className="sticky top-0 z-30 lg:hidden bg-background-dark border-b border-border-dark px-4 py-3">
<div className="flex-none lg:hidden bg-background-dark border-b border-border-dark px-4 py-3">
<button
onClick={() => setSidebarOpen(true)}
className="text-text-secondary hover:text-white transition-colors"
@@ -166,7 +171,7 @@ export default function Layout() {
</div>
{/* Page content */}
<main className="min-h-screen">
<main className="flex-1 overflow-hidden">
<Outlet />
</main>
</div>

View File

@@ -69,6 +69,7 @@
.custom-scrollbar::-webkit-scrollbar-track {
background: #111a22;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@@ -80,6 +81,24 @@
background: #476685;
}
.custom-scrollbar {
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
scroll-behavior: smooth;
}
/* Ensure mouse wheel scrolling works */
.custom-scrollbar,
.custom-scrollbar * {
touch-action: pan-y;
}
/* Firefox scrollbar */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #324d67 #111a22;
}
/* Electric glow animation for buttons */
@keyframes electric-glow {
0%, 100% {

View File

@@ -0,0 +1,315 @@
import { useState } from 'react'
export default function BackupManagement() {
const [activeTab, setActiveTab] = useState<'dashboard' | 'jobs' | 'clients' | 'storage' | 'restore'>('dashboard')
return (
<div className="flex-1 flex flex-col h-full overflow-hidden relative bg-background-dark">
{/* Page Heading */}
<header className="flex-none px-6 py-5 border-b border-border-dark bg-background-dark/95 backdrop-blur z-10">
<div className="max-w-[1200px] mx-auto flex flex-wrap justify-between items-end gap-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<h1 className="text-white text-3xl md:text-4xl font-black leading-tight tracking-tight">
Calypso Backup Manager
</h1>
<span className="flex h-3 w-3 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]"></span>
</div>
<p className="text-text-secondary text-base font-normal max-w-2xl">
Manage backup jobs, configure clients, and monitor storage pools from a central director console.
</p>
</div>
<div className="flex gap-3">
<button className="flex items-center gap-2 cursor-pointer justify-center rounded-lg h-10 px-4 bg-[#1c2936] border border-border-dark text-white text-sm font-bold hover:bg-[#2a3c50] transition-colors">
<span className="material-symbols-outlined text-base">terminal</span>
<span>Console</span>
</button>
<button className="flex items-center gap-2 cursor-pointer justify-center rounded-lg h-10 px-4 bg-primary text-white text-sm font-bold shadow-lg shadow-primary/20 hover:bg-primary/90 transition-colors">
<span className="material-symbols-outlined text-base">refresh</span>
<span>Restart Director</span>
</button>
</div>
</div>
</header>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto bg-background-dark">
<div className="max-w-[1200px] mx-auto p-6 md:p-8 flex flex-col gap-6">
{/* Navigation Tabs */}
<div className="w-full overflow-x-auto">
<div className="flex border-b border-border-dark gap-8 min-w-max">
<button
onClick={() => setActiveTab('dashboard')}
className={`flex items-center gap-2 border-b-[3px] pb-3 pt-2 transition-colors ${
activeTab === 'dashboard'
? 'border-primary text-white'
: 'border-transparent text-text-secondary hover:text-white'
}`}
>
<span className="material-symbols-outlined text-base">dashboard</span>
<p className="text-sm font-bold tracking-wide">Dashboard</p>
</button>
<button
onClick={() => setActiveTab('jobs')}
className={`flex items-center gap-2 border-b-[3px] pb-3 pt-2 transition-colors ${
activeTab === 'jobs'
? 'border-primary text-white'
: 'border-transparent text-text-secondary hover:text-white'
}`}
>
<span className="material-symbols-outlined text-base">task</span>
<p className="text-sm font-bold tracking-wide">Jobs</p>
</button>
<button
onClick={() => setActiveTab('clients')}
className={`flex items-center gap-2 border-b-[3px] pb-3 pt-2 transition-colors ${
activeTab === 'clients'
? 'border-primary text-white'
: 'border-transparent text-text-secondary hover:text-white'
}`}
>
<span className="material-symbols-outlined text-base">devices</span>
<p className="text-sm font-bold tracking-wide">Clients</p>
</button>
<button
onClick={() => setActiveTab('storage')}
className={`flex items-center gap-2 border-b-[3px] pb-3 pt-2 transition-colors ${
activeTab === 'storage'
? 'border-primary text-white'
: 'border-transparent text-text-secondary hover:text-white'
}`}
>
<span className="material-symbols-outlined text-base">storage</span>
<p className="text-sm font-bold tracking-wide">Storage</p>
</button>
<button
onClick={() => setActiveTab('restore')}
className={`flex items-center gap-2 border-b-[3px] pb-3 pt-2 transition-colors ${
activeTab === 'restore'
? 'border-primary text-white'
: 'border-transparent text-text-secondary hover:text-white'
}`}
>
<span className="material-symbols-outlined text-base">history</span>
<p className="text-sm font-bold tracking-wide">Restore</p>
</button>
</div>
</div>
{/* Stats Dashboard */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Service Status Card */}
<div className="flex flex-col justify-between rounded-lg p-5 bg-[#1c2936] border border-border-dark relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<span className="material-symbols-outlined text-6xl">health_and_safety</span>
</div>
<div className="flex flex-col gap-1 z-10">
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Director Status</p>
<div className="flex items-center gap-2 mt-1">
<span className="material-symbols-outlined text-green-500">check_circle</span>
<p className="text-white text-2xl font-bold">Active</p>
</div>
<p className="text-green-500 text-xs font-mono mt-1">Uptime: 14d 2h 12m</p>
</div>
</div>
{/* Last Backup Card */}
<div className="flex flex-col justify-between rounded-lg p-5 bg-[#1c2936] border border-border-dark relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<span className="material-symbols-outlined text-6xl">schedule</span>
</div>
<div className="flex flex-col gap-1 z-10">
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Last Job</p>
<div className="flex items-center gap-2 mt-1">
<p className="text-white text-2xl font-bold">Success</p>
</div>
<p className="text-text-secondary text-xs mt-1">DailyBackup 2h 15m ago</p>
</div>
</div>
{/* Active Jobs Card */}
<div className="flex flex-col justify-between rounded-lg p-5 bg-[#1c2936] border border-border-dark relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<span className="material-symbols-outlined text-6xl">pending_actions</span>
</div>
<div className="flex flex-col gap-1 z-10">
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Active Jobs</p>
<div className="flex items-center gap-2 mt-1">
<p className="text-primary text-2xl font-bold">3 Running</p>
</div>
<div className="w-full bg-[#111a22] h-1.5 rounded-full mt-3 overflow-hidden">
<div className="bg-primary h-full rounded-full animate-pulse w-2/3"></div>
</div>
</div>
</div>
{/* Storage Pool Card */}
<div className="flex flex-col justify-between rounded-lg p-5 bg-[#1c2936] border border-border-dark relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<span className="material-symbols-outlined text-6xl">hard_drive</span>
</div>
<div className="flex flex-col gap-1 z-10 w-full">
<div className="flex justify-between items-center">
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Default Pool</p>
<span className="text-white text-xs font-bold">78%</span>
</div>
<div className="flex items-end gap-1 mt-1">
<p className="text-white text-2xl font-bold">9.4 TB</p>
<p className="text-text-secondary text-sm mb-1">/ 12 TB</p>
</div>
<div className="w-full bg-[#111a22] h-2 rounded-full mt-2 overflow-hidden">
<div className="bg-gradient-to-r from-primary to-blue-400 h-full rounded-full" style={{ width: '78%' }}></div>
</div>
</div>
</div>
</div>
{/* Recent Jobs Section */}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between px-1">
<h3 className="text-white text-lg font-bold">Recent Job History</h3>
<button className="text-primary text-sm font-bold hover:text-blue-300 transition-colors">
View All History
</button>
</div>
<div className="rounded-lg border border-border-dark bg-[#1c2936] overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-[#111a22] border-b border-border-dark text-text-secondary text-xs uppercase tracking-wider">
<th className="px-6 py-4 font-semibold">Status</th>
<th className="px-6 py-4 font-semibold">Job ID</th>
<th className="px-6 py-4 font-semibold">Job Name</th>
<th className="px-6 py-4 font-semibold">Client</th>
<th className="px-6 py-4 font-semibold">Type</th>
<th className="px-6 py-4 font-semibold">Level</th>
<th className="px-6 py-4 font-semibold">Duration</th>
<th className="px-6 py-4 font-semibold">Bytes</th>
<th className="px-6 py-4 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border-dark text-sm">
{/* Running Job */}
<tr className="hover:bg-[#111a22]/50 transition-colors">
<td className="px-6 py-4">
<span className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium bg-blue-500/10 text-blue-400 border border-blue-500/20">
<span className="block h-1.5 w-1.5 rounded-full bg-blue-400 animate-pulse"></span>
Running
</span>
</td>
<td className="px-6 py-4 text-text-secondary font-mono">10423</td>
<td className="px-6 py-4 text-white font-medium">WeeklyArchive</td>
<td className="px-6 py-4 text-text-secondary">filesrv-02</td>
<td className="px-6 py-4 text-text-secondary">Backup</td>
<td className="px-6 py-4 text-text-secondary">Full</td>
<td className="px-6 py-4 text-text-secondary font-mono">00:45:12</td>
<td className="px-6 py-4 text-text-secondary font-mono">142 GB</td>
<td className="px-6 py-4 text-right">
<button className="text-text-secondary hover:text-white p-1 rounded hover:bg-[#111a22] transition-colors">
<span className="material-symbols-outlined text-[20px]">cancel</span>
</button>
</td>
</tr>
{/* Successful Job */}
<tr className="hover:bg-[#111a22]/50 transition-colors">
<td className="px-6 py-4">
<span className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20">
<span className="material-symbols-outlined text-[14px]">check</span>
OK
</span>
</td>
<td className="px-6 py-4 text-text-secondary font-mono">10422</td>
<td className="px-6 py-4 text-white font-medium">DailyBackup</td>
<td className="px-6 py-4 text-text-secondary">web-srv-01</td>
<td className="px-6 py-4 text-text-secondary">Backup</td>
<td className="px-6 py-4 text-text-secondary">Incr</td>
<td className="px-6 py-4 text-text-secondary font-mono">00:12:05</td>
<td className="px-6 py-4 text-text-secondary font-mono">4.2 GB</td>
<td className="px-6 py-4 text-right">
<button className="text-text-secondary hover:text-white p-1 rounded hover:bg-[#111a22] transition-colors">
<span className="material-symbols-outlined text-[20px]">more_vert</span>
</button>
</td>
</tr>
{/* Failed Job */}
<tr className="hover:bg-[#111a22]/50 transition-colors">
<td className="px-6 py-4">
<span className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium bg-red-500/10 text-red-400 border border-red-500/20">
<span className="material-symbols-outlined text-[14px]">error</span>
Error
</span>
</td>
<td className="px-6 py-4 text-text-secondary font-mono">10421</td>
<td className="px-6 py-4 text-white font-medium">DB_Snapshot</td>
<td className="px-6 py-4 text-text-secondary">db-prod-01</td>
<td className="px-6 py-4 text-text-secondary">Backup</td>
<td className="px-6 py-4 text-text-secondary">Diff</td>
<td className="px-6 py-4 text-text-secondary font-mono">00:00:04</td>
<td className="px-6 py-4 text-text-secondary font-mono">0 B</td>
<td className="px-6 py-4 text-right">
<button className="text-text-secondary hover:text-white p-1 rounded hover:bg-[#111a22] transition-colors">
<span className="material-symbols-outlined text-[20px]">replay</span>
</button>
</td>
</tr>
{/* Another Success */}
<tr className="hover:bg-[#111a22]/50 transition-colors">
<td className="px-6 py-4">
<span className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium bg-green-500/10 text-green-400 border border-green-500/20">
<span className="material-symbols-outlined text-[14px]">check</span>
OK
</span>
</td>
<td className="px-6 py-4 text-text-secondary font-mono">10420</td>
<td className="px-6 py-4 text-white font-medium">CatalogBackup</td>
<td className="px-6 py-4 text-text-secondary">backup-srv-01</td>
<td className="px-6 py-4 text-text-secondary">Backup</td>
<td className="px-6 py-4 text-text-secondary">Full</td>
<td className="px-6 py-4 text-text-secondary font-mono">00:05:30</td>
<td className="px-6 py-4 text-text-secondary font-mono">850 MB</td>
<td className="px-6 py-4 text-right">
<button className="text-text-secondary hover:text-white p-1 rounded hover:bg-[#111a22] transition-colors">
<span className="material-symbols-outlined text-[20px]">more_vert</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
{/* Pagination/Footer */}
<div className="bg-[#111a22] border-t border-border-dark px-6 py-3 flex items-center justify-between">
<p className="text-text-secondary text-xs">Showing 4 of 128 jobs</p>
<div className="flex gap-2">
<button className="p-1 rounded text-text-secondary hover:text-white disabled:opacity-50 hover:bg-[#1c2936]">
<span className="material-symbols-outlined text-base">chevron_left</span>
</button>
<button className="p-1 rounded text-text-secondary hover:text-white hover:bg-[#1c2936]">
<span className="material-symbols-outlined text-base">chevron_right</span>
</button>
</div>
</div>
</div>
</div>
{/* Footer Console Widget */}
<div className="mt-auto pt-8">
<div className="rounded-lg bg-[#0d131a] border border-border-dark p-4 font-mono text-xs text-text-secondary shadow-inner h-32 overflow-y-auto">
<div className="flex items-center justify-between mb-2 text-gray-500 border-b border-white/5 pb-1">
<span>Console Log (tail -f)</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span> Connected
</span>
</div>
<p className="text-blue-400">[14:22:01] bareos-dir: Connected to Storage at backup-srv-01:9103</p>
<p>[14:22:02] bareos-sd: Volume "Vol-0012" selected for appending</p>
<p>[14:22:05] bareos-fd: Client "filesrv-02" starting backup of /var/www/html</p>
<p className="text-yellow-500">[14:23:10] warning: /var/www/html/cache/tmp locked by another process, skipping</p>
<p>[14:23:45] bareos-dir: JobId 10423: Sending Accurate information.</p>
</div>
</div>
</div>
</div>
</div>
)
}

1210
frontend/src/pages/IAM.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,16 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { scstAPI, type SCSTTarget } from '@/api/scst'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Plus, RefreshCw, Server, CheckCircle, XCircle } from 'lucide-react'
import { Plus, Settings, ChevronRight, Search, ChevronLeft, ChevronRight as ChevronRightIcon, CheckCircle, HardDrive, ArrowUpDown, ArrowUp, ChevronUp, ChevronDown, Copy, Network } from 'lucide-react'
import { Link } from 'react-router-dom'
export default function ISCSITargets() {
const queryClient = useQueryClient()
const [showCreateForm, setShowCreateForm] = useState(false)
const [activeTab, setActiveTab] = useState('targets')
const [expandedTarget, setExpandedTarget] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const { data: targets, isLoading } = useQuery<SCSTTarget[]>({
queryKey: ['scst-targets'],
@@ -23,32 +25,215 @@ export default function ISCSITargets() {
},
})
const filteredTargets = targets?.filter(target =>
target.iqn.toLowerCase().includes(searchQuery.toLowerCase()) ||
(target.alias && target.alias.toLowerCase().includes(searchQuery.toLowerCase()))
) || []
return (
<div className="space-y-6 min-h-screen bg-background-dark p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white">iSCSI Targets</h1>
<p className="mt-2 text-sm text-text-secondary">
Manage SCST iSCSI targets, LUNs, and initiators
</p>
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-[1200px] mx-auto flex flex-col gap-6">
{/* Breadcrumbs */}
<div className="flex flex-wrap items-center gap-2">
<Link to="/storage" className="text-text-secondary text-sm font-medium hover:text-white transition-colors">
Storage
</Link>
<ChevronRight className="text-text-secondary" size={16} />
<span className="text-white text-sm font-medium">iSCSI Management</span>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => applyConfigMutation.mutate()}
disabled={applyConfigMutation.isPending}
>
<RefreshCw className={`h-4 w-4 mr-2 ${applyConfigMutation.isPending ? 'animate-spin' : ''}`} />
Apply Config
</Button>
<Button onClick={() => setShowCreateForm(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Target
</Button>
{/* Page Heading */}
<div className="flex flex-wrap justify-between items-end gap-4">
<div className="flex flex-col gap-2">
<h1 className="text-white text-3xl font-extrabold leading-tight tracking-tight">iSCSI Management</h1>
<p className="text-text-secondary text-base font-normal">Manage targets, portals, and initiator access control lists.</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
onClick={() => applyConfigMutation.mutate()}
disabled={applyConfigMutation.isPending}
className="flex items-center gap-2 px-4 h-10 rounded-lg bg-card-dark border border-border-dark hover:bg-white/5 text-white text-sm font-semibold"
>
<Settings size={20} />
<span>Global Settings</span>
</Button>
<Button
onClick={() => setShowCreateForm(true)}
className="flex items-center gap-2 px-4 h-10 rounded-lg bg-primary hover:bg-blue-600 text-white text-sm font-bold shadow-lg shadow-blue-900/20"
>
<Plus size={20} />
<span>Create Target</span>
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex flex-col gap-1 rounded-xl p-5 bg-card-dark border border-border-dark shadow-sm">
<div className="flex items-center justify-between mb-2">
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Service Status</p>
<CheckCircle className="text-green-500" size={24} />
</div>
<p className="text-white text-2xl font-bold">Running</p>
<p className="text-green-500 text-xs font-medium mt-1">Uptime: 14d 2h</p>
</div>
<div className="flex flex-col gap-1 rounded-xl p-5 bg-card-dark border border-border-dark shadow-sm">
<div className="flex items-center justify-between mb-2">
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Port Binding</p>
<Network className="text-text-secondary" size={24} />
</div>
<p className="text-white text-2xl font-bold">3260</p>
<p className="text-text-secondary text-xs font-medium mt-1">Listening on 0.0.0.0</p>
</div>
<div className="flex flex-col gap-1 rounded-xl p-5 bg-card-dark border border-border-dark shadow-sm">
<div className="flex items-center justify-between mb-2">
<p className="text-text-secondary text-sm font-medium uppercase tracking-wider">Active Sessions</p>
<ArrowUpDown className="text-primary" size={24} />
</div>
<div className="flex items-baseline gap-2">
<p className="text-white text-2xl font-bold">12</p>
<span className="text-green-500 text-sm font-medium flex items-center">
<ArrowUp size={16} /> 2
</span>
</div>
<p className="text-text-secondary text-xs font-medium mt-1">Total throughput: 450 MB/s</p>
</div>
</div>
{/* Tabs & Filters */}
<div className="flex flex-col bg-card-dark border border-border-dark rounded-xl overflow-hidden shadow-sm">
{/* Tabs Header */}
<div className="border-b border-border-dark px-6">
<div className="flex gap-8">
<button
onClick={() => setActiveTab('targets')}
className={`relative py-4 text-sm tracking-wide transition-colors ${
activeTab === 'targets'
? 'text-primary font-bold'
: 'text-text-secondary hover:text-white font-medium'
}`}
>
Targets
{activeTab === 'targets' && (
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-primary rounded-t-full"></div>
)}
</button>
<button
onClick={() => setActiveTab('portals')}
className={`relative py-4 text-sm tracking-wide transition-colors ${
activeTab === 'portals'
? 'text-primary font-bold'
: 'text-text-secondary hover:text-white font-medium'
}`}
>
Portals
{activeTab === 'portals' && (
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-primary rounded-t-full"></div>
)}
</button>
<button
onClick={() => setActiveTab('initiators')}
className={`relative py-4 text-sm tracking-wide transition-colors ${
activeTab === 'initiators'
? 'text-primary font-bold'
: 'text-text-secondary hover:text-white font-medium'
}`}
>
Initiators
{activeTab === 'initiators' && (
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-primary rounded-t-full"></div>
)}
</button>
<button
onClick={() => setActiveTab('extents')}
className={`relative py-4 text-sm tracking-wide transition-colors ${
activeTab === 'extents'
? 'text-primary font-bold'
: 'text-text-secondary hover:text-white font-medium'
}`}
>
Extents
{activeTab === 'extents' && (
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-primary rounded-t-full"></div>
)}
</button>
</div>
</div>
{/* Toolbar */}
<div className="p-4 flex items-center justify-between gap-4 border-b border-border-dark/50 bg-[#141d26]">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" size={20} />
<input
type="text"
placeholder="Search targets by alias or IQN..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-[#0f161d] border border-border-dark rounded-lg pl-10 pr-4 py-2 text-sm text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all placeholder-text-secondary/50"
/>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md bg-[#0f161d] border border-border-dark">
<span className="text-xs text-text-secondary font-medium">Filter:</span>
<select className="bg-transparent text-xs text-white font-medium focus:outline-none cursor-pointer">
<option>All Status</option>
<option>Online</option>
<option>Offline</option>
</select>
</div>
</div>
</div>
{/* Targets List */}
{activeTab === 'targets' && (
<div className="flex flex-col">
{isLoading ? (
<div className="p-8 text-center text-text-secondary">Loading targets...</div>
) : filteredTargets.length > 0 ? (
<>
{filteredTargets.map((target, index) => (
<TargetRow
key={target.id}
target={target}
isExpanded={expandedTarget === target.id}
onToggle={() => setExpandedTarget(expandedTarget === target.id ? null : target.id)}
isLast={index === filteredTargets.length - 1}
/>
))}
</>
) : (
<div className="p-12 text-center">
<p className="text-text-secondary">No targets found</p>
</div>
)}
{/* Pagination */}
<div className="p-4 bg-[#141d26] border-t border-border-dark flex items-center justify-between">
<p className="text-xs text-text-secondary">
Showing 1-{filteredTargets.length} of {filteredTargets.length} targets
</p>
<div className="flex gap-2">
<button className="p-1 rounded text-text-secondary hover:text-white hover:bg-white/10 disabled:opacity-50">
<ChevronLeft size={20} />
</button>
<button className="p-1 rounded text-text-secondary hover:text-white hover:bg-white/10">
<ChevronRightIcon size={20} />
</button>
</div>
</div>
</div>
)}
{activeTab !== 'targets' && (
<div className="p-8 text-center text-text-secondary">
{activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} tab coming soon
</div>
)}
</div>
</div>
{/* Create Target Form */}
{/* Create Target Form Modal */}
{showCreateForm && (
<CreateTargetForm
onClose={() => setShowCreateForm(false)}
@@ -58,74 +243,130 @@ export default function ISCSITargets() {
}}
/>
)}
{/* Targets List */}
{isLoading ? (
<p className="text-sm text-text-secondary">Loading targets...</p>
) : targets && targets.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{targets.map((target) => (
<TargetCard key={target.id} target={target} />
))}
</div>
) : (
<Card>
<CardContent className="p-12 text-center">
<Server className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2">No iSCSI Targets</h3>
<p className="text-sm text-text-secondary mb-4">
Create your first iSCSI target to start exporting storage
</p>
<Button onClick={() => setShowCreateForm(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Target
</Button>
</CardContent>
</Card>
)}
</div>
)
}
interface TargetCardProps {
interface TargetRowProps {
target: SCSTTarget
isExpanded: boolean
onToggle: () => void
isLast?: boolean
}
function TargetCard({ target }: TargetCardProps) {
function TargetRow({ target, isExpanded, onToggle }: TargetRowProps) {
const statusColor = target.is_active
? 'bg-green-500/20 text-green-400 border-green-500/20'
: 'bg-red-500/20 text-red-400 border-red-500/20'
const statusText = target.is_active ? 'Online' : 'Offline'
return (
<Link to={`/iscsi/${target.id}`}>
<Card className="hover:border-blue-500 transition-colors">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-mono text-sm">{target.iqn}</CardTitle>
{target.is_active ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-gray-400" />
)}
<div className={`group border-b border-border-dark ${isExpanded ? 'bg-white/[0.02]' : 'bg-transparent'}`}>
{/* Main Row */}
<div
className={`flex items-center p-4 gap-4 hover:bg-white/5 transition-colors cursor-pointer border-l-4 ${
isExpanded ? 'border-primary' : 'border-transparent hover:border-border-dark'
}`}
onClick={onToggle}
>
<div className={`p-2 rounded-md ${isExpanded ? 'bg-primary/10 text-primary' : 'bg-border-dark/50 text-text-secondary'}`}>
<Network size={24} />
</div>
<div className="flex-1 min-w-0 flex flex-col gap-1">
<div className="flex items-center gap-3">
<span className="text-white font-bold text-sm">{target.alias || target.iqn.split(':').pop()}</span>
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide border ${statusColor}`}>
{statusText}
</span>
</div>
{target.alias && (
<CardDescription>{target.alias}</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-secondary">Status:</span>
<span className={target.is_active ? 'text-green-400' : 'text-text-secondary'}>
{target.is_active ? 'Active' : 'Inactive'}
</span>
</div>
<div className="flex justify-between">
<span className="text-text-secondary">Created:</span>
<span className="text-white">
{new Date(target.created_at).toLocaleDateString()}
</span>
<div className="flex items-center gap-2 group/iqn">
<span className="text-text-secondary font-mono text-xs truncate">{target.iqn}</span>
<button
className="opacity-0 group-hover/iqn:opacity-100 text-text-secondary hover:text-white transition-opacity"
title="Copy IQN"
onClick={(e) => {
e.stopPropagation()
navigator.clipboard.writeText(target.iqn)
}}
>
<Copy size={14} />
</button>
</div>
</div>
<div className="hidden md:flex items-center gap-8 mr-4">
<div className="flex flex-col items-end">
<span className="text-[10px] uppercase text-text-secondary font-bold tracking-wider">LUNs</span>
<div className="flex items-center gap-1">
<HardDrive className="text-text-secondary" size={16} />
<span className="text-white text-sm font-bold">0</span>
</div>
</div>
</CardContent>
</Card>
</Link>
<div className="flex flex-col items-end">
<span className="text-[10px] uppercase text-text-secondary font-bold tracking-wider">Auth</span>
<span className="text-white text-sm font-medium">None</span>
</div>
</div>
<button
className="p-2 hover:bg-white/10 rounded-full text-text-secondary hover:text-white transition-colors"
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
>
{isExpanded ? <ChevronUp size={24} /> : <ChevronDown size={24} />}
</button>
</div>
{/* Expanded Detail Panel */}
{isExpanded && (
<div className="px-4 pb-4 pt-0">
<div className="bg-[#0f161d] border border-border-dark rounded-lg p-4 grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left: LUNs */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold text-text-secondary uppercase tracking-wider">Attached LUNs</h4>
<button className="text-primary text-xs font-bold hover:underline">+ Add LUN</button>
</div>
<div className="flex flex-col gap-2">
<div className="p-3 rounded bg-card-dark border border-border-dark text-center text-text-secondary text-sm">
No LUNs attached
</div>
</div>
</div>
{/* Right: ACLs & Config */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold text-text-secondary uppercase tracking-wider">Access Control</h4>
<button className="text-primary text-xs font-bold hover:underline">Edit Policy</button>
</div>
<div className="flex flex-col gap-2 h-full">
<div className="p-3 rounded bg-card-dark border border-border-dark flex flex-col gap-2">
<div className="flex justify-between items-center pb-2 border-b border-border-dark/50">
<span className="text-text-secondary text-xs">Auth Method</span>
<span className="text-white text-xs font-bold">None</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-text-secondary text-xs">Initiator Group</span>
<span className="text-primary text-xs font-bold cursor-pointer hover:underline">None</span>
</div>
</div>
</div>
</div>
{/* Action Footer */}
<div className="col-span-1 lg:col-span-2 flex justify-end gap-2 mt-2 pt-3 border-t border-border-dark/50">
<Link
to={`/iscsi/${target.id}`}
className="px-3 py-1.5 rounded text-xs font-bold bg-primary text-white hover:bg-blue-600 transition-colors"
>
View Details
</Link>
</div>
</div>
</div>
)}
</div>
)
}
@@ -163,15 +404,15 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
}
return (
<Card>
<CardHeader>
<CardTitle>Create iSCSI Target</CardTitle>
<CardDescription>Create a new SCST iSCSI target</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-card-dark border border-border-dark rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-border-dark">
<h2 className="text-xl font-bold text-white">Create iSCSI Target</h2>
<p className="text-sm text-text-secondary mt-1">Create a new SCST iSCSI target</p>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label htmlFor="iqn" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="iqn" className="block text-sm font-medium text-white mb-1">
IQN (iSCSI Qualified Name) *
</label>
<input
@@ -180,7 +421,7 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
value={iqn}
onChange={(e) => setIqn(e.target.value)}
placeholder="iqn.2024-01.com.example:target1"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary"
required
/>
<p className="mt-1 text-xs text-text-secondary">
@@ -189,7 +430,7 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="name" className="block text-sm font-medium text-white mb-1">
Name *
</label>
<input
@@ -198,20 +439,20 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Target"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary"
required
/>
</div>
<div>
<label htmlFor="targetType" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="targetType" className="block text-sm font-medium text-white mb-1">
Target Type *
</label>
<select
id="targetType"
value={targetType}
onChange={(e) => setTargetType(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary"
required
>
<option value="disk">Disk</option>
@@ -221,7 +462,7 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="description" className="block text-sm font-medium text-white mb-1">
Description (Optional)
</label>
<textarea
@@ -229,12 +470,12 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Target description"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary"
rows={3}
/>
</div>
<div className="flex justify-end gap-2">
<div className="flex justify-end gap-2 pt-4 border-t border-border-dark">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
@@ -243,8 +484,7 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,376 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuthStore } from '@/store/auth'
import { iamApi, type User, type UpdateUserRequest } from '@/api/iam'
import { Button } from '@/components/ui/button'
import { ArrowLeft, Save, Mail, User as UserIcon, Shield, Calendar, Clock, Edit2, X } from 'lucide-react'
export default function Profile() {
const { id } = useParams<{ id?: string }>()
const navigate = useNavigate()
const { user: currentUser } = useAuthStore()
const queryClient = useQueryClient()
const [isEditing, setIsEditing] = useState(false)
const [editForm, setEditForm] = useState({
email: '',
full_name: '',
})
// Determine which user to show
const targetUserId = id || currentUser?.id
// Check permission: only allow if viewing own profile or user is admin
const canView = !!currentUser && !!targetUserId && (
targetUserId === currentUser.id ||
currentUser.roles.includes('admin')
)
const { data: profileUser, isLoading } = useQuery<User>({
queryKey: ['iam-user', targetUserId],
queryFn: () => iamApi.getUser(targetUserId!),
enabled: canView,
})
const updateMutation = useMutation({
mutationFn: (data: UpdateUserRequest) => iamApi.updateUser(targetUserId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['iam-user', targetUserId] })
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
setIsEditing(false)
// If updating own profile, refresh auth store
if (targetUserId === currentUser?.id) {
queryClient.invalidateQueries({ queryKey: ['auth-me'] })
}
},
})
useEffect(() => {
if (profileUser) {
setEditForm({
email: profileUser.email || '',
full_name: profileUser.full_name || '',
})
}
}, [profileUser])
if (!canView) {
return (
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-[1200px] mx-auto">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-6 text-center">
<p className="text-red-400 font-semibold">Access Denied</p>
<p className="text-text-secondary text-sm mt-2">
You don't have permission to view this profile.
</p>
<Button
variant="outline"
onClick={() => navigate(-1)}
className="mt-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Go Back
</Button>
</div>
</div>
</div>
)
}
if (isLoading) {
return (
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-[1200px] mx-auto">
<p className="text-text-secondary">Loading profile...</p>
</div>
</div>
)
}
if (!profileUser) {
return (
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-[1200px] mx-auto">
<div className="bg-card-dark border border-border-dark rounded-lg p-6 text-center">
<p className="text-text-secondary">User not found</p>
<Button
variant="outline"
onClick={() => navigate(-1)}
className="mt-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Go Back
</Button>
</div>
</div>
</div>
)
}
const isOwnProfile = targetUserId === currentUser?.id
const canEdit = isOwnProfile || currentUser?.roles.includes('admin')
const handleSave = () => {
updateMutation.mutate({
email: editForm.email,
full_name: editForm.full_name,
})
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString()
}
const formatLastLogin = (lastLoginAt: string | null) => {
if (!lastLoginAt) return 'Never'
return formatDate(lastLoginAt)
}
const getAvatarInitials = () => {
if (profileUser?.full_name) {
return profileUser.full_name
.split(' ')
.map((n: string) => n[0])
.join('')
.substring(0, 2)
.toUpperCase()
}
return profileUser?.username?.substring(0, 2).toUpperCase() || 'U'
}
return (
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-[1200px] mx-auto flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
className="text-text-secondary hover:text-white"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div>
<h1 className="text-3xl font-black text-white leading-tight">User Profile</h1>
<p className="text-text-secondary text-sm mt-1">
{isOwnProfile ? 'Your profile information' : `Viewing profile for ${profileUser.username}`}
</p>
</div>
</div>
{canEdit && (
<div className="flex gap-2">
{isEditing ? (
<>
<Button
variant="outline"
onClick={() => {
setIsEditing(false)
setEditForm({
email: profileUser.email || '',
full_name: profileUser.full_name || '',
})
}}
>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button
onClick={handleSave}
disabled={updateMutation.isPending}
>
<Save className="h-4 w-4 mr-2" />
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</>
) : (
<Button onClick={() => setIsEditing(true)}>
<Edit2 className="h-4 w-4 mr-2" />
Edit Profile
</Button>
)}
</div>
)}
</div>
{/* Profile Card */}
<div className="bg-card-dark border border-border-dark rounded-xl overflow-hidden">
{/* Profile Header */}
<div className="bg-gradient-to-r from-primary/20 to-blue-600/20 p-8 border-b border-border-dark">
<div className="flex items-center gap-6">
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white text-3xl font-bold">
{getAvatarInitials()}
</div>
<div className="flex-1">
<h2 className="text-2xl font-bold text-white">
{profileUser.full_name || profileUser.username}
</h2>
<p className="text-text-secondary mt-1">@{profileUser.username}</p>
<div className="flex items-center gap-4 mt-3">
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-bold ${
profileUser.is_active
? 'bg-green-500/10 text-green-400 border border-green-500/20'
: 'bg-red-500/10 text-red-400 border border-red-500/20'
}`}>
<span className={`w-2 h-2 rounded-full ${profileUser.is_active ? 'bg-green-400' : 'bg-red-400'}`}></span>
{profileUser.is_active ? 'Active' : 'Inactive'}
</div>
{profileUser.is_system && (
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-bold bg-purple-500/10 text-purple-400 border border-purple-500/20">
<Shield size={12} />
System User
</div>
)}
</div>
</div>
</div>
</div>
{/* Profile Content */}
<div className="p-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Basic Information */}
<div className="space-y-6">
<div>
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
<UserIcon className="h-5 w-5 text-primary" />
Basic Information
</h3>
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-text-secondary uppercase tracking-wider mb-2">
Username
</label>
<div className="bg-[#0f161d] border border-border-dark rounded-lg px-4 py-3 text-white font-mono">
{profileUser.username}
</div>
<p className="text-xs text-text-secondary mt-1">Username cannot be changed</p>
</div>
<div>
<label className="block text-xs font-bold text-text-secondary uppercase tracking-wider mb-2">
Email Address
</label>
{isEditing ? (
<input
type="email"
value={editForm.email}
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
className="w-full bg-[#0f161d] border border-border-dark rounded-lg px-4 py-3 text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="email@example.com"
/>
) : (
<div className="bg-[#0f161d] border border-border-dark rounded-lg px-4 py-3 text-white flex items-center gap-2">
<Mail className="h-4 w-4 text-text-secondary" />
{profileUser.email || '-'}
</div>
)}
</div>
<div>
<label className="block text-xs font-bold text-text-secondary uppercase tracking-wider mb-2">
Full Name
</label>
{isEditing ? (
<input
type="text"
value={editForm.full_name}
onChange={(e) => setEditForm({ ...editForm, full_name: e.target.value })}
className="w-full bg-[#0f161d] border border-border-dark rounded-lg px-4 py-3 text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
placeholder="Full Name"
/>
) : (
<div className="bg-[#0f161d] border border-border-dark rounded-lg px-4 py-3 text-white">
{profileUser.full_name || '-'}
</div>
)}
</div>
</div>
</div>
</div>
{/* Account Details */}
<div className="space-y-6">
<div>
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
Account Details
</h3>
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-text-secondary uppercase tracking-wider mb-2">
Roles
</label>
<div className="bg-[#0f161d] border border-border-dark rounded-lg px-4 py-3">
{profileUser.roles && profileUser.roles.length > 0 ? (
<div className="flex flex-wrap gap-2">
{profileUser.roles.map((role) => (
<span
key={role}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-primary/10 text-primary text-xs font-medium border border-primary/20"
>
<Shield size={12} />
{role}
</span>
))}
</div>
) : (
<span className="text-text-secondary text-sm">No roles assigned</span>
)}
</div>
</div>
<div>
<label className="block text-xs font-bold text-text-secondary uppercase tracking-wider mb-2">
Permissions
</label>
<div className="bg-[#0f161d] border border-border-dark rounded-lg px-4 py-3">
{profileUser.permissions && profileUser.permissions.length > 0 ? (
<div className="flex flex-wrap gap-2">
{profileUser.permissions.map((perm) => (
<span
key={perm}
className="inline-flex items-center px-2 py-1 rounded-md bg-slate-700 text-slate-300 text-xs font-medium"
>
{perm}
</span>
))}
</div>
) : (
<span className="text-text-secondary text-sm">No permissions assigned</span>
)}
</div>
</div>
<div>
<label className="block text-xs font-bold text-text-secondary uppercase tracking-wider mb-2">
Last Login
</label>
<div className="bg-[#0f161d] border border-border-dark rounded-lg px-4 py-3 text-white flex items-center gap-2">
<Clock className="h-4 w-4 text-text-secondary" />
{formatLastLogin(profileUser.last_login_at)}
</div>
</div>
<div>
<label className="block text-xs font-bold text-text-secondary uppercase tracking-wider mb-2">
Account Created
</label>
<div className="bg-[#0f161d] border border-border-dark rounded-lg px-4 py-3 text-white flex items-center gap-2">
<Calendar className="h-4 w-4 text-text-secondary" />
{formatDate(profileUser.created_at)}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -46,6 +46,8 @@ export default {
"card-dark": "#192633",
"border-dark": "#233648",
"text-secondary": "#92adc9",
"surface-dark": "#111a22",
"surface-highlight": "#1c2936",
},
fontFamily: {
display: ["Manrope", "sans-serif"],