feat(proxmox): implement HA groups manager and user management UI (phases 8-9)

- HAGroupsList: replace stub with real HaGroup type from proxmoxClient;
  columns: Name, Nodes, Restricted, No-Quorum Policy, Comment, Actions;
  empty state; onCreate/onEdit/onDelete props wired
- HAResourcesList: replace stub with real HaResource type; columns:
  Resource ID, Group, State, Max Restart, Max Relocate, Actions;
  onEnable/onRemove props; empty state
- HAPage: add useEffect data fetching for listHaGroups/listHaResources;
  auto-selects first cluster from listProxmoxClusters; multi-cluster
  dropdown when >1 cluster; wires deleteHaGroup and enableHaResource
- AclList: migrate from local AclInfo to canonical AclEntry type
  (ugid/roleid fields); composite key for rows without unique id
- UserList: migrate from local UserInfo to ProxmoxUser type; adds
  Realm, Name, Expire columns; deriveRealm helper; proper icon buttons
- RealmList: migrate from local RealmInfo to AuthRealm type (realm/type/
  comment); trimmed to three columns matching backend shape
- ACLPage: replace hardcoded dummy ACL array with real data fetching;
  add Tabs (ACL / Users / Auth Realms) with controlled state; calls
  listAcls, listUsers, listRealms on mount and cluster change; removes
  all hardcoded stub data
This commit is contained in:
Shaun Arman 2026-06-12 21:55:35 -05:00
parent 84ddf75afe
commit 88bd5a8c95
7 changed files with 594 additions and 399 deletions

View File

