You've already forked nginx-proxy-manager
							
							
				mirror of
				https://github.com/NginxProxyManager/nginx-proxy-manager.git
				synced 2025-10-30 18:05:34 +03:00 
			
		
		
		
	User table polish and audit log updates
This commit is contained in:
		| @@ -47,6 +47,7 @@ export * from "./toggleDeadHost"; | ||||
| export * from "./toggleProxyHost"; | ||||
| export * from "./toggleRedirectionHost"; | ||||
| export * from "./toggleStream"; | ||||
| export * from "./toggleUser"; | ||||
| export * from "./updateAccessList"; | ||||
| export * from "./updateAuth"; | ||||
| export * from "./updateDeadHost"; | ||||
|   | ||||
							
								
								
									
										10
									
								
								frontend/src/api/backend/toggleUser.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								frontend/src/api/backend/toggleUser.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| import type { User } from "./models"; | ||||
| import { updateUser } from "./updateUser"; | ||||
|  | ||||
| export async function toggleUser(id: number, enabled: boolean): Promise<boolean> { | ||||
| 	await updateUser({ | ||||
| 		id, | ||||
| 		isDisabled: !enabled, | ||||
| 	} as User); | ||||
| 	return true; | ||||
| } | ||||
							
								
								
									
										11
									
								
								frontend/src/components/Table/Formatter/EnabledFormatter.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								frontend/src/components/Table/Formatter/EnabledFormatter.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| import { intl } from "src/locale"; | ||||
