92bed208e0
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>
878 lines
37 KiB
TypeScript
878 lines
37 KiB
TypeScript
"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>
|
||
)
|
||
}
|