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";
export default () => {
let token_data = {};
let tokenData = {};
const self = {
/**
@@ -37,7 +37,7 @@ export default () => {
if (err) {
reject(err);
} else {
token_data = payload;
tokenData = payload;
resolve({
token: token,
payload: payload,
@@ -72,18 +72,18 @@ export default () => {
reject(err);
}
} else {
token_data = result;
tokenData = result;
// 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.
if (
typeof token_data.scope !== "undefined" &&
_.indexOf(token_data.scope, "all") !== -1
typeof tokenData.scope !== "undefined" &&
_.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
* @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
* @return {*}
*/
get: (key) => {
if (typeof token_data[key] !== "undefined") {
return token_data[key];
if (typeof tokenData[key] !== "undefined") {
return tokenData[key];
}
return null;
@@ -119,7 +119,7 @@ export default () => {
* @param {*} 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>{currentUser?.nickname}</div>
<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>
</a>

View File

@@ -10,9 +10,9 @@ export function DomainsFormatter({ domains, createdOn }: Props) {
<div className="flex-fill">
<div className="font-weight-medium">
{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}
</span>
</a>
))}
</div>
{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 {
value: string;
createdOn?: string;
disabled?: boolean;
}
export function ValueWithDateFormatter({ value, createdOn }: Props) {
export function ValueWithDateFormatter({ value, createdOn, disabled }: Props) {
return (
<div className="flex-fill">
<div className="font-weight-medium">
<div className="font-weight-medium">{value}</div>
<div className={`font-weight-medium ${disabled ? "text-red" : ""}`}>{value}</div>
</div>
{createdOn ? (
<div className="text-secondary mt-1">
{intl.formatMessage({ id: "created-on" }, { date: intlFormat(parseISO(createdOn)) })}
<div className={`text-secondary mt-1 ${disabled ? "text-red" : ""}`}>
{disabled
? intl.formatMessage({ id: "disabled" })
: intl.formatMessage({ id: "created-on" }, { date: intlFormat(parseISO(createdOn)) })}
</div>
) : null}
</div>

View File

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

View File

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

View File

@@ -38,9 +38,6 @@
"action.permissions": {
"defaultMessage": "Permissions"
},
"administrator": {
"defaultMessage": "Administrator"
},
"auditlog.title": {
"defaultMessage": "Audit Log"
},
@@ -113,6 +110,9 @@
"dead-hosts.title": {
"defaultMessage": "404 Hosts"
},
"disabled": {
"defaultMessage": "Disabled"
},
"email-address": {
"defaultMessage": "Email address"
},
@@ -158,6 +158,9 @@
"notification.error": {
"defaultMessage": "Error"
},
"notification.user-deleted": {
"defaultMessage": "User has been deleted"
},
"notification.user-saved": {
"defaultMessage": "User has been saved"
},
@@ -203,6 +206,12 @@
"redirection-hosts.title": {
"defaultMessage": "Redirection Hosts"
},
"role.admin": {
"defaultMessage": "Administrator"
},
"role.standard-user": {
"defaultMessage": "Standard User"
},
"save": {
"defaultMessage": "Save"
},
@@ -212,9 +221,6 @@
"sign-in": {
"defaultMessage": "Sign in"
},
"standard-user": {
"defaultMessage": "Apache Helicopter"
},
"streams.actions-title": {
"defaultMessage": "Stream #{id}"
},
@@ -245,12 +251,21 @@
"user.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": {
"defaultMessage": "Edit User"
},
"user.edit-profile": {
"defaultMessage": "Edit Profile"
},
"user.flags.title": {
"defaultMessage": "Properties"
},
"user.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 [errorMsg, setErrorMsg] = useState<string | null>(null);
if (data && currentUser) {
console.log("DATA:", data);
console.log("CURRENT:", currentUser);
}
const onSubmit = async (values: any, { setSubmitting }: any) => {
setErrorMsg(null);
const { ...payload } = {
@@ -161,12 +156,13 @@ export function UserModal({ userId, onClose }: Props) {
</div>
{currentUser && data && currentUser?.id !== data?.id ? (
<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>
<label className="row" htmlFor="isAdmin">
<span className="col">Administrator</span>
<span className="col">
{intl.formatMessage({ id: "role.admin" })}
</span>
<span className="col-auto">
<Field name="isAdmin" type="checkbox">
{({ field }: any) => (
@@ -185,7 +181,9 @@ export function UserModal({ userId, onClose }: Props) {
</div>
<div>
<label className="row" htmlFor="isDisabled">
<span className="col">Disabled</span>
<span className="col">
{intl.formatMessage({ id: "disabled" })}
</span>
<span className="col-auto">
<Field name="isDisabled" type="checkbox">
{({ field }: any) => (

View File

@@ -1,2 +1,3 @@
export * from "./ChangePasswordModal";
export * from "./DeleteConfirmModal";
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 { useMemo } from "react";
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 { intl } from "src/locale";
import Empty from "./Empty";
@@ -12,9 +12,10 @@ interface Props {
isFetching?: boolean;
currentUserId?: number;
onEditUser?: (id: number) => void;
onDeleteUser?: (id: number) => 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 columns = useMemo(
() => [
@@ -34,14 +35,20 @@ export default function Table({ data, isFetching, currentUserId, onEditUser, onN
cell: (info: any) => {
const value = info.getValue();
// 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, {
id: "email",
header: intl.formatMessage({ id: "column.email" }),
cell: (info: any) => {
return info.getValue();
return <EmailFormatter email={info.getValue()} />;
},
}),
// TODO: formatter for roles
@@ -49,7 +56,7 @@ export default function Table({ data, isFetching, currentUserId, onEditUser, onN
id: "roles",
header: intl.formatMessage({ id: "column.roles" }),
cell: (info: any) => {
return JSON.stringify(info.getValue());
return <RolesFormatter roles={info.getValue()} />;
},
}),
columnHelper.display({
@@ -96,7 +103,14 @@ export default function Table({ data, isFetching, currentUserId, onEditUser, onN
{currentUserId !== info.row.original.id ? (
<>
<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} />
{intl.formatMessage({ id: "action.delete" })}
</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>({

View File

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