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

Notification toasts, nicer loading, add new user support

Jamie Curnow пре 3 месеци
родитељ
комит
61a92906f3

+ 8 - 8
backend/internal/token.js

@@ -134,24 +134,24 @@ export default {
 	 * @param   {Object} user
 	 * @returns {Promise}
 	 */
-	getTokenFromUser: (user) => {
+	getTokenFromUser: async (user) => {
 		const expire = "1d";
 		const Token = new TokenModel();
 		const expiry = parseDatePeriod(expire);
 
-		return Token.create({
+		const signed = await Token.create({
 			iss: "api",
 			attrs: {
 				id: user.id,
 			},
 			scope: ["user"],
 			expiresIn: expire,
-		}).then((signed) => {
-			return {
-				token: signed.token,
-				expires: expiry.toISOString(),
-				user: user,
-			};
 		});
+
+		return {
+			token: signed.token,
+			expires: expiry.toISOString(),
+			user: user,
+		};
 	},
 };

+ 3 - 3
backend/internal/user.js

@@ -337,11 +337,11 @@ const internalUser = {
 	 * @param   {Integer} [id_requested]
 	 * @returns {[String]}
 	 */
-	getUserOmisionsByAccess: (access, id_requested) => {
+	getUserOmisionsByAccess: (access, idRequested) => {
 		let response = []; // Admin response
 
-		if (!access.token.hasScope("admin") && access.token.getUserId(0) !== id_requested) {
-			response = ["roles", "is_deleted"]; // Restricted response
+		if (!access.token.hasScope("admin") && access.token.getUserId(0) !== idRequested) {
+			response = ["is_deleted"]; // Restricted response
 		}
 
 		return response;

+ 3 - 3
backend/models/token.js

@@ -123,16 +123,16 @@ export default () => {
 		},
 
 		/**
-		 * @param   [default_value]
+		 * @param   [defaultValue]
 		 * @returns {Integer}
 		 */
-		getUserId: (default_value) => {
+		getUserId: (defaultValue) => {
 			const attrs = self.get("attrs");
 			if (attrs && typeof attrs.id !== "undefined" && attrs.id) {
 				return attrs.id;
 			}
 
-			return default_value || 0;
+			return defaultValue || 0;
 		},
 	};
 

+ 7 - 0
frontend/src/App.css

@@ -5,3 +5,10 @@
 .domain-name {
 	font-family: monospace;
 }
+
+.mr-1 {
+	margin-right: 0.25rem;
+}
+.ml-1 {
+	margin-left: 0.25rem;
+}

+ 10 - 0
frontend/src/App.tsx

@@ -1,6 +1,7 @@
 import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
 import { RawIntlProvider } from "react-intl";
+import { ToastContainer } from "react-toastify";
 import { AuthProvider, LocaleProvider, ThemeProvider } from "src/context";
 import { intl } from "src/locale";
 import Router from "src/Router.tsx";
@@ -16,6 +17,15 @@ function App() {
 					<QueryClientProvider client={queryClient}>
 						<AuthProvider>
 							<Router />
+							<ToastContainer
+								position="top-right"
+								autoClose={5000}
+								hideProgressBar={true}
+								newestOnTop={true}
+								closeOnClick={true}
+								rtl={false}
+								closeButton={false}
+							/>
 						</AuthProvider>
 						<ReactQueryDevtools buttonPosition="bottom-right" position="right" />
 					</QueryClientProvider>

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

@@ -4,6 +4,7 @@ export * from "./createDeadHost";
 export * from "./createProxyHost";
 export * from "./createRedirectionHost";
 export * from "./createStream";
+export * from "./createUser";
 export * from "./deleteAccessList";
 export * from "./deleteCertificate";
 export * from "./deleteDeadHost";

+ 22 - 2
frontend/src/components/HasPermission.tsx

@@ -1,5 +1,6 @@
 import type { ReactNode } from "react";
 import Alert from "react-bootstrap/Alert";
+import { Loading, LoadingPage } from "src/components";
 import { useUser } from "src/hooks";
 import { intl } from "src/locale";
 
@@ -8,11 +9,30 @@ interface Props {
 	type: "manage" | "view";
 	hideError?: boolean;
 	children?: ReactNode;
+	pageLoading?: boolean;
+	loadingNoLogo?: boolean;
 }
-function HasPermission({ permission, type, children, hideError = false }: Props) {
-	const { data } = useUser("me");
+function HasPermission({
+	permission,
+	type,
+	children,
+	hideError = false,
+	pageLoading = false,
+	loadingNoLogo = false,
+}: Props) {
+	const { data, isLoading } = useUser("me");
 	const perms = data?.permissions;
 
+	if (isLoading) {
+		if (hideError) {
+			return null;
+		}
+		if (pageLoading) {
+			return <LoadingPage noLogo={loadingNoLogo} />;
+		}
+		return <Loading noLogo={loadingNoLogo} />;
+	}
+
 	let allowed = permission === "";
 	const acceptable = ["manage", type];
 

+ 0 - 0
frontend/src/components/LoadingPage.module.css → frontend/src/components/Loading.module.css


+ 22 - 0
frontend/src/components/Loading.tsx

@@ -0,0 +1,22 @@
+import { intl } from "src/locale";
+import styles from "./Loading.module.css";
+
+interface Props {
+	label?: string;
+	noLogo?: boolean;
+}
+export function Loading({ label, noLogo }: Props) {
+	return (
+		<div className="empty text-center">
+			{noLogo ? null : (
+				<div className="mb-3">
+					<img className={styles.logo} src="/images/logo-no-text.svg" alt="" />
+				</div>
+			)}
+			<div className="text-secondary mb-3">{label || intl.formatMessage({ id: "loading" })}</div>
+			<div className="progress progress-sm">
+				<div className="progress-bar progress-bar-indeterminate" />
+			</div>
+		</div>
+	);
+}

+ 2 - 14
frontend/src/components/LoadingPage.tsx

@@ -1,6 +1,4 @@
-import { Page } from "src/components";
-import { intl } from "src/locale";
-import styles from "./LoadingPage.module.css";
+import { Loading, Page } from "src/components";
 
 interface Props {
 	label?: string;
@@ -10,17 +8,7 @@ export function LoadingPage({ label, noLogo }: Props) {
 	return (
 		<Page className="page-center">
 			<div className="container-tight py-4">
-				<div className="empty text-center">
-					{noLogo ? null : (
-						<div className="mb-3">
-							<img className={styles.logo} src="/images/logo-no-text.svg" alt="" />
-						</div>
-					)}
-					<div className="text-secondary mb-3">{label || intl.formatMessage({ id: "loading" })}</div>
-					<div className="progress progress-sm">
-						<div className="progress-bar progress-bar-indeterminate" />
-					</div>
-				</div>
+				<Loading label={label} noLogo={noLogo} />
 			</div>
 		</Page>
 	);

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

@@ -2,6 +2,7 @@ export * from "./Button";
 export * from "./ErrorNotFound";
 export * from "./Flag";
 export * from "./HasPermission";
+export * from "./Loading";
 export * from "./LoadingPage";
 export * from "./LocalePicker";
 export * from "./NavLink";

+ 18 - 2
frontend/src/hooks/useUser.ts

@@ -1,7 +1,20 @@
 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { getUser, type User, updateUser } from "src/api/backend";
+import { createUser, getUser, type User, updateUser } from "src/api/backend";
 
 const fetchUser = (id: number | string) => {
+	if (id === "new") {
+		return Promise.resolve({
+			id: 0,
+			createdOn: "",
+			modifiedOn: "",
+			isDisabled: false,
+			email: "",
+			name: "",
+			nickname: "",
+			roles: [],
+			avatar: "",
+		} as User);
+	}
 	return getUser(id, { expand: "permissions" });
 };
 
@@ -17,8 +30,11 @@ const useUser = (id: string | number, options = {}) => {
 const useSetUser = () => {
 	const queryClient = useQueryClient();
 	return useMutation({
-		mutationFn: (values: User) => updateUser(values),
+		mutationFn: (values: User) => (values.id ? updateUser(values) : createUser(values)),
 		onMutate: (values: User) => {
+			if (!values.id) {
+				return;
+			}
 			const previousObject = queryClient.getQueryData(["user", values.id]);
 			queryClient.setQueryData(["user", values.id], (old: User) => ({
 				...old,

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

@@ -51,6 +51,9 @@
   "notfound.action": "Take me home",
   "notfound.text": "We are sorry but the page you are looking for was not found",
   "notfound.title": "Oops… You just found an error page",
+  "notification.error": "Error",
+  "notification.success": "Success",
+  "notification.user-saved": "User has been saved",
   "offline": "Offline",
   "online": "Online",
   "password": "Password",
@@ -82,6 +85,7 @@
   "user.edit-profile": "Edit Profile",
   "user.full-name": "Full Name",
   "user.logout": "Logout",
+  "user.new": "New User",
   "user.new-password": "New Password",
   "user.nickname": "Nickname",
   "user.switch-dark": "Switch to Dark mode",

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

@@ -155,6 +155,15 @@
 	"notfound.title": {
 		"defaultMessage": "Oops… You just found an error page"
 	},
+	"notification.error": {
+		"defaultMessage": "Error"
+	},
+	"notification.user-saved": {
+		"defaultMessage": "User has been saved"
+	},
+	"notification.success": {
+		"defaultMessage": "Success"
+	},
 	"offline": {
 		"defaultMessage": "Offline"
 	},
@@ -248,6 +257,9 @@
 	"user.logout": {
 		"defaultMessage": "Logout"
 	},
+	"user.new": {
+		"defaultMessage": "New User"
+	},
 	"user.new-password": {
 		"defaultMessage": "New Password"
 	},

+ 183 - 166
frontend/src/modals/UserModal.tsx

@@ -2,31 +2,35 @@ import { Field, Form, Formik } from "formik";
 import { useState } from "react";
 import { Alert } from "react-bootstrap";
 import Modal from "react-bootstrap/Modal";
-import { Button } from "src/components";
+import { Button, Loading } from "src/components";
 import { useSetUser, useUser } from "src/hooks";
 import { intl } from "src/locale";
 import { validateEmail, validateString } from "src/modules/Validations";
+import { showSuccess } from "src/notifications";
 
 interface Props {
-	userId: number | "me";
+	userId: number | "me" | "new";
 	onClose: () => void;
 }
 export function UserModal({ userId, onClose }: Props) {
-	const { data } = useUser(userId);
-	const { data: currentUser } = useUser("me");
+	const { data, isLoading, error } = useUser(userId);
+	const { data: currentUser, isLoading: currentIsLoading } = useUser("me");
 	const { mutate: setUser } = useSetUser();
-	const [error, setError] = 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) => {
-		setError(null);
+		setErrorMsg(null);
 		const { ...payload } = {
-			id: userId,
+			id: userId === "new" ? undefined : userId,
 			roles: [],
 			...values,
 		};
 
-		console.log("values", values);
-
 		if (data?.id === currentUser?.id) {
 			// Prevent user from locking themselves out
 			delete payload.isDisabled;
@@ -39,175 +43,188 @@ export function UserModal({ userId, onClose }: Props) {
 		delete payload.isAdmin;
 
 		setUser(payload, {
-			onError: (err: any) => setError(err.message),
-			onSuccess: () => onClose(),
+			onError: (err: any) => setErrorMsg(err.message),
+			onSuccess: () => {
+				showSuccess(intl.formatMessage({ id: "notification.user-saved" }));
+				onClose();
+			},
 			onSettled: () => setSubmitting(false),
 		});
 	};
 
 	return (
 		<Modal show onHide={onClose} animation={false}>
-			<Formik
-				initialValues={
-					{
-						name: data?.name,
-						nickname: data?.nickname,
-						email: data?.email,
-						isAdmin: data?.roles.includes("admin"),
-						isDisabled: data?.isDisabled,
-					} as any
-				}
-				onSubmit={onSubmit}
-			>
-				{({ isSubmitting }) => (
-					<Form>
-						<Modal.Header closeButton>
-							<Modal.Title>{intl.formatMessage({ id: "user.edit" })}</Modal.Title>
-						</Modal.Header>
-						<Modal.Body>
-							<Alert variant="danger" show={!!error} onClose={() => setError(null)} dismissible>
-								{error}
-							</Alert>
-							<div className="row">
-								<div className="col-lg-6">
-									<div className="mb-3">
-										<Field name="name" validate={validateString(1, 50)}>
-											{({ field, form }: any) => (
-												<div className="form-floating mb-3">
-													<input
-														id="name"
-														className={`form-control ${form.errors.name && form.touched.name ? "is-invalid" : ""}`}
-														placeholder={intl.formatMessage({ id: "user.full-name" })}
-														{...field}
-													/>
-													<label htmlFor="name">
-														{intl.formatMessage({ id: "user.full-name" })}
-													</label>
-													{form.errors.name ? (
-														<div className="invalid-feedback">
-															{form.errors.name && form.touched.name
-																? form.errors.name
-																: null}
-														</div>
-													) : null}
-												</div>
-											)}
-										</Field>
+			{!isLoading && error && <Alert variant="danger">{error?.message || "Unknown error"}</Alert>}
+			{(isLoading || currentIsLoading) && <Loading noLogo />}
+			{!isLoading && !currentIsLoading && data && currentUser && (
+				<Formik
+					initialValues={
+						{
+							name: data?.name,
+							nickname: data?.nickname,
+							email: data?.email,
+							isAdmin: data?.roles?.includes("admin"),
+							isDisabled: data?.isDisabled,
+						} as any
+					}
+					onSubmit={onSubmit}
+				>
+					{({ isSubmitting }) => (
+						<Form>
+							<Modal.Header closeButton>
+								<Modal.Title>
+									{intl.formatMessage({ id: data?.id ? "user.edit" : "user.new" })}
+								</Modal.Title>
+							</Modal.Header>
+							<Modal.Body>
+								<Alert variant="danger" show={!!errorMsg} onClose={() => setErrorMsg(null)} dismissible>
+									{errorMsg}
+								</Alert>
+								<div className="row">
+									<div className="col-lg-6">
+										<div className="mb-3">
+											<Field name="name" validate={validateString(1, 50)}>
+												{({ field, form }: any) => (
+													<div className="form-floating mb-3">
+														<input
+															id="name"
+															className={`form-control ${form.errors.name && form.touched.name ? "is-invalid" : ""}`}
+															placeholder={intl.formatMessage({ id: "user.full-name" })}
+															{...field}
+														/>
+														<label htmlFor="name">
+															{intl.formatMessage({ id: "user.full-name" })}
+														</label>
+														{form.errors.name ? (
+															<div className="invalid-feedback">
+																{form.errors.name && form.touched.name
+																	? form.errors.name
+																	: null}
+															</div>
+														) : null}
+													</div>
+												)}
+											</Field>
+										</div>
 									</div>
-								</div>
-								<div className="col-lg-6">
-									<div className="mb-3">
-										<Field name="nickname" validate={validateString(1, 30)}>
-											{({ field, form }: any) => (
-												<div className="form-floating mb-3">
-													<input
-														id="nickname"
-														className={`form-control ${form.errors.nickname && form.touched.nickname ? "is-invalid" : ""}`}
-														placeholder={intl.formatMessage({ id: "user.nickname" })}
-														{...field}
-													/>
-													<label htmlFor="nickname">
-														{intl.formatMessage({ id: "user.nickname" })}
-													</label>
-													{form.errors.nickname ? (
-														<div className="invalid-feedback">
-															{form.errors.nickname && form.touched.nickname
-																? form.errors.nickname
-																: null}
-														</div>
-													) : null}
-												</div>
-											)}
-										</Field>
+									<div className="col-lg-6">
+										<div className="mb-3">
+											<Field name="nickname" validate={validateString(1, 30)}>
+												{({ field, form }: any) => (
+													<div className="form-floating mb-3">
+														<input
+															id="nickname"
+															className={`form-control ${form.errors.nickname && form.touched.nickname ? "is-invalid" : ""}`}
+															placeholder={intl.formatMessage({ id: "user.nickname" })}
+															{...field}
+														/>
+														<label htmlFor="nickname">
+															{intl.formatMessage({ id: "user.nickname" })}
+														</label>
+														{form.errors.nickname ? (
+															<div className="invalid-feedback">
+																{form.errors.nickname && form.touched.nickname
+																	? form.errors.nickname
+																	: null}
+															</div>
+														) : null}
+													</div>
+												)}
+											</Field>
+										</div>
 									</div>
 								</div>
-							</div>
-							<div className="mb-3">
-								<Field name="email" validate={validateEmail()}>
-									{({ field, form }: any) => (
-										<div className="form-floating mb-3">
-											<input
-												id="email"
-												type="email"
-												className={`form-control ${form.errors.email && form.touched.email ? "is-invalid" : ""}`}
-												placeholder={intl.formatMessage({ id: "email-address" })}
-												{...field}
-											/>
-											<label htmlFor="email">{intl.formatMessage({ id: "email-address" })}</label>
-											{form.errors.email ? (
-												<div className="invalid-feedback">
-													{form.errors.email && form.touched.email ? form.errors.email : null}
-												</div>
-											) : null}
-										</div>
-									)}
-								</Field>
-							</div>
-							{currentUser && data && currentUser?.id !== data?.id ? (
-								<div className="my-3">
-									<h3 className="py-2">Properties</h3>
+								<div className="mb-3">
+									<Field name="email" validate={validateEmail()}>
+										{({ field, form }: any) => (
+											<div className="form-floating mb-3">
+												<input
+													id="email"
+													type="email"
+													className={`form-control ${form.errors.email && form.touched.email ? "is-invalid" : ""}`}
+													placeholder={intl.formatMessage({ id: "email-address" })}
+													{...field}
+												/>
+												<label htmlFor="email">
+													{intl.formatMessage({ id: "email-address" })}
+												</label>
+												{form.errors.email ? (
+													<div className="invalid-feedback">
+														{form.errors.email && form.touched.email
+															? form.errors.email
+															: null}
+													</div>
+												) : null}
+											</div>
+										)}
+									</Field>
+								</div>
+								{currentUser && data && currentUser?.id !== data?.id ? (
+									<div className="my-3">
+										<h3 className="py-2">Properties</h3>
 
-									<div className="divide-y">
-										<div>
-											<label className="row" htmlFor="isAdmin">
-												<span className="col">Administrator</span>
-												<span className="col-auto">
-													<Field name="isAdmin" type="checkbox">
-														{({ field }: any) => (
-															<label className="form-check form-check-single form-switch">
-																<input
-																	{...field}
-																	id="isAdmin"
-																	className="form-check-input"
-																	type="checkbox"
-																/>
-															</label>
-														)}
-													</Field>
-												</span>
-											</label>
-										</div>
-										<div>
-											<label className="row" htmlFor="isDisabled">
-												<span className="col">Disabled</span>
-												<span className="col-auto">
-													<Field name="isDisabled" type="checkbox">
-														{({ field }: any) => (
-															<label className="form-check form-check-single form-switch">
-																<input
-																	{...field}
-																	id="isDisabled"
-																	className="form-check-input"
-																	type="checkbox"
-																/>
-															</label>
-														)}
-													</Field>
-												</span>
-											</label>
+										<div className="divide-y">
+											<div>
+												<label className="row" htmlFor="isAdmin">
+													<span className="col">Administrator</span>
+													<span className="col-auto">
+														<Field name="isAdmin" type="checkbox">
+															{({ field }: any) => (
+																<label className="form-check form-check-single form-switch">
+																	<input
+																		{...field}
+																		id="isAdmin"
+																		className="form-check-input"
+																		type="checkbox"
+																	/>
+																</label>
+															)}
+														</Field>
+													</span>
+												</label>
+											</div>
+											<div>
+												<label className="row" htmlFor="isDisabled">
+													<span className="col">Disabled</span>
+													<span className="col-auto">
+														<Field name="isDisabled" type="checkbox">
+															{({ field }: any) => (
+																<label className="form-check form-check-single form-switch">
+																	<input
+																		{...field}
+																		id="isDisabled"
+																		className="form-check-input"
+																		type="checkbox"
+																	/>
+																</label>
+															)}
+														</Field>
+													</span>
+												</label>
+											</div>
 										</div>
 									</div>
-								</div>
-							) : null}
-						</Modal.Body>
-						<Modal.Footer>
-							<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
-								{intl.formatMessage({ id: "cancel" })}
-							</Button>
-							<Button
-								type="submit"
-								actionType="primary"
-								className="ms-auto"
-								data-bs-dismiss="modal"
-								isLoading={isSubmitting}
-								disabled={isSubmitting}
-							>
-								{intl.formatMessage({ id: "save" })}
-							</Button>
-						</Modal.Footer>
-					</Form>
-				)}
-			</Formik>
+								) : null}
+							</Modal.Body>
+							<Modal.Footer>
+								<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
+									{intl.formatMessage({ id: "cancel" })}
+								</Button>
+								<Button
+									type="submit"
+									actionType="primary"
+									className="ms-auto"
+									data-bs-dismiss="modal"
+									isLoading={isSubmitting}
+									disabled={isSubmitting}
+								>
+									{intl.formatMessage({ id: "save" })}
+								</Button>
+							</Modal.Footer>
+						</Form>
+					)}
+				</Formik>
+			)}
 		</Modal>
 	);
 }

+ 14 - 0
frontend/src/notifications/Msg.module.css

@@ -0,0 +1,14 @@
+.toaster {
+	padding: 0;
+	background: transparent !important;
+	box-shadow: none !important;
+	border: none !important;
+
+	&.toast {
+		border-radius: 0;
+		box-shadow: none;
+		font-size: 14px;
+		padding: 16px 24px;
+		background: transparent;
+	}
+}

+ 36 - 0
frontend/src/notifications/Msg.tsx

@@ -0,0 +1,36 @@
+import { IconCheck, IconExclamationCircle } from "@tabler/icons-react";
+import cn from "classnames";
+import type { ReactNode } from "react";
+
+function Msg({ data }: any) {
+	const cns = cn("toast", "show", data.type || null);
+
+	let icon: ReactNode = null;
+	switch (data.type) {
+		case "success":
+			icon = <IconCheck className="text-green mr-1" size={16} />;
+			break;
+		case "error":
+			icon = <IconExclamationCircle className="text-red mr-1" size={16} />;
+			break;
+	}
+
+	return (
+		<div
+			className={cns}
+			role="alert"
+			aria-live="assertive"
+			aria-atomic="true"
+			data-bs-autohide="false"
+			data-bs-toggle="toast"
+		>
+			{data.title && (
+				<div className="toast-header">
+					{icon} {data.title}
+				</div>
+			)}
+			<div className="toast-body">{data.message}</div>
+		</div>
+	);
+}
+export { Msg };

+ 27 - 0
frontend/src/notifications/helpers.tsx

@@ -0,0 +1,27 @@
+import { toast } from "react-toastify";
+import { intl } from "src/locale";
+import { Msg } from "./Msg";
+import styles from "./Msg.module.css";
+
+const showSuccess = (message: string) => {
+	toast(Msg, {
+		className: styles.toaster,
+		data: {
+			type: "success",
+			title: intl.formatMessage({ id: "notification.success" }),
+			message,
+		},
+	});
+};
+
+const showError = (message: string) => {
+	toast(<Msg />, {
+		data: {
+			type: "error",
+			title: intl.formatMessage({ id: "notification.error" }),
+			message,
+		},
+	});
+};
+
+export { showSuccess, showError };

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

@@ -0,0 +1 @@
+export * from "./helpers";

+ 1 - 1
frontend/src/pages/Access/index.tsx

@@ -3,7 +3,7 @@ import TableWrapper from "./TableWrapper";
 
 const Access = () => {
 	return (
-		<HasPermission permission="accessLists" type="view">
+		<HasPermission permission="accessLists" type="view" pageLoading loadingNoLogo>
 			<TableWrapper />
 		</HasPermission>
 	);

+ 1 - 1
frontend/src/pages/AuditLog/index.tsx

@@ -3,7 +3,7 @@ import AuditTable from "./AuditTable";
 
 const AuditLog = () => {
 	return (
-		<HasPermission permission="admin" type="manage">
+		<HasPermission permission="admin" type="manage" pageLoading loadingNoLogo>
 			<AuditTable />
 		</HasPermission>
 	);

+ 1 - 1
frontend/src/pages/Certificates/index.tsx

@@ -3,7 +3,7 @@ import CertificateTable from "./CertificateTable";
 
 const Certificates = () => {
 	return (
-		<HasPermission permission="certificates" type="view">
+		<HasPermission permission="certificates" type="view" pageLoading loadingNoLogo>
 			<CertificateTable />
 		</HasPermission>
 	);

+ 1 - 1
frontend/src/pages/Nginx/DeadHosts/index.tsx

@@ -3,7 +3,7 @@ import TableWrapper from "./TableWrapper";
 
 const DeadHosts = () => {
 	return (
-		<HasPermission permission="deadHosts" type="view">
+		<HasPermission permission="deadHosts" type="view" pageLoading loadingNoLogo>
 			<TableWrapper />
 		</HasPermission>
 	);

+ 1 - 1
frontend/src/pages/Nginx/ProxyHosts/index.tsx

@@ -3,7 +3,7 @@ import TableWrapper from "./TableWrapper";
 
 const ProxyHosts = () => {
 	return (
-		<HasPermission permission="proxyHosts" type="view">
+		<HasPermission permission="proxyHosts" type="view" pageLoading loadingNoLogo>
 			<TableWrapper />
 		</HasPermission>
 	);

+ 1 - 1
frontend/src/pages/Nginx/RedirectionHosts/index.tsx

@@ -3,7 +3,7 @@ import TableWrapper from "./TableWrapper";
 
 const RedirectionHosts = () => {
 	return (
-		<HasPermission permission="redirectionHosts" type="view">
+		<HasPermission permission="redirectionHosts" type="view" pageLoading loadingNoLogo>
 			<TableWrapper />
 		</HasPermission>
 	);

+ 1 - 1
frontend/src/pages/Nginx/Streams/index.tsx

@@ -3,7 +3,7 @@ import TableWrapper from "./TableWrapper";
 
 const Streams = () => {
 	return (
-		<HasPermission permission="streams" type="view">
+		<HasPermission permission="streams" type="view" pageLoading loadingNoLogo>
 			<TableWrapper />
 		</HasPermission>
 	);

+ 1 - 1
frontend/src/pages/Settings/index.tsx

@@ -3,7 +3,7 @@ import SettingTable from "./SettingTable";
 
 const Settings = () => {
 	return (
-		<HasPermission permission="admin" type="manage">
+		<HasPermission permission="admin" type="manage" pageLoading loadingNoLogo>
 			<SettingTable />
 		</HasPermission>
 	);

+ 5 - 2
frontend/src/pages/Users/Empty.tsx

@@ -4,15 +4,18 @@ import { intl } from "src/locale";
 
 interface Props {
 	tableInstance: ReactTable<any>;
+	onNewUser?: () => void;
 }
-export default function Empty({ tableInstance }: Props) {
+export default function Empty({ tableInstance, onNewUser }: Props) {
 	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">{intl.formatMessage({ id: "proxy-hosts.add" })}</Button>
+					<Button className="btn-lime my-3" onClick={onNewUser}>
+						{intl.formatMessage({ id: "proxy-hosts.add" })}
+					</Button>
 				</div>
 			</td>
 		</tr>

+ 8 - 2
frontend/src/pages/Users/Table.tsx

@@ -12,8 +12,9 @@ interface Props {
 	isFetching?: boolean;
 	currentUserId?: number;
 	onEditUser?: (id: number) => void;
+	onNewUser?: () => void;
 }
-export default function Table({ data, isFetching, currentUserId, onEditUser }: Props) {
+export default function Table({ data, isFetching, currentUserId, onEditUser, onNewUser }: Props) {
 	const columnHelper = createColumnHelper<User>();
 	const columns = useMemo(
 		() => [
@@ -124,5 +125,10 @@ export default function Table({ data, isFetching, currentUserId, onEditUser }: P
 		enableSortingRemoval: false,
 	});
 
-	return <TableLayout tableInstance={tableInstance} emptyState={<Empty tableInstance={tableInstance} />} />;
+	return (
+		<TableLayout
+			tableInstance={tableInstance}
+			emptyState={<Empty tableInstance={tableInstance} onNewUser={onNewUser} />}
+		/>
+	);
 }

+ 3 - 2
frontend/src/pages/Users/TableWrapper.tsx

@@ -8,7 +8,7 @@ import { UserModal } from "src/modals";
 import Table from "./Table";
 
 export default function TableWrapper() {
-	const [editUserId, setEditUserId] = useState(0);
+	const [editUserId, setEditUserId] = useState(0 as number | "new");
 	const { isFetching, isLoading, isError, error, data } = useUsers(["permissions"]);
 	const { data: currentUser } = useUser("me");
 
@@ -42,7 +42,7 @@ export default function TableWrapper() {
 										autoComplete="off"
 									/>
 								</div>
-								<Button size="sm" className="btn-orange">
+								<Button size="sm" className="btn-orange" onClick={() => setEditUserId("new")}>
 									{intl.formatMessage({ id: "users.add" })}
 								</Button>
 							</div>
@@ -54,6 +54,7 @@ export default function TableWrapper() {
 					isFetching={isFetching}
 					currentUserId={currentUser?.id}
 					onEditUser={(id: number) => setEditUserId(id)}
+					onNewUser={() => setEditUserId("new")}
 				/>
 				{editUserId ? <UserModal userId={editUserId} onClose={() => setEditUserId(0)} /> : null}
 			</div>

+ 1 - 1
frontend/src/pages/Users/index.tsx

@@ -3,7 +3,7 @@ import TableWrapper from "./TableWrapper";
 
 const Users = () => {
 	return (
-		<HasPermission permission="admin" type="manage">
+		<HasPermission permission="admin" type="manage" pageLoading loadingNoLogo>
 			<TableWrapper />
 		</HasPermission>
 	);