Преглед изворни кода

User table polish and audit log updates

Jamie Curnow пре 2 месеци
родитељ
комит
a3d17249d0

+ 1 - 1
backend/internal/user.js

@@ -131,7 +131,7 @@ const internalUser = {
 						action: "updated",
 						object_type: "user",
 						object_id: user.id,
-						meta: data,
+						meta: { ...data, id: user.id, name: user.name },
 					})
 					.then(() => {
 						return user;

+ 1 - 0
frontend/src/api/backend/index.ts

@@ -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 - 0
frontend/src/api/backend/toggleUser.ts

@@ -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 - 0
frontend/src/components/Table/Formatter/EnabledFormatter.tsx

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

+ 4 - 1
frontend/src/components/Table/Formatter/EventFormatter.tsx

@@ -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 - 0
frontend/src/components/Table/Formatter/index.ts

@@ -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";

+ 1 - 0
frontend/src/hooks/useDeadHost.ts

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

+ 1 - 0
frontend/src/hooks/useUser.ts

@@ -46,6 +46,7 @@ const useSetUser = () => {
 		onSuccess: async ({ id }: User) => {
 			queryClient.invalidateQueries({ queryKey: ["user", id] });
 			queryClient.invalidateQueries({ queryKey: ["users"] });
+			queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
 		},
 	});
 };

+ 6 - 0
frontend/src/locale/lang/en.json

@@ -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"
 }

+ 18 - 0
frontend/src/locale/src/en.json

@@ -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"
 	}

+ 17 - 12
frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx

@@ -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>

+ 16 - 6
frontend/src/pages/Users/Empty.tsx

@@ -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>

+ 34 - 5
frontend/src/pages/Users/Table.tsx

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

+ 42 - 13
frontend/src/pages/Users/TableWrapper.tsx

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