From a3d17249d0352de73ed989fb908232fb0d68e74e Mon Sep 17 00:00:00 2001 From: Jamie Curnow Date: Wed, 24 Sep 2025 21:11:00 +1000 Subject: [PATCH] User table polish and audit log updates --- backend/internal/user.js | 2 +- frontend/src/api/backend/index.ts | 1 + frontend/src/api/backend/toggleUser.ts | 10 ++++ .../Table/Formatter/EnabledFormatter.tsx | 11 ++++ .../Table/Formatter/EventFormatter.tsx | 5 +- .../src/components/Table/Formatter/index.ts | 1 + frontend/src/hooks/useDeadHost.ts | 1 + frontend/src/hooks/useUser.ts | 1 + frontend/src/locale/lang/en.json | 6 ++ frontend/src/locale/src/en.json | 18 ++++++ .../pages/Nginx/DeadHosts/TableWrapper.tsx | 29 ++++++---- frontend/src/pages/Users/Empty.tsx | 22 ++++++-- frontend/src/pages/Users/Table.tsx | 39 +++++++++++-- frontend/src/pages/Users/TableWrapper.tsx | 55 ++++++++++++++----- 14 files changed, 163 insertions(+), 38 deletions(-) create mode 100644 frontend/src/api/backend/toggleUser.ts create mode 100644 frontend/src/components/Table/Formatter/EnabledFormatter.tsx diff --git a/backend/internal/user.js b/backend/internal/user.js index 5ae0c373..d13931d5 100644 --- a/backend/internal/user.js +++ b/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; diff --git a/frontend/src/api/backend/index.ts b/frontend/src/api/backend/index.ts index 65210cb8..9fb6b13b 100644 --- a/frontend/src/api/backend/index.ts +++ b/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"; diff --git a/frontend/src/api/backend/toggleUser.ts b/frontend/src/api/backend/toggleUser.ts new file mode 100644 index 00000000..d2a24c94 --- /dev/null +++ b/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 { + await updateUser({ + id, + isDisabled: !enabled, + } as User); + return true; +} diff --git a/frontend/src/components/Table/Formatter/EnabledFormatter.tsx b/frontend/src/components/Table/Formatter/EnabledFormatter.tsx new file mode 100644 index 00000000..ccd60489 --- /dev/null +++ b/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 {intl.formatMessage({ id: "enabled" })}; + } + return {intl.formatMessage({ id: "disabled" })}; +} diff --git a/frontend/src/components/Table/Formatter/EventFormatter.tsx b/frontend/src/components/Table/Formatter/EventFormatter.tsx index b248dbfb..61655ac7 100644 --- a/frontend/src/components/Table/Formatter/EventFormatter.tsx +++ b/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 = ; break; + case "dead-host": + ico = ; + break; } return ico; diff --git a/frontend/src/components/Table/Formatter/index.ts b/frontend/src/components/Table/Formatter/index.ts index 33f447b2..1b423179 100644 --- a/frontend/src/components/Table/Formatter/index.ts +++ b/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"; diff --git a/frontend/src/hooks/useDeadHost.ts b/frontend/src/hooks/useDeadHost.ts index 87615c75..44cbd20f 100644 --- a/frontend/src/hooks/useDeadHost.ts +++ b/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"] }); }, }); }; diff --git a/frontend/src/hooks/useUser.ts b/frontend/src/hooks/useUser.ts index 92e0f63a..4f399490 100644 --- a/frontend/src/hooks/useUser.ts +++ b/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"] }); }, }); }; diff --git a/frontend/src/locale/lang/en.json b/frontend/src/locale/lang/en.json index 6e785f08..f3d7198e 100644 --- a/frontend/src/locale/lang/en.json +++ b/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" } \ No newline at end of file diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index be7f2a17..ceddda30 100644 --- a/frontend/src/locale/src/en.json +++ b/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" } diff --git a/frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx b/frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx index 3f0b1cde..3c4688d0 100644 --- a/frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx +++ b/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() {
-
- - - - setSearch(e.target.value.toLowerCase())} - /> -
+ {data?.length ? ( +
+ + + + setSearch(e.target.value.toLowerCase().trim())} + /> +
+ ) : null} diff --git a/frontend/src/pages/Users/Empty.tsx b/frontend/src/pages/Users/Empty.tsx index f17b1f66..3346a6c5 100644 --- a/frontend/src/pages/Users/Empty.tsx +++ b/frontend/src/pages/Users/Empty.tsx @@ -5,17 +5,27 @@ import { intl } from "src/locale"; interface Props { tableInstance: ReactTable; onNewUser?: () => void; + isFiltered?: boolean; } -export default function Empty({ tableInstance, onNewUser }: Props) { +export default function Empty({ tableInstance, onNewUser, isFiltered }: Props) { + if (isFiltered) { + } + return (
-

{intl.formatMessage({ id: "proxy-hosts.empty" })}

-

{intl.formatMessage({ id: "empty-subtitle" })}

- + {isFiltered ? ( +

{intl.formatMessage({ id: "empty-search" })}

+ ) : ( + <> +

{intl.formatMessage({ id: "users.empty" })}

+

{intl.formatMessage({ id: "empty-subtitle" })}

+ + + )}
diff --git a/frontend/src/pages/Users/Table.tsx b/frontend/src/pages/Users/Table.tsx index 21265615..b76d21f0 100644 --- a/frontend/src/pages/Users/Table.tsx +++ b/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(); @@ -62,7 +72,6 @@ export default function Table({ return ; }, }), - // 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 ; }, }), + columnHelper.accessor((row: any) => row.isDisabled, { + id: "isDisabled", + header: intl.formatMessage({ id: "column.status" }), + cell: (info: any) => { + return ; + }, + }), columnHelper.display({ id: "id", // todo: not needed for a display? cell: (info: any) => { @@ -127,6 +143,19 @@ export default function Table({ {intl.formatMessage({ id: "user.set-password" })} + { + e.preventDefault(); + onDisableToggle?.(info.row.original.id, info.row.original.isDisabled); + }} + > + + {intl.formatMessage({ + id: info.row.original.isDisabled ? "action.enable" : "action.disable", + })} +
({ @@ -167,7 +196,7 @@ export default function Table({ return ( } + emptyState={} /> ); } diff --git a/frontend/src/pages/Users/TableWrapper.tsx b/frontend/src/pages/Users/TableWrapper.tsx index b19902bf..0e08cd9c 100644 --- a/frontend/src/pages/Users/TableWrapper.tsx +++ b/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 (
@@ -41,17 +65,20 @@ export default function TableWrapper() {
-
- - - - -
+ {data?.length ? ( +
+ + + + setSearch(e.target.value.toLowerCase().trim())} + /> +
+ ) : null} @@ -60,13 +87,15 @@ export default function TableWrapper() {
setEditUserId(id)} onEditPermissions={(id: number) => setEditUserPermissionsId(id)} onSetPassword={(id: number) => setEditUserPasswordId(id)} onDeleteUser={(id: number) => setDeleteUserId(id)} + onDisableToggle={handleDisableToggle} onNewUser={() => setEditUserId("new")} /> {editUserId ? setEditUserId(0)} /> : null}