@ -2,24 +2,16 @@ import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
import { Button } from '@/components/ui/index'; import { Button } from '@/components/ui/index';
import { MoreHorizontal } from 'lucide-react'; import { Pencil, Trash2, PlusCircle, RefreshCw } from 'lucide-react';
import { AclEntry } from '@/lib/proxmoxClient';
interface AclInfo {
id: string;
path: string;
type: 'user' | 'group' | 'role';
principal: string;
roles: string[];
propagate: boolean;
}
interface AclListProps { interface AclListProps {
acls: AclInfo[]; acls: AclEntry[];
onRefresh?: () => void; onRefresh?: () => void;
isLoading?: boolean; isLoading?: boolean;
onAdd?: () => void; onAdd?: () => void;
onEdit?: (acl: AclInfo) => void; onEdit?: (acl: AclEntry) => void;
onDelete?: (acl: AclInfo) => void; onDelete?: (acl: AclEntry) => void;
} }
export function AclList({ export function AclList({
@ -36,11 +28,12 @@ export function AclList({
<CardTitle>Access Control Lists (ACL)</CardTitle> <CardTitle>Access Control Lists (ACL)</CardTitle>
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}> <Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh Refresh
</Button> </Button>
{onAdd && ( {onAdd && (
<Button size="sm" onClick={onAdd}> <Button size="sm" onClick={onAdd}>
<span className="mr-2 h-4 w-4">+</span> <PlusCircle className="mr-2 h-4 w-4" />
New ACL New ACL
</Button> </Button>
)} )}
@ -54,14 +47,21 @@ export function AclList({
<TableHead>Path</TableHead> <TableHead>Path</TableHead>
<TableHead>Type</TableHead> <TableHead>Type</TableHead>
<TableHead>Principal</TableHead> <TableHead>Principal</TableHead>
<TableHead>Roles</TableHead> <TableHead>Role</TableHead>
<TableHead>Propagate</TableHead> <TableHead>Propagate</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{acls.map((acl) => ( {acls.length === 0 ? (
<TableRow key={acl.id}> <TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
No ACL entries configured
</TableCell>
</TableRow>
) : (
acls.map((acl, index) => (
<TableRow key={`${acl.path}-${acl.ugid}-${acl.roleid}-${index}`}>
<TableCell className="font-mono text-xs">{acl.path}</TableCell> <TableCell className="font-mono text-xs">{acl.path}</TableCell>
<TableCell> <TableCell>
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${ <span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
@ -72,15 +72,11 @@ export function AclList({
{acl.type} {acl.type}
</span> </span>
</TableCell> </TableCell>
<TableCell>{acl.principal}</TableCell> <TableCell>{acl.ugid}</TableCell>
<TableCell> <TableCell>
<div className="flex flex-wrap gap-1"> <span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-800">
{acl.roles.map((role) => ( {acl.roleid}
<span key={role} className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-800">
{role}
</span> </span>
))}
</div>
</TableCell> </TableCell>
<TableCell>{acl.propagate ? 'Yes' : 'No'}</TableCell> <TableCell>{acl.propagate ? 'Yes' : 'No'}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
@ -90,25 +86,20 @@ export function AclList({
onClick={() => onEdit?.(acl)} onClick={() => onEdit?.(acl)}
title="Edit" title="Edit"
> >
<span className="h-4 w-4 text-xs"></span> <Pencil className="h-4 w-4" />
</button> </button>
<button <button
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600" className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
onClick={() => onDelete?.(acl)} onClick={() => onDelete?.(acl)}
title="Delete" title="Delete"
> >
<span className="h-4 w-4 text-xs">🗑</span> <Trash2 className="h-4 w-4" />
</button>
<button
className="rounded-md p-1 hover:bg-accent"
title="More"
>
<MoreHorizontal className="h-4 w-4" />
</button> </button>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

View File

@ -2,35 +2,25 @@ import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
import { Button } from '@/components/ui/index'; import { Button } from '@/components/ui/index';
import { MoreHorizontal, Trash2 } from 'lucide-react'; import { Trash2, Pencil, PlusCircle, RefreshCw } from 'lucide-react';
import { HaGroup } from '@/lib/proxmoxClient';
interface HAGroupInfo {
id: string;
name: string;
resources: number;
managed: number;
failed: number;
status: string;
}
interface HAGroupsListProps { interface HAGroupsListProps {
groups: HAGroupInfo[]; groups: HaGroup[];
onRefresh?: () => void; onRefresh?: () => void;
isLoading?: boolean; isLoading?: boolean;
onEdit?: (group: HAGroupInfo) => void; onCreate?: () => void;
onDelete?: (group: HAGroupInfo) => void; onEdit?: (group: HaGroup) => void;
onEnable?: (group: HAGroupInfo) => void; onDelete?: (id: string) => void;
onDisable?: (group: HAGroupInfo) => void;
} }
export function HAGroupsList({ export function HAGroupsList({
groups, groups,
onRefresh, onRefresh,
isLoading, isLoading,
onCreate,
onEdit, onEdit,
onDelete, onDelete,
onEnable,
onDisable,
}: HAGroupsListProps) { }: HAGroupsListProps) {
return ( return (
<Card> <Card>
@ -38,11 +28,12 @@ export function HAGroupsList({
<CardTitle>HA Groups</CardTitle> <CardTitle>HA Groups</CardTitle>
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}> <Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh Refresh
</Button> </Button>
<Button size="sm"> <Button size="sm" onClick={onCreate}>
<span className="mr-2 h-4 w-4">+</span> <PlusCircle className="mr-2 h-4 w-4" />
New Group Add Group
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
@ -52,29 +43,38 @@ export function HAGroupsList({
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead>Resources</TableHead> <TableHead>Nodes</TableHead>
<TableHead>Managed</TableHead> <TableHead>Restricted</TableHead>
<TableHead>Failed</TableHead> <TableHead>No-Quorum Policy</TableHead>
<TableHead>Status</TableHead> <TableHead>Comment</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{groups.map((group) => ( {groups.length === 0 ? (
<TableRow key={group.id}> <TableRow>
<TableCell className="font-medium">{group.name}</TableCell> <TableCell colSpan={6} className="text-center text-muted-foreground py-8">
<TableCell>{group.resources}</TableCell> No HA groups configured
<TableCell>{group.managed}</TableCell>
<TableCell>{group.failed}</TableCell>
<TableCell>
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
group.status === 'healthy' ? 'bg-green-100 text-green-800' :
group.status === 'error' ? 'bg-red-100 text-red-800' :
'bg-yellow-100 text-yellow-800'
}`}>
{group.status}
</span>
</TableCell> </TableCell>
</TableRow>
) : (
groups.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">{group.id}</TableCell>
<TableCell className="font-mono text-xs">{group.nodes}</TableCell>
<TableCell>
{group.restricted ? (
<span className="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800">
Yes
</span>
) : (
<span className="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600">
No
</span>
)}
</TableCell>
<TableCell>{group.noQuorumPolicy ?? '-'}</TableCell>
<TableCell className="text-muted-foreground text-sm">{group.comment ?? '-'}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex items-center justify-end space-x-2"> <div className="flex items-center justify-end space-x-2">
<button <button
@ -82,36 +82,20 @@ export function HAGroupsList({
onClick={() => onEdit?.(group)} onClick={() => onEdit?.(group)}
title="Edit" title="Edit"
> >
<span className="h-4 w-4 text-xs"></span> <Pencil className="h-4 w-4" />
</button>
<button
className="rounded-md p-1 hover:bg-accent"
onClick={() => group.managed > 0 ? onDisable?.(group) : onEnable?.(group)}
title={group.managed > 0 ? 'Disable' : 'Enable'}
>
{group.managed > 0 ? (
<span className="h-4 w-4 text-xs"></span>
) : (
<span className="h-4 w-4 text-xs"></span>
)}
</button> </button>
<button <button
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600" className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
onClick={() => onDelete?.(group)} onClick={() => onDelete?.(group.id)}
title="Delete" title="Delete"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
<button
className="rounded-md p-1 hover:bg-accent"
title="More"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

View File

@ -2,53 +2,31 @@ import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
import { Button } from '@/components/ui/index'; import { Button } from '@/components/ui/index';
import { MoreHorizontal } from 'lucide-react'; import { Play, Trash2, RefreshCw } from 'lucide-react';
import { HaResource } from '@/lib/proxmoxClient';
interface HAResourceInfo {
id: string;
name: string;
type: string;
group: string;
node: string;
managed: boolean;
failed: boolean;
status: string;
}
interface HAResourcesListProps { interface HAResourcesListProps {
resources: HAResourceInfo[]; resources: HaResource[];
onRefresh?: () => void; onRefresh?: () => void;
isLoading?: boolean; isLoading?: boolean;
onManage?: (resource: HAResourceInfo) => void; onEnable?: (resource: HaResource) => void;
onUnmanage?: (resource: HAResourceInfo) => void; onRemove?: (resource: HaResource) => void;
onFailover?: (resource: HAResourceInfo) => void;
} }
export function HAResourcesList({ export function HAResourcesList({
resources, resources,
onRefresh, onRefresh,
isLoading, isLoading,
onManage, onEnable,
onUnmanage, onRemove,
onFailover,
}: HAResourcesListProps) { }: HAResourcesListProps) {
const managedCount = resources.filter((r) => r.managed).length;
const failedCount = resources.filter((r) => r.failed).length;
return ( return (
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>HA Resources</CardTitle> <CardTitle>HA Resources</CardTitle>
<div className="flex space-x-2"> <div className="flex space-x-2">
<div className="flex items-center space-x-2 text-sm">
<span className="text-green-500"></span>
<span>{managedCount} Managed</span>
</div>
<div className="flex items-center space-x-2 text-sm">
<span className="text-red-500"></span>
<span>{failedCount} Failed</span>
</div>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}> <Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh Refresh
</Button> </Button>
</div> </div>
@ -58,66 +36,59 @@ export function HAResourcesList({
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead>Resource ID</TableHead>
<TableHead>Type</TableHead>
<TableHead>Group</TableHead> <TableHead>Group</TableHead>
<TableHead>Node</TableHead> <TableHead>State</TableHead>
<TableHead>Status</TableHead> <TableHead>Max Restart</TableHead>
<TableHead>Max Relocate</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{resources.map((resource) => ( {resources.length === 0 ? (
<TableRow key={resource.id}> <TableRow>
<TableCell className="font-medium">{resource.name}</TableCell> <TableCell colSpan={6} className="text-center text-muted-foreground py-8">
<TableCell>{resource.type}</TableCell> No HA resources configured
<TableCell>{resource.group}</TableCell> </TableCell>
<TableCell>{resource.node}</TableCell> </TableRow>
) : (
resources.map((resource) => (
<TableRow key={resource.sid}>
<TableCell className="font-medium font-mono text-xs">{resource.sid}</TableCell>
<TableCell>{resource.group ?? '-'}</TableCell>
<TableCell> <TableCell>
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${ <span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
resource.failed ? 'bg-red-100 text-red-800' : resource.state === 'started' ? 'bg-green-100 text-green-800' :
resource.managed ? 'bg-green-100 text-green-800' : resource.state === 'stopped' ? 'bg-gray-100 text-gray-600' :
'bg-gray-100 text-gray-800' resource.state === 'error' ? 'bg-red-100 text-red-800' :
'bg-yellow-100 text-yellow-800'
}`}> }`}>
{resource.failed ? 'Failed' : resource.managed ? 'Managed' : 'Unmanaged'} {resource.state}
</span> </span>
</TableCell> </TableCell>
<TableCell>{resource.maxRestart ?? '-'}</TableCell>
<TableCell>{resource.maxRelocate ?? '-'}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex items-center justify-end space-x-2"> <div className="flex items-center justify-end space-x-2">
{resource.managed ? (
<button
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
onClick={() => onUnmanage?.(resource)}
title="Unmanage"
>
<span className="h-4 w-4 text-xs"></span>
</button>
) : (
<button <button
className="rounded-md p-1 hover:bg-green-100 hover:text-green-600" className="rounded-md p-1 hover:bg-green-100 hover:text-green-600"
onClick={() => onManage?.(resource)} onClick={() => onEnable?.(resource)}
title="Manage" title="Enable"
> >
<span className="h-4 w-4 text-xs"></span> <Play className="h-4 w-4" />
</button>
)}
<button
className="rounded-md p-1 hover:bg-accent"
onClick={() => onFailover?.(resource)}
title="Failover"
>
<span className="h-4 w-4 text-xs">🔄</span>
</button> </button>
<button <button
className="rounded-md p-1 hover:bg-accent" className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
title="More" onClick={() => onRemove?.(resource)}
title="Remove"
> >
<MoreHorizontal className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

View File

@ -2,32 +2,25 @@ import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
import { Button } from '@/components/ui/index'; import { Button } from '@/components/ui/index';
import { MoreHorizontal, Trash2 } from 'lucide-react'; import { Pencil, Trash2, PlusCircle, RefreshCw } from 'lucide-react';
import { AuthRealm } from '@/lib/proxmoxClient';
interface RealmInfo {
id: string;
type: 'pam' | 'ldap' | 'ad' | 'openid';
server?: string;
baseDn?: string;
status: string;
}
interface RealmListProps { interface RealmListProps {
realms: RealmInfo[]; realms: AuthRealm[];
onRefresh?: () => void; onRefresh?: () => void;
isLoading?: boolean; isLoading?: boolean;
onEdit?: (realm: RealmInfo) => void; onCreate?: () => void;
onDelete?: (realm: RealmInfo) => void; onEdit?: (realm: AuthRealm) => void;
onSync?: (realm: RealmInfo) => void; onDelete?: (realm: AuthRealm) => void;
} }
export function RealmList({ export function RealmList({
realms, realms,
onRefresh, onRefresh,
isLoading, isLoading,
onCreate,
onEdit, onEdit,
onDelete, onDelete,
onSync,
}: RealmListProps) { }: RealmListProps) {
return ( return (
<Card> <Card>
@ -35,10 +28,11 @@ export function RealmList({
<CardTitle>Authentication Realms</CardTitle> <CardTitle>Authentication Realms</CardTitle>
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}> <Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh Refresh
</Button> </Button>
<Button size="sm"> <Button size="sm" onClick={onCreate}>
<span className="mr-2 h-4 w-4">+</span> <PlusCircle className="mr-2 h-4 w-4" />
New Realm New Realm
</Button> </Button>
</div> </div>
@ -48,30 +42,29 @@ export function RealmList({
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Realm ID</TableHead> <TableHead>Realm Name</TableHead>
<TableHead>Type</TableHead> <TableHead>Type</TableHead>
<TableHead>Server</TableHead> <TableHead>Comment</TableHead>
<TableHead>Base DN</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{realms.map((realm) => ( {realms.length === 0 ? (
<TableRow key={realm.id}> <TableRow>
<TableCell className="font-medium">{realm.id}</TableCell> <TableCell colSpan={4} className="text-center text-muted-foreground py-8">
No auth realms configured
</TableCell>
</TableRow>
) : (
realms.map((realm) => (
<TableRow key={realm.realm}>
<TableCell className="font-medium">{realm.realm}</TableCell>
<TableCell> <TableCell>
<span className="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800"> <span className="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800">
{realm.type.toUpperCase()} {realm.type.toUpperCase()}
</span> </span>
</TableCell> </TableCell>
<TableCell>{realm.server || '-'}</TableCell> <TableCell className="text-muted-foreground text-sm">{realm.comment ?? '-'}</TableCell>
<TableCell>{realm.baseDn || '-'}</TableCell>
<TableCell>
<span className="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium bg-green-100 text-green-800">
Active
</span>
</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex items-center justify-end space-x-2"> <div className="flex items-center justify-end space-x-2">
<button <button
@ -79,14 +72,7 @@ export function RealmList({
onClick={() => onEdit?.(realm)} onClick={() => onEdit?.(realm)}
title="Edit" title="Edit"
> >
<span className="h-4 w-4 text-xs"></span> <Pencil className="h-4 w-4" />
</button>
<button
className="rounded-md p-1 hover:bg-accent"
onClick={() => onSync?.(realm)}
title="Sync Users"
>
<span className="h-4 w-4 text-xs">🔄</span>
</button> </button>
<button <button
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600" className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
@ -95,16 +81,11 @@ export function RealmList({
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
<button
className="rounded-md p-1 hover:bg-accent"
title="More"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

View File

@ -2,29 +2,35 @@ import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
import { Button } from '@/components/ui/index'; import { Button } from '@/components/ui/index';
import { MoreHorizontal, Trash2 } from 'lucide-react'; import { Pencil, Trash2, PlusCircle, RefreshCw, Play, Pause } from 'lucide-react';
import { ProxmoxUser } from '@/lib/proxmoxClient';
interface UserInfo {
id: string;
email?: string;
enabled: boolean;
lastLogin?: string;
}
interface UserListProps { interface UserListProps {
users: UserInfo[]; users: ProxmoxUser[];
onRefresh?: () => void; onRefresh?: () => void;
isLoading?: boolean; isLoading?: boolean;
onEdit?: (user: UserInfo) => void; onCreate?: () => void;
onDelete?: (user: UserInfo) => void; onEdit?: (user: ProxmoxUser) => void;
onEnable?: (user: UserInfo) => void; onDelete?: (user: ProxmoxUser) => void;
onDisable?: (user: UserInfo) => void; onEnable?: (user: ProxmoxUser) => void;
onDisable?: (user: ProxmoxUser) => void;
}
function formatExpiry(expire?: number): string {
if (!expire || expire === 0) return 'Never';
return new Date(expire * 1000).toLocaleDateString();
}
function deriveRealm(userid: string): string {
const parts = userid.split('@');
return parts.length > 1 ? parts[1] : '-';
} }
export function UserList({ export function UserList({
users, users,
onRefresh, onRefresh,
isLoading, isLoading,
onCreate,
onEdit, onEdit,
onDelete, onDelete,
onEnable, onEnable,
@ -37,20 +43,21 @@ export function UserList({
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>Users</CardTitle> <CardTitle>Users</CardTitle>
<div className="flex space-x-2"> <div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 text-sm"> <div className="flex items-center space-x-1 text-sm text-muted-foreground">
<span className="text-green-500"></span> <span className="text-green-500"></span>
<span>{enabledCount} Enabled</span> <span>{enabledCount} Enabled</span>
</div> </div>
<div className="flex items-center space-x-2 text-sm"> <div className="flex items-center space-x-1 text-sm text-muted-foreground">
<span className="text-gray-500"></span> <span className="text-gray-400"></span>
<span>{disabledCount} Disabled</span> <span>{disabledCount} Disabled</span>
</div> </div>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}> <Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh Refresh
</Button> </Button>
<Button size="sm"> <Button size="sm" onClick={onCreate}>
<span className="mr-2 h-4 w-4">+</span> <PlusCircle className="mr-2 h-4 w-4" />
New User New User
</Button> </Button>
</div> </div>
@ -61,25 +68,38 @@ export function UserList({
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>User ID</TableHead> <TableHead>User ID</TableHead>
<TableHead>Realm</TableHead>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead> <TableHead>Email</TableHead>
<TableHead>Status</TableHead> <TableHead>Enabled</TableHead>
<TableHead>Last Login</TableHead> <TableHead>Expire</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{users.map((user) => ( {users.length === 0 ? (
<TableRow key={user.id}> <TableRow>
<TableCell className="font-medium">{user.id}</TableCell> <TableCell colSpan={7} className="text-center text-muted-foreground py-8">
<TableCell>{user.email || '-'}</TableCell> No users found
</TableCell>
</TableRow>
) : (
users.map((user) => {
const fullName = [user.firstname, user.lastname].filter(Boolean).join(' ') || '-';
return (
<TableRow key={user.userid}>
<TableCell className="font-medium font-mono text-xs">{user.userid}</TableCell>
<TableCell>{deriveRealm(user.userid)}</TableCell>
<TableCell>{fullName}</TableCell>
<TableCell>{user.email ?? '-'}</TableCell>
<TableCell> <TableCell>
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${ <span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
user.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800' user.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'
}`}> }`}>
{user.enabled ? 'Enabled' : 'Disabled'} {user.enabled ? 'Enabled' : 'Disabled'}
</span> </span>
</TableCell> </TableCell>
<TableCell>{user.lastLogin || '-'}</TableCell> <TableCell>{formatExpiry(user.expire)}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex items-center justify-end space-x-2"> <div className="flex items-center justify-end space-x-2">
<button <button
@ -87,19 +107,17 @@ export function UserList({
onClick={() => onEdit?.(user)} onClick={() => onEdit?.(user)}
title="Edit" title="Edit"
> >
<span className="h-4 w-4 text-xs"></span> <Pencil className="h-4 w-4" />
</button> </button>
<button <button
className={`rounded-md p-1 hover:bg-accent ${ className="rounded-md p-1 hover:bg-accent"
user.enabled ? 'text-green-600' : 'text-gray-600'
}`}
onClick={() => user.enabled ? onDisable?.(user) : onEnable?.(user)} onClick={() => user.enabled ? onDisable?.(user) : onEnable?.(user)}
title={user.enabled ? 'Disable' : 'Enable'} title={user.enabled ? 'Disable' : 'Enable'}
> >
{user.enabled ? ( {user.enabled ? (
<span className="h-4 w-4 text-xs"></span> <Pause className="h-4 w-4 text-yellow-600" />
) : ( ) : (
<span className="h-4 w-4 text-xs"></span> <Play className="h-4 w-4 text-green-600" />
)} )}
</button> </button>
<button <button
@ -109,16 +127,12 @@ export function UserList({
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
<button
className="rounded-md p-1 hover:bg-accent"
title="More"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} );
})
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

View File

@ -1,34 +1,173 @@
import React from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/index';
import { RefreshCw } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
import { AclList } from '@/components/Proxmox'; import { Button, Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/index';
import { AclList, UserList, RealmList } from '@/components/Proxmox';
import {
listProxmoxClusters,
listAcls,
listUsers,
listRealms,
AclEntry,
ProxmoxUser,
AuthRealm,
} from '@/lib/proxmoxClient';
import { ClusterInfo } from '@/lib/domain';
import { toast } from 'sonner';
export function ProxmoxACLPage() { export function ProxmoxACLPage() {
const acls = [ const [clusters, setClusters] = useState<ClusterInfo[]>([]);
{ id: '1', path: '/nodes/pve1', type: 'user' as const, principal: 'admin@pam', roles: ['PVEAdmin'], propagate: true }, const [selectedClusterId, setSelectedClusterId] = useState<string>('');
{ id: '2', path: '/storage/local', type: 'group' as const, principal: 'admins', roles: ['PVEDataStoreAdmin'], propagate: false }, const [activeTab, setActiveTab] = useState<string>('acl');
{ id: '3', path: '/vms/100', type: 'user' as const, principal: 'developer@pam', roles: ['PVEVMUser'], propagate: false },
]; const [acls, setAcls] = useState<AclEntry[]>([]);
const [users, setUsers] = useState<ProxmoxUser[]>([]);
const [realms, setRealms] = useState<AuthRealm[]>([]);
const [isLoadingAcls, setIsLoadingAcls] = useState(false);
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
const [isLoadingRealms, setIsLoadingRealms] = useState(false);
// Load clusters on mount, auto-select the first
useEffect(() => {
listProxmoxClusters()
.then((cls) => {
setClusters(cls);
if (cls.length > 0) {
setSelectedClusterId(cls[0].id);
}
})
.catch((err) => {
console.error('Failed to load clusters:', err);
toast.error('Failed to load clusters');
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const loadAcls = useCallback(async (clusterId: string) => {
if (!clusterId) return;
setIsLoadingAcls(true);
try {
const data = await listAcls(clusterId);
setAcls(data);
} catch (err) {
console.error('Failed to load ACLs:', err);
toast.error('Failed to load ACLs');
} finally {
setIsLoadingAcls(false);
}
}, []);
const loadUsers = useCallback(async (clusterId: string) => {
if (!clusterId) return;
setIsLoadingUsers(true);
try {
const data = await listUsers(clusterId);
setUsers(data);
} catch (err) {
console.error('Failed to load users:', err);
toast.error('Failed to load users');
} finally {
setIsLoadingUsers(false);
}
}, []);
const loadRealms = useCallback(async (clusterId: string) => {
if (!clusterId) return;
setIsLoadingRealms(true);
try {
const data = await listRealms(clusterId);
setRealms(data);
} catch (err) {
console.error('Failed to load realms:', err);
toast.error('Failed to load auth realms');
} finally {
setIsLoadingRealms(false);
}
}, []);
useEffect(() => {
if (selectedClusterId) {
loadAcls(selectedClusterId);
loadUsers(selectedClusterId);
loadRealms(selectedClusterId);
}
}, [selectedClusterId, loadAcls, loadUsers, loadRealms]);
const handleRefreshAll = () => {
loadAcls(selectedClusterId);
loadUsers(selectedClusterId);
loadRealms(selectedClusterId);
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">Access Control Lists</h1> <h1 className="text-2xl font-bold">Access Control &amp; Users</h1>
<p className="text-muted-foreground">Manage permissions and access control</p> <p className="text-muted-foreground">Manage permissions, users, and authentication realms</p>
</div> </div>
<div className="flex space-x-2"> <div className="flex items-center space-x-2">
<Button variant="outline" size="sm"> {clusters.length > 1 && (
<select
className="rounded-md border px-3 py-1.5 text-sm bg-background"
value={selectedClusterId}
onChange={(e) => setSelectedClusterId(e.target.value)}
>
{clusters.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
)}
<Button variant="outline" size="sm" onClick={handleRefreshAll}>
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
Refresh Refresh
</Button> </Button>
</div> </div>
</div> </div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="acl">ACLs</TabsTrigger>
<TabsTrigger value="users">Users</TabsTrigger>
<TabsTrigger value="realms">Auth Realms</TabsTrigger>
</TabsList>
<TabsContent value="acl">
<AclList <AclList
acls={acls} acls={acls}
onRefresh={() => {}} isLoading={isLoadingAcls}
onRefresh={() => loadAcls(selectedClusterId)}
onAdd={() => toast.info('Add ACL — not yet implemented')}
onEdit={() => toast.info('Edit ACL — not yet implemented')}
onDelete={() => toast.info('Delete ACL — not yet implemented')}
/> />
</TabsContent>
<TabsContent value="users">
<UserList
users={users}
isLoading={isLoadingUsers}
onRefresh={() => loadUsers(selectedClusterId)}
onCreate={() => toast.info('Create user — not yet implemented')}
onEdit={() => toast.info('Edit user — not yet implemented')}
onDelete={() => toast.info('Delete user — not yet implemented')}
onEnable={() => toast.info('Enable user — not yet implemented')}
onDisable={() => toast.info('Disable user — not yet implemented')}
/>
</TabsContent>
<TabsContent value="realms">
<RealmList
realms={realms}
isLoading={isLoadingRealms}
onRefresh={() => loadRealms(selectedClusterId)}
onCreate={() => toast.info('Create realm — not yet implemented')}
onEdit={() => toast.info('Edit realm — not yet implemented')}
onDelete={() => toast.info('Delete realm — not yet implemented')}
/>
</TabsContent>
</Tabs>
</div> </div>
); );
} }

View File

@ -1,10 +1,119 @@
import React from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Button } from '@/components/ui/index';
import { RefreshCw } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/index';
import { HAGroupsList, HAResourcesList } from '@/components/Proxmox'; import { HAGroupsList, HAResourcesList } from '@/components/Proxmox';
import {
listProxmoxClusters,
listHaGroups,
listHaResources,
deleteHaGroup,
enableHaResource,
HaGroup,
HaResource,
} from '@/lib/proxmoxClient';
import { ClusterInfo } from '@/lib/domain';
import { toast } from 'sonner';
export function ProxmoxHAPage() { export function ProxmoxHAPage() {
const [clusters, setClusters] = useState<ClusterInfo[]>([]);
const [selectedClusterId, setSelectedClusterId] = useState<string>('');
const [groups, setGroups] = useState<HaGroup[]>([]);
const [resources, setResources] = useState<HaResource[]>([]);
const [isLoadingGroups, setIsLoadingGroups] = useState(false);
const [isLoadingResources, setIsLoadingResources] = useState(false);
// Load clusters on mount and auto-select the first one
useEffect(() => {
listProxmoxClusters()
.then((cls) => {
setClusters(cls);
if (cls.length > 0 && !selectedClusterId) {
setSelectedClusterId(cls[0].id);
}
})
.catch((err) => {
console.error('Failed to load clusters:', err);
toast.error('Failed to load clusters');
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const loadGroups = useCallback(async (clusterId: string) => {
if (!clusterId) return;
setIsLoadingGroups(true);
try {
const data = await listHaGroups(clusterId);
setGroups(data);
} catch (err) {
console.error('Failed to load HA groups:', err);
toast.error('Failed to load HA groups');
} finally {
setIsLoadingGroups(false);
}
}, []);
const loadResources = useCallback(async (clusterId: string) => {
if (!clusterId) return;
setIsLoadingResources(true);
try {
const data = await listHaResources(clusterId);
setResources(data);
} catch (err) {
console.error('Failed to load HA resources:', err);
toast.error('Failed to load HA resources');
} finally {
setIsLoadingResources(false);
}
}, []);
useEffect(() => {
if (selectedClusterId) {
loadGroups(selectedClusterId);
loadResources(selectedClusterId);
}
}, [selectedClusterId, loadGroups, loadResources]);
const handleRefreshAll = () => {
loadGroups(selectedClusterId);
loadResources(selectedClusterId);
};
const handleDeleteGroup = async (id: string) => {
try {
await deleteHaGroup(selectedClusterId, id);
toast.success(`HA group "${id}" deleted`);
await loadGroups(selectedClusterId);
} catch (err) {
console.error('Failed to delete HA group:', err);
toast.error('Failed to delete HA group');
}
};
const handleEditGroup = (group: HaGroup) => {
// Placeholder: edit dialog integration to be wired when dialog component is available
toast.info(`Edit group: ${group.id}`);
};
const handleCreateGroup = () => {
// Placeholder: create dialog integration to be wired when dialog component is available
toast.info('Create HA group — not yet implemented');
};
const handleEnableResource = async (resource: HaResource) => {
try {
await enableHaResource(selectedClusterId, resource.sid);
toast.success(`HA resource "${resource.sid}" enabled`);
await loadResources(selectedClusterId);
} catch (err) {
console.error('Failed to enable HA resource:', err);
toast.error('Failed to enable HA resource');
}
};
const handleRemoveResource = async (resource: HaResource) => {
// Placeholder: removal command to be wired when backend command is available
toast.info(`Remove resource: ${resource.sid}`);
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -12,38 +121,44 @@ export function ProxmoxHAPage() {
<h1 className="text-2xl font-bold">High Availability</h1> <h1 className="text-2xl font-bold">High Availability</h1>
<p className="text-muted-foreground">Manage HA groups and resources</p> <p className="text-muted-foreground">Manage HA groups and resources</p>
</div> </div>
<div className="flex space-x-2"> <div className="flex items-center space-x-2">
<Button variant="outline" size="sm"> {clusters.length > 1 && (
<select
className="rounded-md border px-3 py-1.5 text-sm bg-background"
value={selectedClusterId}
onChange={(e) => setSelectedClusterId(e.target.value)}
>
{clusters.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
)}
<Button variant="outline" size="sm" onClick={handleRefreshAll}>
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
Refresh Refresh
</Button> </Button>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4">
<Card>
<CardHeader>
<CardTitle>HA Groups</CardTitle>
</CardHeader>
<CardContent>
<HAGroupsList <HAGroupsList
groups={[]} groups={groups}
onRefresh={() => {}} isLoading={isLoadingGroups}
onRefresh={() => loadGroups(selectedClusterId)}
onCreate={handleCreateGroup}
onEdit={handleEditGroup}
onDelete={handleDeleteGroup}
/> />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>HA Resources</CardTitle>
</CardHeader>
<CardContent>
<HAResourcesList <HAResourcesList
resources={[]} resources={resources}
onRefresh={() => {}} isLoading={isLoadingResources}
onRefresh={() => loadResources(selectedClusterId)}
onEnable={handleEnableResource}
onRemove={handleRemoveResource}
/> />
</CardContent>
</Card>
</div> </div>
</div> </div>
); );