فهرست منبع

User table polishing, user delete modal

Jamie Curnow 3 ماه پیش
والد
کامیت
6ab7198e61

+ 11 - 11
backend/models/token.js

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

+ 3 - 1
frontend/src/components/SiteHeader.tsx

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

+ 2 - 2
frontend/src/components/Table/Formatter/DomainsFormatter.tsx

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

+ 10 - 0
frontend/src/components/Table/Formatter/EmailFormatter.tsx

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

+ 20 - 0
frontend/src/components/Table/Formatter/RolesFormatter.tsx

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

+ 7 - 4
frontend/src/components/Table/Formatter/ValueWithDateFormatter.tsx

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

+ 2 - 0
frontend/src/components/Table/Formatter/index.ts

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

+ 7 - 2
frontend/src/locale/lang/en.json

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

+ 21 - 6
frontend/src/locale/src/en.json

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

+ 65 - 0
frontend/src/modals/DeleteConfirmModal.tsx

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

+ 7 - 9
frontend/src/modals/UserModal.tsx

@@ -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) => (

+ 1 - 0
frontend/src/modals/index.ts

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

+ 21 - 7
frontend/src/pages/Users/Table.tsx

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

+ 20 - 1
frontend/src/pages/Users/TableWrapper.tsx

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