Files
zmb-webui/frontend/app/identities/page.tsx
T
Claude Code 6d74d874b6 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>
2026-04-22 00:43:05 +02:00

878 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
)
}