feat: update imports and improve admin settings user management

This commit is contained in:
LukeGus
2025-12-22 23:39:32 -06:00
parent de45bcc1b9
commit ae6b165e37
6 changed files with 934 additions and 362 deletions

View File

@@ -2079,13 +2079,38 @@ router.post(
enableTerminal: hostData.enableTerminal !== false,
enableTunnel: hostData.enableTunnel !== false,
enableFileManager: hostData.enableFileManager !== false,
enableDocker: hostData.enableDocker || false,
defaultPath: hostData.defaultPath || "/",
tunnelConnections: hostData.tunnelConnections
? JSON.stringify(hostData.tunnelConnections)
: "[]",
jumpHosts: hostData.jumpHosts
? JSON.stringify(hostData.jumpHosts)
: null,
quickActions: hostData.quickActions
? JSON.stringify(hostData.quickActions)
: null,
statsConfig: hostData.statsConfig
? JSON.stringify(hostData.statsConfig)
: null,
terminalConfig: hostData.terminalConfig
? JSON.stringify(hostData.terminalConfig)
: null,
forceKeyboardInteractive: hostData.forceKeyboardInteractive
? "true"
: "false",
notes: hostData.notes || null,
useSocks5: hostData.useSocks5 ? 1 : 0,
socks5Host: hostData.socks5Host || null,
socks5Port: hostData.socks5Port || null,
socks5Username: hostData.socks5Username || null,
socks5Password: hostData.socks5Password || null,
socks5ProxyChain: hostData.socks5ProxyChain
? JSON.stringify(hostData.socks5ProxyChain)
: null,
overrideCredentialUsername: hostData.overrideCredentialUsername
? 1
: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};

View File

