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

@@ -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>
);
}