|  | ||||
| interface Props { | ||||
| 	enabled: boolean; | ||||
| } | ||||
| export function EnabledFormatter({ enabled }: Props) { | ||||
| 	if (enabled) { | ||||
| 		return <span className="badge bg-lime-lt">{intl.formatMessage({ id: "enabled" })}</span>; | ||||
| 	} | ||||
| 	return <span className="badge bg-red-lt">{intl.formatMessage({ id: "disabled" })}</span>; | ||||
| } | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { IconUser } from "@tabler/icons-react"; | ||||
| import { IconBoltOff, IconUser } from "@tabler/icons-react"; | ||||
| import type { AuditLog } from "src/api/backend"; | ||||
| import { DateTimeFormat, intl } from "src/locale"; | ||||
|  | ||||
| @@ -35,6 +35,9 @@ const getIcon = (row: AuditLog) => { | ||||
| 		case "user": | ||||
| 			ico = <IconUser size={16} className={c} />; | ||||
| 			break; | ||||
| 		case "dead-host": | ||||
| 			ico = <IconBoltOff size={16} className={c} />; | ||||
| 			break; | ||||
| 	} | ||||
|  | ||||
| 	return ico; | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| export * from "./CertificateFormatter"; | ||||
| export * from "./DomainsFormatter"; | ||||
| export * from "./EmailFormatter"; | ||||
| export * from "./EnabledFormatter"; | ||||
| export * from "./EventFormatter"; | ||||
| export * from "./GravatarFormatter"; | ||||
| export * from "./RolesFormatter"; | ||||
|   | ||||
| @@ -50,6 +50,7 @@ const useSetDeadHost = () => { | ||||
| 		onSuccess: async ({ id }: DeadHost) => { | ||||
| 			queryClient.invalidateQueries({ queryKey: ["dead-host", id] }); | ||||
| 			queryClient.invalidateQueries({ queryKey: ["dead-hosts"] }); | ||||
| 			queryClient.invalidateQueries({ queryKey: ["audit-logs"] }); | ||||
| 		}, | ||||
| 	}); | ||||
| }; | ||||
|   | ||||
| @@ -46,6 +46,7 @@ const useSetUser = () => { | ||||
| 		onSuccess: async ({ id }: User) => { | ||||
| 			queryClient.invalidateQueries({ queryKey: ["user", id] }); | ||||
| 			queryClient.invalidateQueries({ queryKey: ["users"] }); | ||||
| 			queryClient.invalidateQueries({ queryKey: ["audit-logs"] }); | ||||
| 		}, | ||||
| 	}); | ||||
| }; | ||||
|   | ||||
| @@ -62,7 +62,9 @@ | ||||
|   "domains.http2-support": "HTTP/2 Support", | ||||
|   "domains.use-dns": "Use DNS Challenge", | ||||
|   "email-address": "Email address", | ||||
|   "empty-search": "No results found", | ||||
|   "empty-subtitle": "Why don't you create one?", | ||||
|   "enabled": "Enabled", | ||||
|   "error.invalid-auth": "Invalid email or password", | ||||
|   "error.invalid-domain": "Invalid domain: {domain}", | ||||
|   "error.invalid-email": "Invalid email address", | ||||
| @@ -71,6 +73,7 @@ | ||||
|   "error.required": "This is required", | ||||
|   "event.created-dead-host": "Created 404 Host", | ||||
|   "event.created-user": "Created User", | ||||
|   "event.deleted-dead-host": "Deleted 404 Host", | ||||
|   "event.deleted-user": "Deleted User", | ||||
|   "event.disabled-dead-host": "Disabled 404 Host", | ||||
|   "event.enabled-dead-host": "Enabled 404 Host", | ||||
| @@ -94,6 +97,8 @@ | ||||
|   "notification.host-enabled": "Host has been enabled", | ||||
|   "notification.success": "Success", | ||||
|   "notification.user-deleted": "User has been deleted", | ||||
|   "notification.user-disabled": "User has been disabled", | ||||
|   "notification.user-enabled": "User has been enabled", | ||||
|   "notification.user-saved": "User has been saved", | ||||
|   "offline": "Offline", | ||||
|   "online": "Online", | ||||
| @@ -151,5 +156,6 @@ | ||||
|   "user.switch-light": "Switch to Light mode", | ||||
|   "users.actions-title": "User #{id}", | ||||
|   "users.add": "Add User", | ||||
|   "users.empty": "There are no Users", | ||||
|   "users.title": "Users" | ||||
| } | ||||
| @@ -188,6 +188,12 @@ | ||||
| 	"email-address": { | ||||
| 		"defaultMessage": "Email address" | ||||
| 	}, | ||||
| 	"empty-search": { | ||||
| 		"defaultMessage": "No results found" | ||||
| 	}, | ||||
| 	"enabled": { | ||||
| 		"defaultMessage": "Enabled" | ||||
| 	}, | ||||
| 	"error.passwords-must-match": { | ||||
| 		"defaultMessage": "Passwords must match" | ||||
| 	}, | ||||
| @@ -212,6 +218,9 @@ | ||||
| 	"event.created-user": { | ||||
| 		"defaultMessage": "Created User" | ||||
| 	}, | ||||
| 	"event.deleted-dead-host": { | ||||
| 		"defaultMessage": "Deleted 404 Host" | ||||
| 	}, | ||||
| 	"event.deleted-user": { | ||||
| 		"defaultMessage": "Deleted User" | ||||
| 	}, | ||||
| @@ -281,6 +290,12 @@ | ||||
| 	"notification.host-enabled": { | ||||
| 		"defaultMessage": "Host has been enabled" | ||||
| 	}, | ||||
| 	"notification.user-disabled": { | ||||
| 		"defaultMessage": "User has been disabled" | ||||
| 	}, | ||||
| 	"notification.user-enabled": { | ||||
| 		"defaultMessage": "User has been enabled" | ||||
| 	}, | ||||
| 	"notification.user-saved": { | ||||
| 		"defaultMessage": "User has been saved" | ||||
| 	}, | ||||
| @@ -455,6 +470,9 @@ | ||||
| 	"users.add": { | ||||
| 		"defaultMessage": "Add User" | ||||
| 	}, | ||||
| 	"users.empty": { | ||||
| 		"defaultMessage": "There are no Users" | ||||
| 	}, | ||||
| 	"users.title": { | ||||
| 		"defaultMessage": "Users" | ||||
| 	} | ||||
|   | ||||
| @@ -42,6 +42,9 @@ export default function TableWrapper() { | ||||
| 		filtered = data?.filter((item) => { | ||||
| 			return item.domainNames.some((domain: string) => domain.toLowerCase().includes(search)); | ||||
| 		}); | ||||
| 	} else if (search !== "") { | ||||
| 		// this can happen if someone deletes the last item while searching | ||||
| 		setSearch(""); | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| @@ -55,18 +58,20 @@ export default function TableWrapper() { | ||||
| 						</div> | ||||
| 						<div className="col-md-auto col-sm-12"> | ||||
| 							<div className="ms-auto d-flex flex-wrap btn-list"> | ||||
| 								<div className="input-group input-group-flat w-auto"> | ||||
| 									<span className="input-group-text input-group-text-sm"> | ||||
| 										<IconSearch size={16} /> | ||||
| 									</span> | ||||
| 									<input | ||||
| 										id="advanced-table-search" | ||||
| 										type="text" | ||||
| 										className="form-control form-control-sm" | ||||
| 										autoComplete="off" | ||||
| 										onChange={(e: any) => setSearch(e.target.value.toLowerCase())} | ||||
| 									/> | ||||
| 								</div> | ||||
| 								{data?.length ? ( | ||||
| 									<div className="input-group input-group-flat w-auto"> | ||||
| 										<span className="input-group-text input-group-text-sm"> | ||||
| 											<IconSearch size={16} /> | ||||
| 										</span> | ||||
| 										<input | ||||
| 											id="advanced-table-search" | ||||
| 											type="text" | ||||
| 											className="form-control form-control-sm" | ||||
| 											autoComplete="off" | ||||
| 											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())} | ||||
| 										/> | ||||
| 									</div> | ||||
| 								) : null} | ||||
| 								<Button size="sm" className="btn-red" onClick={() => setEditId("new")}> | ||||
| 									{intl.formatMessage({ id: "dead-hosts.add" })} | ||||
| 								</Button> | ||||
|   | ||||
| @@ -5,17 +5,27 @@ import { intl } from "src/locale"; | ||||
| interface Props { | ||||
| 	tableInstance: ReactTable<any>; | ||||
| 	onNewUser?: () => void; | ||||
| 	isFiltered?: boolean; | ||||
| } | ||||
| export default function Empty({ tableInstance, onNewUser }: Props) { | ||||
| export default function Empty({ tableInstance, onNewUser, isFiltered }: Props) { | ||||
| 	if (isFiltered) { | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<tr> | ||||
| 			<td colSpan={tableInstance.getVisibleFlatColumns().length}> | ||||
| 				<div className="text-center my-4"> | ||||
| 					<h2>{intl.formatMessage({ id: "proxy-hosts.empty" })}</h2> | ||||
| 					<p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p> | ||||
| 					<Button className="btn-lime my-3" onClick={onNewUser}> | ||||
| 						{intl.formatMessage({ id: "proxy-hosts.add" })} | ||||
| 					</Button> | ||||
| 					{isFiltered ? ( | ||||
| 						<h2>{intl.formatMessage({ id: "empty-search" })}</h2> | ||||
| 					) : ( | ||||
| 						<> | ||||
| 							<h2>{intl.formatMessage({ id: "users.empty" })}</h2> | ||||
| 							<p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p> | ||||
| 							<Button className="btn-orange my-3" onClick={onNewUser}> | ||||
| 								{intl.formatMessage({ id: "users.add" })} | ||||
| 							</Button> | ||||
| 						</> | ||||
| 					)} | ||||
| 				</div> | ||||
| 			</td> | ||||
| 		</tr> | ||||
|   | ||||
| @@ -1,30 +1,40 @@ | ||||
| import { IconDotsVertical, IconEdit, IconLock, IconShield, IconTrash } from "@tabler/icons-react"; | ||||
| import { IconDotsVertical, IconEdit, IconLock, IconPower, IconShield, IconTrash } from "@tabler/icons-react"; | ||||
| import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; | ||||
| import { useMemo } from "react"; | ||||
| import type { User } from "src/api/backend"; | ||||
| import { EmailFormatter, GravatarFormatter, RolesFormatter, ValueWithDateFormatter } from "src/components"; | ||||
| import { | ||||
| 	EmailFormatter, | ||||
| 	EnabledFormatter, | ||||
| 	GravatarFormatter, | ||||
| 	RolesFormatter, | ||||
| 	ValueWithDateFormatter, | ||||
| } from "src/components"; | ||||
| import { TableLayout } from "src/components/Table/TableLayout"; | ||||
| import { intl } from "src/locale"; | ||||
| import Empty from "./Empty"; | ||||
|  | ||||
| interface Props { | ||||
| 	data: User[]; | ||||
| 	isFiltered?: boolean; | ||||
| 	isFetching?: boolean; | ||||
| 	currentUserId?: number; | ||||
| 	onEditUser?: (id: number) => void; | ||||
| 	onEditPermissions?: (id: number) => void; | ||||
| 	onSetPassword?: (id: number) => void; | ||||
| 	onDeleteUser?: (id: number) => void; | ||||
| 	onDisableToggle?: (id: number, enabled: boolean) => void; | ||||
| 	onNewUser?: () => void; | ||||
| } | ||||
| export default function Table({ | ||||
| 	data, | ||||
| 	isFiltered, | ||||
| 	isFetching, | ||||
| 	currentUserId, | ||||
| 	onEditUser, | ||||
| 	onEditPermissions, | ||||
| 	onSetPassword, | ||||
| 	onDeleteUser, | ||||
| 	onDisableToggle, | ||||
| 	onNewUser, | ||||
| }: Props) { | ||||
| 	const columnHelper = createColumnHelper<User>(); | ||||
| @@ -62,7 +72,6 @@ export default function Table({ | ||||
| 					return <EmailFormatter email={info.getValue()} />; | ||||
| 				}, | ||||
| 			}), | ||||
| 			// TODO: formatter for roles | ||||
| 			columnHelper.accessor((row: any) => row.roles, { | ||||
| 				id: "roles", | ||||
| 				header: intl.formatMessage({ id: "column.roles" }), | ||||
| @@ -70,6 +79,13 @@ export default function Table({ | ||||
| 					return <RolesFormatter roles={info.getValue()} />; | ||||
| 				}, | ||||
| 			}), | ||||
| 			columnHelper.accessor((row: any) => row.isDisabled, { | ||||
| 				id: "isDisabled", | ||||
| 				header: intl.formatMessage({ id: "column.status" }), | ||||
| 				cell: (info: any) => { | ||||
| 					return <EnabledFormatter enabled={!info.getValue()} />; | ||||
| 				}, | ||||
| 			}), | ||||
| 			columnHelper.display({ | ||||
| 				id: "id", // todo: not needed for a display? | ||||
| 				cell: (info: any) => { | ||||
| @@ -127,6 +143,19 @@ export default function Table({ | ||||
| 											<IconLock size={16} /> | ||||
| 											{intl.formatMessage({ id: "user.set-password" })} | ||||
| 										</a> | ||||
| 										<a | ||||
| 											className="dropdown-item" | ||||
| 											href="#" | ||||
| 											onClick={(e) => { | ||||
| 												e.preventDefault(); | ||||
| 												onDisableToggle?.(info.row.original.id, info.row.original.isDisabled); | ||||
| 											}} | ||||
| 										> | ||||
| 											<IconPower size={16} /> | ||||
| 											{intl.formatMessage({ | ||||
| 												id: info.row.original.isDisabled ? "action.enable" : "action.disable", | ||||
| 											})} | ||||
| 										</a> | ||||
| 										<div className="dropdown-divider" /> | ||||
| 										<a | ||||
| 											className="dropdown-item" | ||||
| @@ -150,7 +179,7 @@ export default function Table({ | ||||
| 				}, | ||||
| 			}), | ||||
| 		], | ||||
| 		[columnHelper, currentUserId, onEditUser, onDeleteUser, onEditPermissions, onSetPassword], | ||||
| 		[columnHelper, currentUserId, onEditUser, onDisableToggle, onDeleteUser, onEditPermissions, onSetPassword], | ||||
| 	); | ||||
|  | ||||
| 	const tableInstance = useReactTable<User>({ | ||||
| @@ -167,7 +196,7 @@ export default function Table({ | ||||
| 	return ( | ||||
| 		<TableLayout | ||||
| 			tableInstance={tableInstance} | ||||
| 			emptyState={<Empty tableInstance={tableInstance} onNewUser={onNewUser} />} | ||||
| 			emptyState={<Empty tableInstance={tableInstance} onNewUser={onNewUser} isFiltered={isFiltered} />} | ||||
| 		/> | ||||
| 	); | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import { IconSearch } from "@tabler/icons-react"; | ||||
| import { useQueryClient } from "@tanstack/react-query"; | ||||
| import { useState } from "react"; | ||||
| import Alert from "react-bootstrap/Alert"; | ||||
| import { deleteUser } from "src/api/backend"; | ||||
| import { deleteUser, toggleUser } from "src/api/backend"; | ||||
| import { Button, LoadingPage } from "src/components"; | ||||
| import { useUser, useUsers } from "src/hooks"; | ||||
| import { intl } from "src/locale"; | ||||
| @@ -10,6 +11,8 @@ import { showSuccess } from "src/notifications"; | ||||
| import Table from "./Table"; | ||||
|  | ||||
| export default function TableWrapper() { | ||||
| 	const queryClient = useQueryClient(); | ||||
| 	const [search, setSearch] = useState(""); | ||||
| 	const [editUserId, setEditUserId] = useState(0 as number | "new"); | ||||
| 	const [editUserPermissionsId, setEditUserPermissionsId] = useState(0); | ||||
| 	const [editUserPasswordId, setEditUserPasswordId] = useState(0); | ||||
| @@ -30,6 +33,27 @@ export default function TableWrapper() { | ||||
| 		showSuccess(intl.formatMessage({ id: "notification.user-deleted" })); | ||||
| 	}; | ||||
|  | ||||
| 	const handleDisableToggle = async (id: number, enabled: boolean) => { | ||||
| 		await toggleUser(id, enabled); | ||||
| 		queryClient.invalidateQueries({ queryKey: ["users"] }); | ||||
| 		queryClient.invalidateQueries({ queryKey: ["user", id] }); | ||||
| 		showSuccess(intl.formatMessage({ id: enabled ? "notification.user-enabled" : "notification.user-disabled" })); | ||||
| 	}; | ||||
|  | ||||
| 	let filtered = null; | ||||
| 	if (search && data) { | ||||
| 		filtered = data?.filter((item) => { | ||||
| 			return ( | ||||
| 				item.name.toLowerCase().includes(search) || | ||||
| 				item.nickname.toLowerCase().includes(search) || | ||||
| 				item.email.toLowerCase().includes(search) | ||||
| 			); | ||||
| 		}); | ||||
| 	} else if (search !== "") { | ||||
| 		// this can happen if someone deletes the last item while searching | ||||
| 		setSearch(""); | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<div className="card mt-4"> | ||||
| 			<div className="card-status-top bg-orange" /> | ||||
| @@ -41,17 +65,20 @@ export default function TableWrapper() { | ||||
| 						</div> | ||||
| 						<div className="col-md-auto col-sm-12"> | ||||
| 							<div className="ms-auto d-flex flex-wrap btn-list"> | ||||
| 								<div className="input-group input-group-flat w-auto"> | ||||
| 									<span className="input-group-text input-group-text-sm"> | ||||
| 										<IconSearch size={16} /> | ||||
| 									</span> | ||||
| 									<input | ||||
| 										id="advanced-table-search" | ||||
| 										type="text" | ||||
| 										className="form-control form-control-sm" | ||||
| 										autoComplete="off" | ||||
| 									/> | ||||
| 								</div> | ||||
| 								{data?.length ? ( | ||||
| 									<div className="input-group input-group-flat w-auto"> | ||||
| 										<span className="input-group-text input-group-text-sm"> | ||||
| 											<IconSearch size={16} /> | ||||
| 										</span> | ||||
| 										<input | ||||
| 											id="advanced-table-search" | ||||
| 											type="text" | ||||
| 											className="form-control form-control-sm" | ||||
| 											autoComplete="off" | ||||
| 											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())} | ||||
| 										/> | ||||
| 									</div> | ||||
| 								) : null} | ||||
| 								<Button size="sm" className="btn-orange" onClick={() => setEditUserId("new")}> | ||||
| 									{intl.formatMessage({ id: "users.add" })} | ||||
| 								</Button> | ||||
| @@ -60,13 +87,15 @@ export default function TableWrapper() { | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<Table | ||||
| 					data={data ?? []} | ||||
| 					data={filtered ?? data ?? []} | ||||
| 					isFiltered={!!search} | ||||
| 					isFetching={isFetching} | ||||
| 					currentUserId={currentUser?.id} | ||||
| 					onEditUser={(id: number) => setEditUserId(id)} | ||||
| 					onEditPermissions={(id: number) => setEditUserPermissionsId(id)} | ||||
| 					onSetPassword={(id: number) => setEditUserPasswordId(id)} | ||||
| 					onDeleteUser={(id: number) => setDeleteUserId(id)} | ||||
| 					onDisableToggle={handleDisableToggle} | ||||
| 					onNewUser={() => setEditUserId("new")} | ||||
| 				/> | ||||
| 				{editUserId ? <UserModal userId={editUserId} onClose={() => setEditUserId(0)} /> : null} | ||||
|   | ||||
		Reference in New Issue
	
	Block a user