User table polishing, user delete modal

This commit is contained in:
Jamie Curnow
2025-09-03 19:13:00 +10:00
parent 5a01da2916
commit 432afe73ad
14 changed files with 197 additions and 43 deletions

View File

@@ -13,7 +13,7 @@ import { global as logger } from "../logger.js";
const ALGO = "RS256"; const ALGO = "RS256";
export default () => { export default () => {
let token_data = {}; let tokenData = {};
const self = { const self = {
/** /**
@@ -37,7 +37,7 @@ export default () => {
if (err) { if (err) {
reject(err); reject(err);
} else { } else {
token_data = payload; tokenData = payload;
resolve({ resolve({
token: token, token: token,
payload: payload, payload: payload,
@@ -72,18 +72,18 @@ export default () => {
reject(err); reject(err);
} }
} else { } else {
token_data = result; tokenData = result;
// Hack: some tokens out in the wild have a scope of 'all' instead of 'user'. // Hack: some tokens out in the wild have a scope of 'all' instead of 'user'.
// For 30 days at least, we need to replace 'all' with user. // For 30 days at least, we need to replace 'all' with user.
if ( if (
typeof token_data.scope !== "undefined" && typeof tokenData.scope !== "undefined" &&
_.indexOf(token_data.scope, "all") !== -1 _.indexOf(tokenData.scope, "all") !== -1
) { ) {
token_data.scope = ["user"]; tokenData.scope = ["user"];
} }
resolve(token_data); resolve(tokenData);
} }
}, },
); );
@@ -100,15 +100,15 @@ export default () => {
* @param {String} scope * @param {String} scope
* @returns {Boolean} * @returns {Boolean}
*/ */
hasScope: (scope) => typeof token_data.scope !== "undefined" && _.indexOf(token_data.scope, scope) !== -1, hasScope: (scope) => typeof tokenData.scope !== "undefined" && _.indexOf(tokenData.scope, scope) !== -1,
/** /**
* @param {String} key * @param {String} key
* @return {*} * @return {*}
*/ */
get: (key) => { get: (key) => {
if (typeof token_data[key] !== "undefined") { if (typeof tokenData[key] !== "undefined") {
return token_data[key]; return tokenData[key];
} }
return null; return null;
@@ -119,7 +119,7 @@ export default () => {
* @param {*} value * @param {*} value
*/ */
set: (key, value) => { set: (key, value) => {
token_data[key] = value; tokenData[key] = value;
}, },
/** /**

View File

@@ -66,7 +66,9 @@ export function SiteHeader() {
<div className="d-none d-xl-block ps-2"> <div className="d-none d-xl-block ps-2">
<div>{currentUser?.nickname}</div> <div>{currentUser?.nickname}</div>
<div className="mt-1 small text-secondary"> <div className="mt-1 small text-secondary">
{intl.formatMessage({ id: isAdmin ? "administrator" : "standard-user" })} {intl.formatMessage({
id: isAdmin ? "role.admin" : "role.standard-user",
})}
</div> </div>
</div> </div>
</a> </a>

View File

@@ -10,9 +10,9 @@ export function DomainsFormatter({ domains, createdOn }: Props) {
<div className="flex-fill"> <div className="flex-fill">
<div className="font-weight-medium"> <div className="font-weight-medium">
{domains.map((domain: string) => ( {domains.map((domain: string) => (
<span key={domain} className="badge badge-lg domain-name"> <a key={domain} href={`http://${domain}`} className="badge bg-yellow-lt domain-name">
{domain} {domain}
</span> </a>
))} ))}
</div> </div>
{createdOn ? ( {createdOn ? (

View File

@@ -0,0 +1,10 @@
interface Props {
email: string;
}
export function EmailFormatter({ email }: Props) {
return (
<a href={`mailto:${email}`} className="badge bg-yellow-lt">
{email}
</a>
);
}

View File

@@ -0,0 +1,20 @@
import { intl } from "src/locale";
interface Props {
roles: string[];
}
export function RolesFormatter({ roles }: Props) {
const r = roles || [];
if (r.length === 0) {
r[0] = "standard-user";
}
return (
<>
{r.map((role: string) => (
<span key={role} className="badge bg-yellow-lt me-1">
{intl.formatMessage({ id: `role.${role}` })}
</span>
))}
</>
);
}

View File

@@ -4,16 +4,19 @@ import { intl } from "src/locale";
interface Props { interface Props {
value: string; value: string;
createdOn?: string; createdOn?: string;
disabled?: boolean;
} }
export function ValueWithDateFormatter({ value, createdOn }: Props) { export function ValueWithDateFormatter({ value, createdOn, disabled }: Props) {
return ( return (
<div className="flex-fill"> <div className="flex-fill">
<div className="font-weight-medium"> <div className="font-weight-medium">
<div className="font-weight-medium">{value}</div> <div className={`font-weight-medium ${disabled ? "text-red" : ""}`}>{value}</div>
</div> </div>
{createdOn ? ( {createdOn ? (
<div className="text-secondary mt-1"> <div className={`text-secondary mt-1 ${disabled ? "text-red" : ""}`}>
{intl.formatMessage({ id: "created-on" }, { date: intlFormat(parseISO(createdOn)) })} {disabled
? intl.formatMessage({ id: "disabled" })
: intl.formatMessage({ id: "created-on" }, { date: intlFormat(parseISO(createdOn)) })}
</div> </div>
) : null} ) : null}
</div> </div>

View File

@@ -1,5 +1,7 @@
export * from "./CertificateFormatter"; export * from "./CertificateFormatter";
export * from "./DomainsFormatter"; export * from "./DomainsFormatter";
export * from "./EmailFormatter";
export * from "./GravatarFormatter"; export * from "./GravatarFormatter";
export * from "./RolesFormatter";
export * from "./StatusFormatter"; export * from "./StatusFormatter";
export * from "./ValueWithDateFormatter"; export * from "./ValueWithDateFormatter";

View File

@@ -12,7 +12,6 @@
"action.edit": "Edit", "action.edit": "Edit",
"action.enable": "Enable", "action.enable": "Enable",
"action.permissions": "Permissions", "action.permissions": "Permissions",
"administrator": "Administrator",
"auditlog.title": "Audit Log", "auditlog.title": "Audit Log",
"cancel": "Cancel", "cancel": "Cancel",
"certificates.title": "SSL Certificates", "certificates.title": "SSL Certificates",
@@ -37,6 +36,7 @@
"dead-hosts.count": "{count} 404 Hosts", "dead-hosts.count": "{count} 404 Hosts",
"dead-hosts.empty": "There are no 404 Hosts", "dead-hosts.empty": "There are no 404 Hosts",
"dead-hosts.title": "404 Hosts", "dead-hosts.title": "404 Hosts",
"disabled": "Disabled",
"email-address": "Email address", "email-address": "Email address",
"empty-subtitle": "Why don't you create one?", "empty-subtitle": "Why don't you create one?",
"error.invalid-auth": "Invalid email or password", "error.invalid-auth": "Invalid email or password",
@@ -53,6 +53,7 @@
"notfound.title": "Oops… You just found an error page", "notfound.title": "Oops… You just found an error page",
"notification.error": "Error", "notification.error": "Error",
"notification.success": "Success", "notification.success": "Success",
"notification.user-deleted": "User has been deleted",
"notification.user-saved": "User has been saved", "notification.user-saved": "User has been saved",
"offline": "Offline", "offline": "Offline",
"online": "Online", "online": "Online",
@@ -67,10 +68,11 @@
"redirection-hosts.count": "{count} Redirection Hosts", "redirection-hosts.count": "{count} Redirection Hosts",
"redirection-hosts.empty": "There are no Redirection Hosts", "redirection-hosts.empty": "There are no Redirection Hosts",
"redirection-hosts.title": "Redirection Hosts", "redirection-hosts.title": "Redirection Hosts",
"role.admin": "Administrator",
"role.standard-user": "Standard User",
"save": "Save", "save": "Save",
"settings.title": "Settings", "settings.title": "Settings",
"sign-in": "Sign in", "sign-in": "Sign in",
"standard-user": "Apache Helicopter",
"streams.actions-title": "Stream #{id}", "streams.actions-title": "Stream #{id}",
"streams.add": "Add Stream", "streams.add": "Add Stream",
"streams.count": "{count} Streams", "streams.count": "{count} Streams",
@@ -81,8 +83,11 @@
"user.change-password": "Change Password", "user.change-password": "Change Password",
"user.confirm-password": "Confirm Password", "user.confirm-password": "Confirm Password",
"user.current-password": "Current Password", "user.current-password": "Current Password",
"user.delete.content": "Are you sure you want to delete this user?",
"user.delete.title": "Delete User",
"user.edit": "Edit User", "user.edit": "Edit User",
"user.edit-profile": "Edit Profile", "user.edit-profile": "Edit Profile",
"user.flags.title": "Properties",
"user.full-name": "Full Name", "user.full-name": "Full Name",
"user.logout": "Logout", "user.logout": "Logout",
"user.new": "New User", "user.new": "New User",

View File

@@ -38,9 +38,6 @@
"action.permissions": { "action.permissions": {
"defaultMessage": "Permissions" "defaultMessage": "Permissions"
}, },
"administrator": {
"defaultMessage": "Administrator"
},
"auditlog.title": { "auditlog.title": {
"defaultMessage": "Audit Log" "defaultMessage": "Audit Log"
}, },
@@ -113,6 +110,9 @@
"dead-hosts.title": { "dead-hosts.title": {
"defaultMessage": "404 Hosts" "defaultMessage": "404 Hosts"
}, },
"disabled": {
"defaultMessage": "Disabled"
},
"email-address": { "email-address": {
"defaultMessage": "Email address" "defaultMessage": "Email address"
}, },
@@ -158,6 +158,9 @@
"notification.error": { "notification.error": {
"defaultMessage": "Error" "defaultMessage": "Error"
}, },
"notification.user-deleted": {
"defaultMessage": "User has been deleted"
},
"notification.user-saved": { "notification.user-saved": {
"defaultMessage": "User has been saved" "defaultMessage": "User has been saved"
}, },
@@ -203,6 +206,12 @@
"redirection-hosts.title": { "redirection-hosts.title": {
"defaultMessage": "Redirection Hosts" "defaultMessage": "Redirection Hosts"
}, },
"role.admin": {
"defaultMessage": "Administrator"
},
"role.standard-user": {
"defaultMessage": "Standard User"
},
"save": { "save": {
"defaultMessage": "Save" "defaultMessage": "Save"
}, },
@@ -212,9 +221,6 @@
"sign-in": { "sign-in": {
"defaultMessage": "Sign in" "defaultMessage": "Sign in"
}, },
"standard-user": {
"defaultMessage": "Apache Helicopter"
},
"streams.actions-title": { "streams.actions-title": {
"defaultMessage": "Stream #{id}" "defaultMessage": "Stream #{id}"
}, },
@@ -245,12 +251,21 @@
"user.current-password": { "user.current-password": {
"defaultMessage": "Current Password" "defaultMessage": "Current Password"
}, },
"user.delete.title": {
"defaultMessage": "Delete User"
},
"user.delete.content": {
"defaultMessage": "Are you sure you want to delete this user?"
},
"user.edit": { "user.edit": {
"defaultMessage": "Edit User" "defaultMessage": "Edit User"
}, },
"user.edit-profile": { "user.edit-profile": {
"defaultMessage": "Edit Profile" "defaultMessage": "Edit Profile"
}, },
"user.flags.title": {
"defaultMessage": "Properties"
},
"user.full-name": { "user.full-name": {
"defaultMessage": "Full Name" "defaultMessage": "Full Name"
}, },

View File

@@ -0,0 +1,65 @@
import { useQueryClient } from "@tanstack/react-query";
import { type ReactNode, useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import { Button } from "src/components";
import { intl } from "src/locale";
interface Props {
title: string;
children: ReactNode;
onConfirm: () => Promise<void> | void;
onClose: () => void;
invalidations?: any[];
}
export function DeleteConfirmModal({ title, children, onConfirm, onClose, invalidations }: Props) {
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const onSubmit = async () => {
setSubmitting(true);
setError(null);
try {
await onConfirm();
onClose();
// invalidate caches as requested
invalidations?.forEach((inv) => {
queryClient.invalidateQueries({ queryKey: inv });
});
} catch (err: any) {
setError(intl.formatMessage({ id: err.message }));
}
setSubmitting(false);
};
return (
<Modal show onHide={onClose} animation={false}>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Alert variant="danger" show={!!error} onClose={() => setError(null)} dismissible>
{error}
</Alert>
{children}
</Modal.Body>
<Modal.Footer>
<Button data-bs-dismiss="modal" onClick={onClose} disabled={submitting}>
{intl.formatMessage({ id: "cancel" })}
</Button>
<Button
type="submit"
actionType="primary"
className="ms-auto btn-red"
data-bs-dismiss="modal"
isLoading={submitting}
disabled={submitting}
onClick={onSubmit}
>
{intl.formatMessage({ id: "action.delete" })}
</Button>
</Modal.Footer>
</Modal>
);
}

View File

@@ -18,11 +18,6 @@ export function UserModal({ userId, onClose }: Props) {
const { mutate: setUser } = useSetUser(); const { mutate: setUser } = useSetUser();
const [errorMsg, setErrorMsg] = useState<string | null>(null); const [errorMsg, setErrorMsg] = useState<string | null>(null);
if (data && currentUser) {
console.log("DATA:", data);
console.log("CURRENT:", currentUser);
}
const onSubmit = async (values: any, { setSubmitting }: any) => { const onSubmit = async (values: any, { setSubmitting }: any) => {
setErrorMsg(null); setErrorMsg(null);
const { ...payload } = { const { ...payload } = {
@@ -161,12 +156,13 @@ export function UserModal({ userId, onClose }: Props) {
</div> </div>
{currentUser && data && currentUser?.id !== data?.id ? ( {currentUser && data && currentUser?.id !== data?.id ? (
<div className="my-3"> <div className="my-3">
<h3 className="py-2">Properties</h3> <h3 className="py-2">{intl.formatMessage({ id: "user.flags.title" })}</h3>
<div className="divide-y"> <div className="divide-y">
<div> <div>
<label className="row" htmlFor="isAdmin"> <label className="row" htmlFor="isAdmin">
<span className="col">Administrator</span> <span className="col">
{intl.formatMessage({ id: "role.admin" })}
</span>
<span className="col-auto"> <span className="col-auto">
<Field name="isAdmin" type="checkbox"> <Field name="isAdmin" type="checkbox">
{({ field }: any) => ( {({ field }: any) => (
@@ -185,7 +181,9 @@ export function UserModal({ userId, onClose }: Props) {
</div> </div>
<div> <div>
<label className="row" htmlFor="isDisabled"> <label className="row" htmlFor="isDisabled">
<span className="col">Disabled</span> <span className="col">
{intl.formatMessage({ id: "disabled" })}
</span>
<span className="col-auto"> <span className="col-auto">
<Field name="isDisabled" type="checkbox"> <Field name="isDisabled" type="checkbox">
{({ field }: any) => ( {({ field }: any) => (

View File

@@ -1,2 +1,3 @@
export * from "./ChangePasswordModal"; export * from "./ChangePasswordModal";
export * from "./DeleteConfirmModal";
export * from "./UserModal"; export * from "./UserModal";

View File

@@ -2,7 +2,7 @@ import { IconDotsVertical, IconEdit, IconLock, IconShield, IconTrash } from "@ta
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useMemo } from "react"; import { useMemo } from "react";
import type { User } from "src/api/backend"; import type { User } from "src/api/backend";
import { GravatarFormatter, ValueWithDateFormatter } from "src/components"; import { EmailFormatter, GravatarFormatter, RolesFormatter, ValueWithDateFormatter } from "src/components";
import { TableLayout } from "src/components/Table/TableLayout"; import { TableLayout } from "src/components/Table/TableLayout";
import { intl } from "src/locale"; import { intl } from "src/locale";
import Empty from "./Empty"; import Empty from "./Empty";
@@ -12,9 +12,10 @@ interface Props {
isFetching?: boolean; isFetching?: boolean;
currentUserId?: number; currentUserId?: number;
onEditUser?: (id: number) => void; onEditUser?: (id: number) => void;
onDeleteUser?: (id: number) => void;
onNewUser?: () => void; onNewUser?: () => void;
} }
export default function Table({ data, isFetching, currentUserId, onEditUser, onNewUser }: Props) { export default function Table({ data, isFetching, currentUserId, onEditUser, onDeleteUser, onNewUser }: Props) {
const columnHelper = createColumnHelper<User>(); const columnHelper = createColumnHelper<User>();
const columns = useMemo( const columns = useMemo(
() => [ () => [
@@ -34,14 +35,20 @@ export default function Table({ data, isFetching, currentUserId, onEditUser, onN
cell: (info: any) => { cell: (info: any) => {
const value = info.getValue(); const value = info.getValue();
// Hack to reuse domains formatter // Hack to reuse domains formatter
return <ValueWithDateFormatter value={value.name} createdOn={value.createdOn} />; return (
<ValueWithDateFormatter
value={value.name}
createdOn={value.createdOn}
disabled={value.isDisabled}
/>
);
}, },
}), }),
columnHelper.accessor((row: any) => row.email, { columnHelper.accessor((row: any) => row.email, {
id: "email", id: "email",
header: intl.formatMessage({ id: "column.email" }), header: intl.formatMessage({ id: "column.email" }),
cell: (info: any) => { cell: (info: any) => {
return info.getValue(); return <EmailFormatter email={info.getValue()} />;
}, },
}), }),
// TODO: formatter for roles // TODO: formatter for roles
@@ -49,7 +56,7 @@ export default function Table({ data, isFetching, currentUserId, onEditUser, onN
id: "roles", id: "roles",
header: intl.formatMessage({ id: "column.roles" }), header: intl.formatMessage({ id: "column.roles" }),
cell: (info: any) => { cell: (info: any) => {
return JSON.stringify(info.getValue()); return <RolesFormatter roles={info.getValue()} />;
}, },
}), }),
columnHelper.display({ columnHelper.display({
@@ -96,7 +103,14 @@ export default function Table({ data, isFetching, currentUserId, onEditUser, onN
{currentUserId !== info.row.original.id ? ( {currentUserId !== info.row.original.id ? (
<> <>
<div className="dropdown-divider" /> <div className="dropdown-divider" />
<a className="dropdown-item" href="#"> <a
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onDeleteUser?.(info.row.original.id);
}}
>
<IconTrash size={16} /> <IconTrash size={16} />
{intl.formatMessage({ id: "action.delete" })} {intl.formatMessage({ id: "action.delete" })}
</a> </a>
@@ -111,7 +125,7 @@ export default function Table({ data, isFetching, currentUserId, onEditUser, onN
}, },
}), }),
], ],
[columnHelper, currentUserId, onEditUser], [columnHelper, currentUserId, onEditUser, onDeleteUser],
); );
const tableInstance = useReactTable<User>({ const tableInstance = useReactTable<User>({

View File

@@ -1,14 +1,17 @@
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import { useState } from "react"; import { useState } from "react";
import Alert from "react-bootstrap/Alert"; import Alert from "react-bootstrap/Alert";
import { deleteUser } from "src/api/backend";
import { Button, LoadingPage } from "src/components"; import { Button, LoadingPage } from "src/components";
import { useUser, useUsers } from "src/hooks"; import { useUser, useUsers } from "src/hooks";
import { intl } from "src/locale"; import { intl } from "src/locale";
import { UserModal } from "src/modals"; import { DeleteConfirmModal, UserModal } from "src/modals";
import { showSuccess } from "src/notifications";
import Table from "./Table"; import Table from "./Table";
export default function TableWrapper() { export default function TableWrapper() {
const [editUserId, setEditUserId] = useState(0 as number | "new"); const [editUserId, setEditUserId] = useState(0 as number | "new");
const [deleteUserId, setDeleteUserId] = useState(0);
const { isFetching, isLoading, isError, error, data } = useUsers(["permissions"]); const { isFetching, isLoading, isError, error, data } = useUsers(["permissions"]);
const { data: currentUser } = useUser("me"); const { data: currentUser } = useUser("me");
@@ -20,6 +23,11 @@ export default function TableWrapper() {
return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>; return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
} }
const handleDelete = async () => {
await deleteUser(deleteUserId);
showSuccess(intl.formatMessage({ id: "notification.user-deleted" }));
};
return ( return (
<div className="card mt-4"> <div className="card mt-4">
<div className="card-status-top bg-orange" /> <div className="card-status-top bg-orange" />
@@ -54,9 +62,20 @@ export default function TableWrapper() {
isFetching={isFetching} isFetching={isFetching}
currentUserId={currentUser?.id} currentUserId={currentUser?.id}
onEditUser={(id: number) => setEditUserId(id)} onEditUser={(id: number) => setEditUserId(id)}
onDeleteUser={(id: number) => setDeleteUserId(id)}
onNewUser={() => setEditUserId("new")} onNewUser={() => setEditUserId("new")}
/> />
{editUserId ? <UserModal userId={editUserId} onClose={() => setEditUserId(0)} /> : null} {editUserId ? <UserModal userId={editUserId} onClose={() => setEditUserId(0)} /> : null}
{deleteUserId ? (
<DeleteConfirmModal
title={intl.formatMessage({ id: "user.delete.title" })}
onConfirm={handleDelete}
onClose={() => setDeleteUserId(0)}
invalidations={[["users"], ["user", deleteUserId]]}
>
{intl.formatMessage({ id: "user.delete.content" })}
</DeleteConfirmModal>
) : null}
</div> </div>
</div> </div>
); );