You've already forked nginx-proxy-manager
							
							
				mirror of
				https://github.com/NginxProxyManager/nginx-proxy-manager.git
				synced 2025-10-30 18:05:34 +03:00 
			
		
		
		
	Notification toasts, nicer loading, add new user support
This commit is contained in:
		| @@ -5,3 +5,10 @@ | ||||
| .domain-name { | ||||
| 	font-family: monospace; | ||||
| } | ||||
|  | ||||
| .mr-1 { | ||||
| 	margin-right: 0.25rem; | ||||
| } | ||||
| .ml-1 { | ||||
| 	margin-left: 0.25rem; | ||||
| } | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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"; | ||||
|   | ||||
| @@ -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]; | ||||
|  | ||||
|   | ||||
							
								
								
									
										22
									
								
								frontend/src/components/Loading.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								frontend/src/components/Loading.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||
| 	); | ||||
| } | ||||
| @@ -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> | ||||
| 	); | ||||
|   | ||||
| @@ -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"; | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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" | ||||
| 	}, | ||||
|   | ||||
| @@ -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> | ||||
| 									</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> | ||||
| 								</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} | ||||
| 			{!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> | ||||
| 									)} | ||||
| 								</Field> | ||||
| 							</div> | ||||
| 							{currentUser && data && currentUser?.id !== data?.id ? ( | ||||
| 								<div className="my-3"> | ||||
| 									<h3 className="py-2">Properties</h3> | ||||
| 									</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> | ||||
| 									</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="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
									
								
								frontend/src/notifications/Msg.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								frontend/src/notifications/Msg.module.css
									
									
									
									
									
										Normal file
									
								
							| @@ -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
									
								
								frontend/src/notifications/Msg.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								frontend/src/notifications/Msg.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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
									
								
								frontend/src/notifications/helpers.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								frontend/src/notifications/helpers.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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
									
								
								frontend/src/notifications/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/src/notifications/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| export * from "./helpers"; | ||||
| @@ -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> | ||||
| 	); | ||||
|   | ||||
| @@ -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> | ||||
| 	); | ||||
|   | ||||
| @@ -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> | ||||
| 	); | ||||
|   | ||||
| @@ -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> | ||||
| 	); | ||||
|   | ||||
| @@ -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> | ||||
| 	); | ||||
|   | ||||
| @@ -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> | ||||
| 	); | ||||
|   | ||||
| @@ -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> | ||||
| 	); | ||||
|   | ||||
| @@ -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> | ||||
| 	); | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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} />} | ||||
| 		/> | ||||
| 	); | ||||
| } | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
| 	); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user