@@ -38,52 +38,15 @@ export function useConfirmation() {
const confirmWithToast = (
opts: ConfirmationOptions | string,
callback?: () => void,
variant?: "default" | "destructive",
): Promise<boolean> => {
// Legacy signature support
if (typeof opts === "string" && callback) {
const actionText = variant === "destructive" ? "Delete" : "Confirm";
const cancelText = "Cancel";
toast(opts, {
action: {
label: actionText,
onClick: callback,
},
cancel: {
label: cancelText,
onClick: () => {},
},
duration: 10000,
className: variant === "destructive" ? "border-red-500" : "",
actionButtonStyle: { marginLeft: "0.1rem" },
cancelButtonStyle: { marginRight: "0.1rem" },
});
callback();
return Promise.resolve(true);
}
// New Promise-based signature
return new Promise<boolean>((resolve) => {
const options = opts as ConfirmationOptions;
const actionText = options.confirmText || "Confirm";
const cancelText = options.cancelText || "Cancel";
const variantClass =
options.variant === "destructive" ? "border-red-500" : "";
toast(options.title, {
description: options.description,
action: {
label: actionText,
onClick: () => resolve(true),
},
cancel: {
label: cancelText,
onClick: () => resolve(false),
},
duration: 10000,
className: variantClass,
});
});
return Promise.resolve(true);
};
return {

View File

@@ -302,6 +302,7 @@
"optional": "Optional",
"connect": "Connect",
"connecting": "Connecting...",
"creating": "Creating...",
"clear": "Clear",
"toggleSidebar": "Toggle Sidebar",
"sidebar": "Sidebar",
@@ -487,6 +488,46 @@
"linkToPasswordAccount": "Link to Password Account",
"linkOIDCDialogTitle": "Link OIDC Account to Password Account",
"linkOIDCDialogDescription": "Link {{username}} (OIDC user) to an existing password account. This will enable dual authentication for the password account.",
"createUser": "Create User",
"createUserDescription": "Create a new local user with username and password",
"enterUsername": "Enter username",
"enterPassword": "Enter password",
"userCreatedSuccessfully": "User {{username}} created successfully",
"failedToCreateUser": "Failed to create user",
"manageUser": "Manage User",
"manageUserDescription": "Manage user settings, roles, and permissions",
"authType": "Authentication Type",
"adminStatus": "Admin Status",
"userId": "User ID",
"regularUser": "Regular User",
"adminPrivileges": "Administrator Privileges",
"administratorRole": "Administrator Role",
"administratorRoleDescription": "Grant full system access and management privileges",
"passwordManagement": "Password Management",
"passwordResetWarning": "Resetting a user's password will delete all their data (SSH hosts, credentials, settings). This action cannot be undone.",
"resetUserPassword": "Reset User Password",
"resettingPassword": "Resetting...",
"passwordResetInitiated": "Password reset initiated for {{username}}. Reset code sent.",
"failedToResetPassword": "Failed to initiate password reset",
"sessionManagement": "Session Management",
"revokeAllSessions": "Revoke All Sessions",
"revokeAllSessionsDescription": "Force logout from all devices and sessions",
"revoking": "Revoking...",
"revoke": "Revoke All",
"dangerZone": "Danger Zone",
"deleteUserTitle": "Delete User Account",
"deleteUserWarning": "Permanently delete this user account and all associated data. This action cannot be undone.",
"deleting": "Deleting...",
"cannotDeleteSelf": "You cannot delete your own account",
"cannotRemoveLastAdmin": "Cannot remove the last administrator",
"cannotRemoveOwnAdmin": "You cannot remove your own admin privileges",
"cannotModifyOwnAdminStatus": "You cannot modify your own admin status",
"dualAuth": "Dual Auth",
"externalOIDC": "External (OIDC)",
"localPassword": "Local Password",
"confirmRevokeOwnSessions": "Are you sure you want to revoke all your own sessions? You will be logged out.",
"confirmMakeAdmin": "Are you sure you want to make {{username}} an admin?",
"confirmRemoveAdmin": "Are you sure you want to remove admin status from {{username}}?",
"linkOIDCWarningTitle": "Warning: OIDC User Data Will Be Deleted",
"linkOIDCActionDeleteUser": "Delete the OIDC user account and all their data",
"linkOIDCActionAddCapability": "Add OIDC login capability to the target password account",

View File

@@ -43,6 +43,8 @@ import {
Globe,
Clock,
UserCog,
UserPlus,
Edit,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
@@ -75,6 +77,8 @@ import {
type Role,
} from "@/ui/main-axios.ts";
import { RoleManagement } from "./RoleManagement.tsx";
import { CreateUserDialog } from "./CreateUserDialog.tsx";
import { UserEditDialog } from "./UserEditDialog.tsx";
interface AdminSettingsProps {
isTopbarOpen?: boolean;
@@ -121,21 +125,17 @@ export function AdminSettings({
}>
>([]);
const [usersLoading, setUsersLoading] = React.useState(false);
const [newAdminUsername, setNewAdminUsername] = React.useState("");
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(
null,
);
// Role management states
const [rolesDialogOpen, setRolesDialogOpen] = React.useState(false);
const [selectedUser, setSelectedUser] = React.useState<{
// New dialog states
const [createUserDialogOpen, setCreateUserDialogOpen] = React.useState(false);
const [userEditDialogOpen, setUserEditDialogOpen] = React.useState(false);
const [selectedUserForEdit, setSelectedUserForEdit] = React.useState<{
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean;
password_hash?: string;
} | null>(null);
const [userRoles, setUserRoles] = React.useState<UserRole[]>([]);
const [availableRoles, setAvailableRoles] = React.useState<Role[]>([]);
const [rolesLoading, setRolesLoading] = React.useState(false);
const [securityInitialized, setSecurityInitialized] = React.useState(true);
const [currentUser, setCurrentUser] = React.useState<{
@@ -285,62 +285,30 @@ export function AdminSettings({
}
};
// Role management functions
const handleOpenRolesDialog = async (user: {
id: string;
username: string;
}) => {
setSelectedUser(user);
setRolesDialogOpen(true);
setRolesLoading(true);
try {
// Load user's current roles
const rolesResponse = await getUserRoles(user.id);
setUserRoles(rolesResponse.roles || []);
// Load all available roles
const allRolesResponse = await getRoles();
setAvailableRoles(allRolesResponse.roles || []);
} catch (error) {
console.error("Failed to load roles:", error);
toast.error(t("rbac.failedToLoadRoles"));
} finally {
setRolesLoading(false);
}
// New dialog handlers
const handleEditUser = (user: (typeof users)[0]) => {
setSelectedUserForEdit(user);
setUserEditDialogOpen(true);
};
const handleAssignRole = async (roleId: number) => {
if (!selectedUser) return;
try {
await assignRoleToUser(selectedUser.id, roleId);
toast.success(
t("rbac.roleAssignedSuccessfully", { username: selectedUser.username }),
);
// Reload user roles
const rolesResponse = await getUserRoles(selectedUser.id);
setUserRoles(rolesResponse.roles || []);
} catch (error) {
toast.error(t("rbac.failedToAssignRole"));
}
const handleCreateUserSuccess = () => {
fetchUsers();
setCreateUserDialogOpen(false);
};
const handleRemoveRole = async (roleId: number) => {
if (!selectedUser) return;
const handleEditUserSuccess = () => {
fetchUsers();
setUserEditDialogOpen(false);
setSelectedUserForEdit(null);
};
try {
await removeRoleFromUser(selectedUser.id, roleId);
toast.success(
t("rbac.roleRemovedSuccessfully", { username: selectedUser.username }),
);
// Reload user roles
const rolesResponse = await getUserRoles(selectedUser.id);
setUserRoles(rolesResponse.roles || []);
} catch (error) {
toast.error(t("rbac.failedToRemoveRole"));
const getAuthTypeDisplay = (user: (typeof users)[0]): string => {
if (user.is_oidc && user.password_hash) {
return t("admin.dualAuth");
} else if (user.is_oidc) {
return t("admin.externalOIDC");
} else {
return t("admin.localPassword");
}
};
@@ -445,39 +413,7 @@ export function AdminSettings({
setOidcConfig((prev) => ({ ...prev, [field]: value }));
};
const handleMakeUserAdmin = async (e: React.FormEvent) => {
e.preventDefault();
if (!newAdminUsername.trim()) return;
setMakeAdminLoading(true);
setMakeAdminError(null);
try {
await makeUserAdmin(newAdminUsername.trim());
toast.success(t("admin.userIsNowAdmin", { username: newAdminUsername }));
setNewAdminUsername("");
fetchUsers();
} catch (err: unknown) {
setMakeAdminError(
(err as { response?: { data?: { error?: string } } })?.response?.data
?.error || t("admin.failedToMakeUserAdmin"),
);
} finally {
setMakeAdminLoading(false);
}
};
const handleRemoveAdminStatus = async (username: string) => {
confirmWithToast(t("admin.removeAdminStatus", { username }), async () => {
try {
await removeAdminStatus(username);
toast.success(t("admin.adminStatusRemoved", { username }));
fetchUsers();
} catch {
toast.error(t("admin.failedToRemoveAdminStatus"));
}
});
};
const handleDeleteUser = async (username: string) => {
const handleDeleteUserQuick = async (username: string) => {
confirmWithToast(
t("admin.deleteUser", { username }),
async () => {
@@ -844,10 +780,6 @@ export function AdminSettings({
<Clock className="h-4 w-4" />
Sessions
</TabsTrigger>
<TabsTrigger value="admins" className="flex items-center gap-2">
<Shield className="h-4 w-4" />
{t("admin.adminManagement")}
</TabsTrigger>
<TabsTrigger value="roles" className="flex items-center gap-2">
<Shield className="h-4 w-4" />
{t("rbac.roles.label")}
@@ -1148,14 +1080,25 @@ export function AdminSettings({
<h3 className="text-lg font-semibold">
{t("admin.userManagement")}
</h3>
<Button
onClick={fetchUsers}
disabled={usersLoading}
variant="outline"
size="sm"
>
{usersLoading ? t("admin.loading") : t("admin.refresh")}
</Button>
<div className="flex gap-2">
{allowPasswordLogin && (
<Button
onClick={() => setCreateUserDialogOpen(true)}
size="sm"
>
<UserPlus className="h-4 w-4 mr-2" />
{t("admin.createUser")}
</Button>
)}
<Button
onClick={fetchUsers}
disabled={usersLoading}
variant="outline"
size="sm"
>
{usersLoading ? t("admin.loading") : t("admin.refresh")}
</Button>
</div>
</div>
{usersLoading ? (
<div className="text-center py-8 text-muted-foreground">
@@ -1166,7 +1109,7 @@ export function AdminSettings({
<TableHeader>
<TableRow>
<TableHead>{t("admin.username")}</TableHead>
<TableHead>{t("admin.type")}</TableHead>
<TableHead>{t("admin.authType")}</TableHead>
<TableHead>{t("admin.actions")}</TableHead>
</TableRow>
</TableHeader>
@@ -1181,15 +1124,18 @@ export function AdminSettings({
</span>
)}
</TableCell>
<TableCell>
{user.is_oidc && user.password_hash
? "Dual Auth"
: user.is_oidc
? t("admin.external")
: t("admin.local")}
</TableCell>
<TableCell>{getAuthTypeDisplay(user)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditUser(user)}
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
title={t("admin.manageUser")}
>
<Edit className="h-4 w-4" />
</Button>
{user.is_oidc && !user.password_hash && (
<Button
variant="ghost"
@@ -1200,7 +1146,7 @@ export function AdminSettings({
username: user.username,
})
}
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
className="text-purple-600 hover:text-purple-700 hover:bg-purple-50"
title="Link to password account"
>
<Link2 className="h-4 w-4" />
@@ -1223,20 +1169,8 @@ export function AdminSettings({
variant="ghost"
size="sm"
onClick={() =>
handleOpenRolesDialog({
id: user.id,
username: user.username,
})
handleDeleteUserQuick(user.username)
}
className="text-purple-600 hover:text-purple-700 hover:bg-purple-50"
title={t("rbac.manageRoles")}
>
<UserCog className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteUser(user.username)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}
>
@@ -1379,94 +1313,6 @@ export function AdminSettings({
</div>
</TabsContent>
<TabsContent value="admins" className="space-y-6">
<div className="space-y-6">
<h3 className="text-lg font-semibold">
{t("admin.adminManagement")}
</h3>
<div className="space-y-4 p-4 border rounded-md bg-dark-bg-panel">
<h4 className="font-semibold">{t("admin.makeUserAdmin")}</h4>
<form onSubmit={handleMakeUserAdmin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-admin-username">
{t("admin.username")}
</Label>
<div className="flex gap-2">
<Input
id="new-admin-username"
value={newAdminUsername}
onChange={(e) => setNewAdminUsername(e.target.value)}
placeholder={t("admin.enterUsernameToMakeAdmin")}
required
/>
<Button
type="submit"
disabled={
makeAdminLoading || !newAdminUsername.trim()
}
>
{makeAdminLoading
? t("admin.adding")
: t("admin.makeAdmin")}
</Button>
</div>
</div>
{makeAdminError && (
<Alert variant="destructive">
<AlertTitle>{t("common.error")}</AlertTitle>
<AlertDescription>{makeAdminError}</AlertDescription>
</Alert>
)}
</form>
</div>
<div className="space-y-4">
<h4 className="font-medium">{t("admin.currentAdmins")}</h4>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("admin.username")}</TableHead>
<TableHead>{t("admin.type")}</TableHead>
<TableHead>{t("admin.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users
.filter((u) => u.is_admin)
.map((admin) => (
<TableRow key={admin.id}>
<TableCell className="font-medium">
{admin.username}
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
{t("admin.adminBadge")}
</span>
</TableCell>
<TableCell>
{admin.is_oidc
? t("admin.external")
: t("admin.local")}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRemoveAdminStatus(admin.username)
}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
>
<Shield className="h-4 w-4" />
{t("admin.removeAdminButton")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</TabsContent>
<TabsContent value="roles" className="space-y-6">
<RoleManagement />
</TabsContent>
@@ -1687,113 +1533,21 @@ export function AdminSettings({
</Dialog>
)}
{/* Role Management Dialog */}
<Dialog open={rolesDialogOpen} onOpenChange={setRolesDialogOpen}>
<DialogContent className="max-w-2xl bg-dark-bg border-2 border-dark-border">
<DialogHeader>
<DialogTitle>{t("rbac.manageRoles")}</DialogTitle>
<DialogDescription>
{t("rbac.manageRolesFor", {
username: selectedUser?.username || "",
})}
</DialogDescription>
</DialogHeader>
{/* New User Management Dialogs */}
<CreateUserDialog
open={createUserDialogOpen}
onOpenChange={setCreateUserDialogOpen}
onSuccess={handleCreateUserSuccess}
/>
{rolesLoading ? (
<div className="text-center py-8 text-muted-foreground">
{t("common.loading")}
</div>
) : (
<div className="space-y-6 py-4">
{/* Current Roles */}
<div className="space-y-3">
<Label>{t("rbac.currentRoles")}</Label>
{userRoles.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("rbac.noRolesAssigned")}
</p>
) : (
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-2">
{userRoles.map((userRole) => (
<div
key={userRole.roleId}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<p className="font-medium">
{t(userRole.roleDisplayName)}
</p>
<p className="text-xs text-muted-foreground">
{userRole.roleName}
</p>
</div>
{userRole.isSystem ? (
<Badge variant="secondary" className="text-xs">
{t("rbac.systemRole")}
</Badge>
) : (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => handleRemoveRole(userRole.roleId)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
)}
</div>
{/* Assign New Role */}
<div className="space-y-3">
<Label>{t("rbac.assignNewRole")}</Label>
<div className="flex gap-2">
{availableRoles
.filter(
(role) =>
!role.isSystem &&
!userRoles.some((ur) => ur.roleId === role.id),
)
.map((role) => (
<Button
key={role.id}
type="button"
size="sm"
variant="outline"
onClick={() => handleAssignRole(role.id)}
>
{t(role.displayName)}
</Button>
))}
{availableRoles.filter(
(role) =>
!role.isSystem &&
!userRoles.some((ur) => ur.roleId === role.id),
).length === 0 && (
<p className="text-sm text-muted-foreground">
{t("rbac.noCustomRolesToAssign")}
</p>
)}
</div>
</div>
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setRolesDialogOpen(false)}
>
{t("common.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<UserEditDialog
open={userEditDialogOpen}
onOpenChange={setUserEditDialogOpen}
user={selectedUserForEdit}
currentUser={currentUser}
onSuccess={handleEditUserSuccess}
allowPasswordLogin={allowPasswordLogin}
/>
</div>
);
}

View File

@@ -0,0 +1,164 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useTranslation } from "react-i18next";
import { UserPlus, AlertCircle } from "lucide-react";
import { toast } from "sonner";
import { registerUser } from "@/ui/main-axios";
interface CreateUserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export function CreateUserDialog({
open,
onOpenChange,
onSuccess,
}: CreateUserDialogProps) {
const { t } = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset form when dialog closes
useEffect(() => {
if (!open) {
setUsername("");
setPassword("");
setError(null);
}
}, [open]);
const handleCreateUser = async (e?: React.FormEvent) => {
if (e) {
e.preventDefault();
}
if (!username.trim()) {
setError(t("admin.enterUsername"));
return;
}
if (!password.trim()) {
setError(t("admin.enterPassword"));
return;
}
if (password.length < 6) {
setError("Password must be at least 6 characters");
return;
}
setLoading(true);
setError(null);
try {
await registerUser(username.trim(), password);
toast.success(
t("admin.userCreatedSuccessfully", { username: username.trim() }),
);
setUsername("");
setPassword("");
onSuccess();
onOpenChange(false);
} catch (err: unknown) {
const error = err as { response?: { data?: { error?: string } } };
const errorMessage =
error?.response?.data?.error || t("admin.failedToCreateUser");
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
return (
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (!loading) {
onOpenChange(newOpen);
}
}}
>
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="w-5 h-5" />
{t("admin.createUser")}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t("admin.createUserDescription")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleCreateUser} className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="create-username">{t("admin.username")}</Label>
<Input
id="create-username"
value={username}
onChange={(e) => {
setUsername(e.target.value);
setError(null);
}}
placeholder={t("admin.enterUsername")}
disabled={loading}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-password">{t("common.password")}</Label>
<PasswordInput
id="create-password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError(null);
}}
placeholder={t("admin.enterPassword")}
disabled={loading}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleCreateUser();
}
}}
/>
<p className="text-xs text-muted-foreground">
Password must be at least 6 characters
</p>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("common.error")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</form>
<DialogFooter>
<Button onClick={() => handleCreateUser()} disabled={loading}>
{loading ? t("common.creating") : t("admin.createUser")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,625 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useTranslation } from "react-i18next";
import {
UserCog,
Trash2,
Plus,
AlertCircle,
Shield,
Key,
Clock,
} from "lucide-react";
import { toast } from "sonner";
import { useConfirmation } from "@/hooks/use-confirmation";
import {
getUserRoles,
getRoles,
assignRoleToUser,
removeRoleFromUser,
makeUserAdmin,
removeAdminStatus,
initiatePasswordReset,
revokeAllUserSessions,
deleteUser,
type UserRole,
type Role,
} from "@/ui/main-axios";
interface User {
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean;
password_hash?: string;
}
interface UserEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user: User | null;
currentUser: { id: string; username: string } | null;
onSuccess: () => void;
allowPasswordLogin: boolean;
}
export function UserEditDialog({
open,
onOpenChange,
user,
currentUser,
onSuccess,
allowPasswordLogin,
}: UserEditDialogProps) {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [adminLoading, setAdminLoading] = useState(false);
const [passwordResetLoading, setPasswordResetLoading] = useState(false);
const [sessionLoading, setSessionLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [rolesLoading, setRolesLoading] = useState(false);
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
const [availableRoles, setAvailableRoles] = useState<Role[]>([]);
const [isAdmin, setIsAdmin] = useState(false);
const isCurrentUser = user?.id === currentUser?.id;
useEffect(() => {
if (open && user) {
setIsAdmin(user.is_admin);
loadRoles();
}
}, [open, user]);
const loadRoles = async () => {
if (!user) return;
setRolesLoading(true);
try {
const [rolesResponse, allRolesResponse] = await Promise.all([
getUserRoles(user.id),
getRoles(),
]);
setUserRoles(rolesResponse.roles || []);
setAvailableRoles(allRolesResponse.roles || []);
} catch (error) {
console.error("Failed to load roles:", error);
toast.error(t("rbac.failedToLoadRoles"));
} finally {
setRolesLoading(false);
}
};
const handleToggleAdmin = async (checked: boolean) => {
if (!user) return;
if (isCurrentUser) {
toast.error(t("admin.cannotRemoveOwnAdmin"));
return;
}
// Close dialog temporarily to show confirmation toast on top
const userToUpdate = user;
onOpenChange(false);
const confirmed = await confirmWithToast({
title: checked ? t("admin.makeUserAdmin") : t("admin.removeAdmin"),
description: checked
? t("admin.confirmMakeAdmin", { username: userToUpdate.username })
: t("admin.confirmRemoveAdmin", { username: userToUpdate.username }),
confirmText: checked ? t("admin.makeAdmin") : t("admin.removeAdmin"),
cancelText: t("common.cancel"),
variant: checked ? "default" : "destructive",
});
if (!confirmed) {
onOpenChange(true);
return;
}
setAdminLoading(true);
try {
if (checked) {
await makeUserAdmin(userToUpdate.username);
toast.success(
t("admin.userIsNowAdmin", { username: userToUpdate.username }),
);
} else {
await removeAdminStatus(userToUpdate.username);
toast.success(
t("admin.adminStatusRemoved", { username: userToUpdate.username }),
);
}
setIsAdmin(checked);
onSuccess();
onOpenChange(true);
} catch (error) {
console.error("Failed to toggle admin status:", error);
toast.error(
checked
? t("admin.failedToMakeUserAdmin")
: t("admin.failedToRemoveAdminStatus"),
);
onOpenChange(true);
} finally {
setAdminLoading(false);
}
};
const handlePasswordReset = async () => {
if (!user) return;
// Close dialog temporarily to show confirmation toast on top
const userToReset = user;
onOpenChange(false);
const confirmed = await confirmWithToast({
title: t("admin.resetUserPassword"),
description: `${t("admin.passwordResetWarning")} (${userToReset.username})`,
confirmText: t("admin.resetUserPassword"),
cancelText: t("common.cancel"),
variant: "destructive",
});
if (!confirmed) {
onOpenChange(true);
return;
}
setPasswordResetLoading(true);
try {
await initiatePasswordReset(userToReset.username);
toast.success(
t("admin.passwordResetInitiated", { username: userToReset.username }),
);
onSuccess();
onOpenChange(true);
} catch (error) {
console.error("Failed to reset password:", error);
toast.error(t("admin.failedToResetPassword"));
onOpenChange(true);
} finally {
setPasswordResetLoading(false);
}
};
const handleAssignRole = async (roleId: number) => {
if (!user) return;
try {
await assignRoleToUser(user.id, roleId);
toast.success(
t("rbac.roleAssignedSuccessfully", { username: user.username }),
);
await loadRoles();
} catch (error) {
console.error("Failed to assign role:", error);
toast.error(t("rbac.failedToAssignRole"));
}
};
const handleRemoveRole = async (roleId: number) => {
if (!user) return;
// Close dialog temporarily to show confirmation toast on top
const userToUpdate = user;
onOpenChange(false);
const confirmed = await confirmWithToast({
title: t("rbac.confirmRemoveRole"),
description: t("rbac.confirmRemoveRoleDescription"),
confirmText: t("common.remove"),
cancelText: t("common.cancel"),
variant: "destructive",
});
if (!confirmed) {
onOpenChange(true);
return;
}
try {
await removeRoleFromUser(userToUpdate.id, roleId);
toast.success(
t("rbac.roleRemovedSuccessfully", { username: userToUpdate.username }),
);
await loadRoles();
onOpenChange(true);
} catch (error) {
console.error("Failed to remove role:", error);
toast.error(t("rbac.failedToRemoveRole"));
onOpenChange(true);
}
};
const handleRevokeAllSessions = async () => {
if (!user) return;
const isRevokingSelf = isCurrentUser;
// Close dialog temporarily to show confirmation toast on top
const userToUpdate = user;
onOpenChange(false);
const confirmed = await confirmWithToast({
title: t("admin.revokeAllSessions"),
description: isRevokingSelf
? t("admin.confirmRevokeOwnSessions")
: t("admin.confirmRevokeAllSessions"),
confirmText: t("admin.revoke"),
cancelText: t("common.cancel"),
variant: "destructive",
});
if (!confirmed) {
onOpenChange(true);
return;
}
setSessionLoading(true);
try {
const data = await revokeAllUserSessions(userToUpdate.id);
toast.success(data.message || t("admin.sessionsRevokedSuccessfully"));
if (isRevokingSelf) {
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
onSuccess();
onOpenChange(true);
}
} catch (error) {
console.error("Failed to revoke sessions:", error);
toast.error(t("admin.failedToRevokeSessions"));
onOpenChange(true);
} finally {
setSessionLoading(false);
}
};
const handleDeleteUser = async () => {
if (!user) return;
if (isCurrentUser) {
toast.error(t("admin.cannotDeleteSelf"));
return;
}
// Close dialog temporarily to show confirmation toast on top
const userToDelete = user;
onOpenChange(false);
const confirmed = await confirmWithToast({
title: t("admin.deleteUserTitle"),
description: t("admin.deleteUser", { username: userToDelete.username }),
confirmText: t("common.delete"),
cancelText: t("common.cancel"),
variant: "destructive",
});
if (!confirmed) {
// Reopen dialog if user cancels
onOpenChange(true);
return;
}
setDeleteLoading(true);
try {
await deleteUser(userToDelete.username);
toast.success(
t("admin.userDeletedSuccessfully", { username: userToDelete.username }),
);
onSuccess();
} catch (error) {
console.error("Failed to delete user:", error);
toast.error(t("admin.failedToDeleteUser"));
onOpenChange(true);
} finally {
setDeleteLoading(false);
}
};
const getAuthTypeDisplay = (): string => {
if (!user) return "";
if (user.is_oidc && user.password_hash) {
return t("admin.dualAuth");
} else if (user.is_oidc) {
return t("admin.externalOIDC");
} else {
return t("admin.localPassword");
}
};
if (!user) return null;
const showPasswordReset =
allowPasswordLogin && (user.password_hash || !user.is_oidc);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl bg-dark-bg border-2 border-dark-border">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserCog className="w-5 h-5" />
{t("admin.manageUser")}: {user.username}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t("admin.manageUserDescription")}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4 max-h-[70vh] overflow-y-auto pr-2">
{/* READ-ONLY INFO SECTION */}
<div className="grid grid-cols-2 gap-4 p-4 bg-dark-bg-panel rounded-lg border border-dark-border">
<div>
<Label className="text-muted-foreground text-xs">
{t("admin.username")}
</Label>
<p className="font-medium">{user.username}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">
{t("admin.authType")}
</Label>
<p className="font-medium">{getAuthTypeDisplay()}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">
{t("admin.adminStatus")}
</Label>
<p className="font-medium">
{isAdmin ? (
<Badge variant="secondary">{t("admin.adminBadge")}</Badge>
) : (
t("admin.regularUser")
)}
</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">
{t("admin.userId")}
</Label>
<p className="font-mono text-xs truncate">{user.id}</p>
</div>
</div>
<Separator />
{/* ADMIN TOGGLE SECTION */}
<div className="space-y-3">
<Label className="text-base font-semibold flex items-center gap-2">
<Shield className="h-4 w-4" />
{t("admin.adminPrivileges")}
</Label>
<div className="flex items-center justify-between p-3 border border-dark-border rounded-lg bg-dark-bg-panel">
<div className="flex-1">
<p className="font-medium">{t("admin.administratorRole")}</p>
<p className="text-sm text-muted-foreground">
{t("admin.administratorRoleDescription")}
</p>
</div>
<Switch
checked={isAdmin}
onCheckedChange={handleToggleAdmin}
disabled={isCurrentUser || adminLoading}
/>
</div>
{isCurrentUser && (
<p className="text-xs text-muted-foreground">
{t("admin.cannotModifyOwnAdminStatus")}
</p>
)}
</div>
<Separator />
{/* PASSWORD RESET SECTION */}
{showPasswordReset && (
<>
<div className="space-y-3">
<Label className="text-base font-semibold flex items-center gap-2">
<Key className="h-4 w-4" />
{t("admin.passwordManagement")}
</Label>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("common.warning")}</AlertTitle>
<AlertDescription>
{t("admin.passwordResetWarning")}
</AlertDescription>
</Alert>
<Button
variant="destructive"
onClick={handlePasswordReset}
disabled={passwordResetLoading}
className="w-full"
>
{passwordResetLoading
? t("admin.resettingPassword")
: t("admin.resetUserPassword")}
</Button>
</div>
<Separator />
</>
)}
{/* ROLE MANAGEMENT SECTION */}
<div className="space-y-4">
<Label className="text-base font-semibold flex items-center gap-2">
<UserCog className="h-4 w-4" />
{t("rbac.roleManagement")}
</Label>
{rolesLoading ? (
<div className="text-center py-4 text-muted-foreground text-sm">
{t("common.loading")}
</div>
) : (
<>
{/* Current Roles */}
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("rbac.currentRoles")}
</Label>
{userRoles.length === 0 ? (
<p className="text-sm text-muted-foreground italic py-2">
{t("rbac.noRolesAssigned")}
</p>
) : (
<div className="space-y-2">
{userRoles.map((role) => (
<div
key={role.roleId}
className="flex items-center justify-between p-3 border border-dark-border rounded-lg bg-dark-bg-panel"
>
<div>
<p className="font-medium text-sm">
{t(role.roleDisplayName)}
</p>
<p className="text-xs text-muted-foreground">
{role.roleName}
</p>
</div>
<div className="flex items-center gap-2">
{role.isSystem && (
<Badge variant="secondary" className="text-xs">
{t("rbac.systemRole")}
</Badge>
)}
{!role.isSystem && (
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveRole(role.roleId)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Assign New Role */}
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("rbac.assignNewRole")}
</Label>
<div className="flex flex-wrap gap-2">
{availableRoles
.filter(
(role) =>
!role.isSystem &&
!userRoles.some((ur) => ur.roleId === role.id),
)
.map((role) => (
<Button
key={role.id}
variant="outline"
size="sm"
onClick={() => handleAssignRole(role.id)}
>
<Plus className="h-3 w-3 mr-1" />
{t(role.displayName)}
</Button>
))}
{availableRoles.filter(
(role) =>
!role.isSystem &&
!userRoles.some((ur) => ur.roleId === role.id),
).length === 0 && (
<p className="text-sm text-muted-foreground italic">
{t("rbac.noCustomRolesToAssign")}
</p>
)}
</div>
</div>
</>
)}
</div>
<Separator />
{/* SESSION MANAGEMENT SECTION */}
<div className="space-y-3">
<Label className="text-base font-semibold flex items-center gap-2">
<Clock className="h-4 w-4" />
{t("admin.sessionManagement")}
</Label>
<div className="flex items-center justify-between p-3 border border-dark-border rounded-lg bg-dark-bg-panel">
<div className="flex-1">
<p className="font-medium text-sm">
{t("admin.revokeAllSessions")}
</p>
<p className="text-sm text-muted-foreground">
{t("admin.revokeAllSessionsDescription")}
</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={handleRevokeAllSessions}
disabled={sessionLoading}
>
{sessionLoading ? t("admin.revoking") : t("admin.revoke")}
</Button>
</div>
</div>
<Separator />
{/* DANGER ZONE - DELETE USER */}
<div className="space-y-3">
<Label className="text-base font-semibold text-destructive flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
{t("admin.dangerZone")}
</Label>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("admin.deleteUserTitle")}</AlertTitle>
<AlertDescription>
{t("admin.deleteUserWarning")}
</AlertDescription>
</Alert>
<Button
variant="destructive"
onClick={handleDeleteUser}
disabled={isCurrentUser || deleteLoading}
className="w-full"
>
<Trash2 className="h-4 w-4 mr-2" />
{deleteLoading
? t("admin.deleting")
: `${t("common.delete")} ${user.username}`}
</Button>
{isCurrentUser && (
<p className="text-xs text-muted-foreground text-center">
{t("admin.cannotDeleteSelf")}
</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}