ZMB Webui: Complete Project – Rebrand & Initial Clean Commit
ARCHITECTURE ============ Backend: FastAPI + uvicorn (port 8000) - JWT authentication with PAM system users - ZFS CLI wrapper with caching (30-60s TTL) - WebSocket pool status broadcaster (30s interval) - Services: auth, zfs_runner, file_manager, shares, identities, system_info - Routers: pools, datasets, snapshots, shares, identities, navigator, system Frontend: Next.js 15 + TypeScript (static export) - Incremental Static Regeneration (ISR) for weak hardware - Type-safe API client (lib/api.ts) - Dark mode + custom Tailwind theme - Pages: Dashboard, Login, Snapshots, Datasets, Shares, etc. DEPLOYMENT ========== Test Target: 192.168.1.179:8090 (Debian LXC) Production: 10.66.120.3:9090 (Raspberry Pi 4GB ARM64) Updater: Automated Gitea-based deployment (update-test.sh, update-pi.sh) FEATURES COMPLETED ================== Phase 3a: Dashboard Quick Stats (System, CPU, Memory, Storage) - Real-time stats with color-coded progress bars - Responsive grid layout (mobile: 1, tablet: 2, desktop: 4 columns) - ISR-optimized for fast loads on weak hardware REBRANDING ========== Renamed throughout: - Project: 'ZFS Manager' → 'ZMB Webui' - Services: 'zfs-manager' → 'zmb-webui' - Systemd units: zfs-manager-backend → zmb-webui-backend - Configuration files and documentation Co-Authored-By: Patrick <patrick@perlbach24.de>
This commit is contained in:
@@ -0,0 +1,877 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { api, SystemUser, SystemGroup, LoginEntry } from "@/lib/api"
|
||||
import { Header } from "@/components/Header"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Dialog } from "@/components/ui/dialog"
|
||||
import { AlertCircle, Plus, Trash2, Lock, Unlock, Key, Terminal, Search as SearchIcon } from "lucide-react"
|
||||
|
||||
export default function IdentitiesPage() {
|
||||
const router = useRouter()
|
||||
const [activeTab, setActiveTab] = useState<"users" | "groups" | "history">("users")
|
||||
const [usersSubTab, setUsersSubTab] = useState<"linux" | "samba">("linux")
|
||||
const [users, setUsers] = useState<SystemUser[]>([])
|
||||
const [sambaUsers, setSambaUsers] = useState<SystemUser[]>([])
|
||||
const [groups, setGroups] = useState<SystemGroup[]>([])
|
||||
const [logins, setLogins] = useState<LoginEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedGroupForAdd, setSelectedGroupForAdd] = useState<string | null>(null)
|
||||
const [selectedUserForGroup, setSelectedUserForGroup] = useState<string | null>(null)
|
||||
|
||||
// Dialog states
|
||||
const [createUserDialog, setCreateUserDialog] = useState(false)
|
||||
const [createGroupDialog, setCreateGroupDialog] = useState(false)
|
||||
const [passwordDialog, setPasswordDialog] = useState(false)
|
||||
const [shellDialog, setShellDialog] = useState(false)
|
||||
const [sambaPasswordDialog, setSambaPasswordDialog] = useState(false)
|
||||
const [deleteUserDialog, setDeleteUserDialog] = useState(false)
|
||||
const [deleteGroupDialog, setDeleteGroupDialog] = useState(false)
|
||||
const [addUserToGroupDialog, setAddUserToGroupDialog] = useState(false)
|
||||
|
||||
// Form states
|
||||
const [selectedUser, setSelectedUser] = useState<string | null>(null)
|
||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
|
||||
const [newUsername, setNewUsername] = useState("")
|
||||
const [newHomeDir, setNewHomeDir] = useState("")
|
||||
const [newShell, setNewShell] = useState("/bin/bash")
|
||||
const [newGecos, setNewGecos] = useState("")
|
||||
const [newGroupName, setNewGroupName] = useState("")
|
||||
const [newPassword, setNewPassword] = useState("")
|
||||
const [newShellValue, setNewShellValue] = useState("")
|
||||
const [sambaPassword, setSambaPassword] = useState("")
|
||||
const [removeHomeDir, setRemoveHomeDir] = useState(true)
|
||||
const [loginLimit, setLoginLimit] = useState(50)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("access_token")
|
||||
if (!token) {
|
||||
router.push("/login")
|
||||
return
|
||||
}
|
||||
loadData()
|
||||
}, [router])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const [usersData, sambaUsersData, groupsData, loginsData] = await Promise.all([
|
||||
api.getUsers(),
|
||||
api.getSambaUsers(),
|
||||
api.getGroups(),
|
||||
api.getLoginHistory(loginLimit),
|
||||
])
|
||||
setUsers(usersData)
|
||||
setSambaUsers(sambaUsersData)
|
||||
setGroups(groupsData)
|
||||
setLogins(loginsData)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load data")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateUser = async () => {
|
||||
if (!newUsername.trim()) return
|
||||
try {
|
||||
await api.createUser(newUsername, newHomeDir || undefined, newShell, newGecos || undefined)
|
||||
setNewUsername("")
|
||||
setNewHomeDir("")
|
||||
setNewShell("/bin/bash")
|
||||
setNewGecos("")
|
||||
setCreateUserDialog(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create user")
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!selectedUser) return
|
||||
try {
|
||||
await api.deleteUser(selectedUser, removeHomeDir)
|
||||
setSelectedUser(null)
|
||||
setDeleteUserDialog(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete user")
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (!selectedUser || !newPassword.trim()) return
|
||||
try {
|
||||
await api.changePassword(selectedUser, newPassword)
|
||||
setSelectedUser(null)
|
||||
setNewPassword("")
|
||||
setPasswordDialog(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to change password")
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangeShell = async () => {
|
||||
if (!selectedUser || !newShellValue.trim()) return
|
||||
try {
|
||||
await api.changeShell(selectedUser, newShellValue)
|
||||
setSelectedUser(null)
|
||||
setNewShellValue("")
|
||||
setShellDialog(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to change shell")
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetSambaPassword = async () => {
|
||||
if (!selectedUser || !sambaPassword.trim()) return
|
||||
try {
|
||||
await api.setSambaPassword(selectedUser, sambaPassword)
|
||||
setSelectedUser(null)
|
||||
setSambaPassword("")
|
||||
setSambaPasswordDialog(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to set Samba password")
|
||||
}
|
||||
}
|
||||
|
||||
const handleLockUser = async (username: string) => {
|
||||
try {
|
||||
await api.lockUser(username)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to lock user")
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnlockUser = async (username: string) => {
|
||||
try {
|
||||
await api.unlockUser(username)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to unlock user")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateGroup = async () => {
|
||||
if (!newGroupName.trim()) return
|
||||
try {
|
||||
await api.createGroup(newGroupName)
|
||||
setNewGroupName("")
|
||||
setCreateGroupDialog(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create group")
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddUserToGroup = async () => {
|
||||
if (!selectedUserForGroup || !selectedGroupForAdd) return
|
||||
try {
|
||||
await api.addUserToGroup(selectedUserForGroup, selectedGroupForAdd)
|
||||
setSelectedUserForGroup(null)
|
||||
setSelectedGroupForAdd(null)
|
||||
setAddUserToGroupDialog(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to add user to group")
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveUserFromGroup = async (username: string, groupname: string) => {
|
||||
try {
|
||||
await api.removeUserFromGroup(username, groupname)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to remove user from group")
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteGroup = async () => {
|
||||
if (!selectedGroup) return
|
||||
try {
|
||||
await api.deleteGroup(selectedGroup)
|
||||
setSelectedGroup(null)
|
||||
setDeleteGroupDialog(false)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete group")
|
||||
}
|
||||
}
|
||||
|
||||
// Filter users and groups based on search
|
||||
const filteredUsers = users.filter(u =>
|
||||
u.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
u.gecos?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
const filteredSambaUsers = sambaUsers.filter(u =>
|
||||
u.username.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
const filteredGroups = groups.filter(g =>
|
||||
g.groupname.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Identities</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage users, groups, and view login history</p>
|
||||
</div>
|
||||
<Button onClick={loadData} variant="outline" size="sm">
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<Card className="mb-6 border-red-200 bg-red-50">
|
||||
<CardContent className="flex items-center gap-3 pt-6">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex gap-2 mb-6 border-b border-border">
|
||||
<button
|
||||
onClick={() => setActiveTab("users")}
|
||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
|
||||
activeTab === "users"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Users ({users.length + sambaUsers.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("groups")}
|
||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
|
||||
activeTab === "groups"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Groups ({groups.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("history")}
|
||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
|
||||
activeTab === "history"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Login History
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Bar (for Users and Groups tabs) */}
|
||||
{(activeTab === "users" || activeTab === "groups") && (
|
||||
<div className="mb-6">
|
||||
<div className="flex gap-2">
|
||||
<SearchIcon className="w-5 h-5 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={activeTab === "users" ? "Search users..." : "Search groups..."}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-border rounded bg-background text-foreground text-sm"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
onClick={() => setSearchQuery("")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* USERS TAB */}
|
||||
{activeTab === "users" && (
|
||||
<div>
|
||||
{/* Sub-tabs for Linux vs Samba users */}
|
||||
<div className="flex gap-2 mb-4 border-b border-border">
|
||||
<button
|
||||
onClick={() => setUsersSubTab("linux")}
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
usersSubTab === "linux"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Linux Users ({users.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUsersSubTab("samba")}
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
usersSubTab === "samba"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Samba Users ({sambaUsers.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* LINUX USERS */}
|
||||
{usersSubTab === "linux" && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Button onClick={() => setCreateUserDialog(true)} size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">Loading users...</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">System Users</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border bg-muted/30">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium">Username</th>
|
||||
<th className="text-left py-3 px-4 font-medium">UID</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Home</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Shell</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Groups</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Status</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map((user) => (
|
||||
<tr key={user.username} className="border-b border-border/50 hover:bg-muted/30">
|
||||
<td className="py-3 px-4 font-mono text-xs">{user.username}</td>
|
||||
<td className="py-3 px-4 text-xs text-muted-foreground">{user.uid}</td>
|
||||
<td className="py-3 px-4 text-xs text-muted-foreground">{user.home}</td>
|
||||
<td className="py-3 px-4 text-xs font-mono">{user.shell}</td>
|
||||
<td className="py-3 px-4 text-xs">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{user.groups.map((g) => (
|
||||
<Badge key={g} variant="secondary" className="text-xs">
|
||||
{g}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-xs">
|
||||
{user.locked ? (
|
||||
<Badge variant="destructive">Locked</Badge>
|
||||
) : (
|
||||
<Badge variant="success">Active</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-xs space-x-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUser(user.username)
|
||||
setPasswordDialog(true)
|
||||
}}
|
||||
className="px-2 py-1 rounded border border-border hover:bg-muted"
|
||||
title="Change password"
|
||||
>
|
||||
<Key className="w-3 h-3 inline" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUser(user.username)
|
||||
setNewShellValue(user.shell)
|
||||
setShellDialog(true)
|
||||
}}
|
||||
className="px-2 py-1 rounded border border-border hover:bg-muted"
|
||||
title="Change shell"
|
||||
>
|
||||
<Terminal className="w-3 h-3 inline" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUser(user.username)
|
||||
setSambaPassword("")
|
||||
setSambaPasswordDialog(true)
|
||||
}}
|
||||
className="px-2 py-1 rounded border border-border hover:bg-muted"
|
||||
title="Set Samba password"
|
||||
>
|
||||
<Key className="w-3 h-3 inline" style={{ opacity: 0.6 }} />
|
||||
</button>
|
||||
{user.locked ? (
|
||||
<button
|
||||
onClick={() => handleUnlockUser(user.username)}
|
||||
className="px-2 py-1 rounded border border-border hover:bg-muted text-green-600"
|
||||
title="Unlock"
|
||||
>
|
||||
<Unlock className="w-3 h-3 inline" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleLockUser(user.username)}
|
||||
className="px-2 py-1 rounded border border-border hover:bg-muted text-amber-600"
|
||||
title="Lock"
|
||||
>
|
||||
<Lock className="w-3 h-3 inline" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedUser(user.username)
|
||||
setDeleteUserDialog(true)
|
||||
}}
|
||||
className="px-2 py-1 rounded border border-destructive/50 hover:bg-destructive/10 text-destructive"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 inline" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SAMBA USERS */}
|
||||
{usersSubTab === "samba" && (
|
||||
<div>
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">Loading Samba users...</div>
|
||||
) : sambaUsers.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
No Samba users found. Install and configure Samba to see users here.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Samba Users</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border bg-muted/30">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium">Username</th>
|
||||
<th className="text-left py-3 px-4 font-medium">UID</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredSambaUsers.map((user) => (
|
||||
<tr key={user.username} className="border-b border-border/50 hover:bg-muted/30">
|
||||
<td className="py-3 px-4 font-mono text-xs">
|
||||
<Badge variant="outline">{user.username}</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-xs text-muted-foreground">{user.uid}</td>
|
||||
<td className="py-3 px-4 text-xs text-muted-foreground">{(user as any).comment || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GROUPS TAB */}
|
||||
{activeTab === "groups" && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Button onClick={() => setCreateGroupDialog(true)} size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">Loading groups...</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">System Groups</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border bg-muted/30">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium">Group Name</th>
|
||||
<th className="text-left py-3 px-4 font-medium">GID</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Members</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredGroups.map((group) => (
|
||||
<tr key={group.groupname} className="border-b border-border/50 hover:bg-muted/30">
|
||||
<td className="py-3 px-4 font-mono text-xs">{group.groupname}</td>
|
||||
<td className="py-3 px-4 text-xs text-muted-foreground">{group.gid}</td>
|
||||
<td className="py-3 px-4 text-xs">
|
||||
<div className="flex gap-1 flex-wrap items-center">
|
||||
{group.members && group.members.length > 0 ? (
|
||||
group.members.map((m) => (
|
||||
<div key={m} className="flex items-center gap-1 bg-secondary rounded px-2 py-1">
|
||||
<span>{m}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveUserFromGroup(m, group.groupname)}
|
||||
className="text-xs hover:text-destructive"
|
||||
title="Remove from group"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">(empty)</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedGroupForAdd(group.groupname)
|
||||
setAddUserToGroupDialog(true)
|
||||
}}
|
||||
className="px-2 py-1 rounded border border-primary/50 hover:bg-primary/10 text-primary text-xs"
|
||||
title="Add user to group"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-xs">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedGroup(group.groupname)
|
||||
setDeleteGroupDialog(true)
|
||||
}}
|
||||
className="px-2 py-1 rounded border border-destructive/50 hover:bg-destructive/10 text-destructive"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 inline" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LOGIN HISTORY TAB */}
|
||||
{activeTab === "history" && (
|
||||
<div>
|
||||
<div className="mb-4 flex gap-2 items-center">
|
||||
<label className="text-sm text-muted-foreground">Limit:</label>
|
||||
<select
|
||||
value={loginLimit}
|
||||
onChange={(e) => {
|
||||
setLoginLimit(parseInt(e.target.value))
|
||||
loadData()
|
||||
}}
|
||||
className="px-3 py-1 text-sm border border-border rounded bg-background"
|
||||
>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">Loading login history...</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Recent Logins</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{logins.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">No login history found</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border bg-muted/30">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium">User</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Terminal</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Host/IP</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Login Time</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Logout Time</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logins.map((login, idx) => (
|
||||
<tr key={idx} className="border-b border-border/50 hover:bg-muted/30">
|
||||
<td className="py-3 px-4 font-mono text-xs">{(login as any).username || (login as any).user}</td>
|
||||
<td className="py-3 px-4 text-xs text-muted-foreground">{(login as any).tty || (login as any).terminal || "—"}</td>
|
||||
<td className="py-3 px-4 text-xs text-muted-foreground">{(login as any).host || "—"}</td>
|
||||
<td className="py-3 px-4 text-xs">{(login as any).login_str || (login as any).login_time || "—"}</td>
|
||||
<td className="py-3 px-4 text-xs text-muted-foreground">
|
||||
{(login as any).logout_time || "—"}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-xs text-muted-foreground">{(login as any).duration || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Create User Dialog */}
|
||||
<Dialog open={createUserDialog} onClose={() => setCreateUserDialog(false)} title="Create User">
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded bg-background text-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Home directory (optional)"
|
||||
value={newHomeDir}
|
||||
onChange={(e) => setNewHomeDir(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded bg-background text-foreground"
|
||||
/>
|
||||
<select
|
||||
value={newShell}
|
||||
onChange={(e) => setNewShell(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded bg-background text-foreground"
|
||||
>
|
||||
<option>/bin/bash</option>
|
||||
<option>/bin/sh</option>
|
||||
<option>/sbin/nologin</option>
|
||||
<option>/usr/sbin/nologin</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Full name (optional)"
|
||||
value={newGecos}
|
||||
onChange={(e) => setNewGecos(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded bg-background text-foreground"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreateUser} className="flex-1">
|
||||
Create
|
||||
</Button>
|
||||
<Button onClick={() => setCreateUserDialog(false)} variant="outline" className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Create Group Dialog */}
|
||||
<Dialog open={createGroupDialog} onClose={() => setCreateGroupDialog(false)} title="Create Group">
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Group name"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded bg-background text-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreateGroup} className="flex-1">
|
||||
Create
|
||||
</Button>
|
||||
<Button onClick={() => setCreateGroupDialog(false)} variant="outline" className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Change Password Dialog */}
|
||||
<Dialog open={passwordDialog} onClose={() => setPasswordDialog(false)} title={`Change Password for ${selectedUser}`}>
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded bg-background text-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleChangePassword} className="flex-1">
|
||||
Change
|
||||
</Button>
|
||||
<Button onClick={() => setPasswordDialog(false)} variant="outline" className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Change Shell Dialog */}
|
||||
<Dialog open={shellDialog} onClose={() => setShellDialog(false)} title={`Change Shell for ${selectedUser}`}>
|
||||
<div className="space-y-4">
|
||||
<select
|
||||
value={newShellValue}
|
||||
onChange={(e) => setNewShellValue(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded bg-background text-foreground"
|
||||
>
|
||||
<option>/bin/bash</option>
|
||||
<option>/bin/sh</option>
|
||||
<option>/sbin/nologin</option>
|
||||
<option>/usr/sbin/nologin</option>
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleChangeShell} className="flex-1">
|
||||
Change
|
||||
</Button>
|
||||
<Button onClick={() => setShellDialog(false)} variant="outline" className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Set Samba Password Dialog */}
|
||||
<Dialog open={sambaPasswordDialog} onClose={() => setSambaPasswordDialog(false)} title={`Set Samba Password for ${selectedUser}`}>
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="New Samba password"
|
||||
value={sambaPassword}
|
||||
onChange={(e) => setSambaPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded bg-background text-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSetSambaPassword} className="flex-1">
|
||||
Set Password
|
||||
</Button>
|
||||
<Button onClick={() => setSambaPasswordDialog(false)} variant="outline" className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete User Dialog */}
|
||||
<Dialog open={deleteUserDialog} onClose={() => setDeleteUserDialog(false)} title="Delete User">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm">Are you sure you want to delete user <span className="font-mono">{selectedUser}</span>?</p>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removeHomeDir}
|
||||
onChange={(e) => setRemoveHomeDir(e.target.checked)}
|
||||
className="rounded border border-border"
|
||||
/>
|
||||
<span className="text-sm">Remove home directory</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleDeleteUser} variant="destructive" className="flex-1">
|
||||
Delete
|
||||
</Button>
|
||||
<Button onClick={() => setDeleteUserDialog(false)} variant="outline" className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Group Dialog */}
|
||||
<Dialog open={deleteGroupDialog} onClose={() => setDeleteGroupDialog(false)} title="Delete Group">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm">Are you sure you want to delete group <span className="font-mono">{selectedGroup}</span>?</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleDeleteGroup} variant="destructive" className="flex-1">
|
||||
Delete
|
||||
</Button>
|
||||
<Button onClick={() => setDeleteGroupDialog(false)} variant="outline" className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Add User to Group Dialog */}
|
||||
<Dialog open={addUserToGroupDialog} onClose={() => setAddUserToGroupDialog(false)} title="Add User to Group">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-2">Select User</label>
|
||||
<select
|
||||
value={selectedUserForGroup || ""}
|
||||
onChange={(e) => setSelectedUserForGroup(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-border rounded bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="">Choose a user...</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.username} value={u.username}>
|
||||
{u.username} (uid {u.uid})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleAddUserToGroup}
|
||||
disabled={!selectedUserForGroup || !selectedGroupForAdd}
|
||||
className="flex-1"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setAddUserToGroupDialog(false)
|
||||
setSelectedUserForGroup(null)
|
||||
setSelectedGroupForAdd(null)
|
||||
}}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user