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:
Claude Code
2026-04-22 00:26:23 +02:00
committed by Patrick
commit 6d74d874b6
104 changed files with 28836 additions and 0 deletions
+877
View File
@@ -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>
)
}