Compare commits

..

2 Commits

Author SHA1 Message Date
Jamie Curnow
432afe73ad User table polishing, user delete modal 2025-09-04 14:59:01 +10:00
Jamie Curnow
5a01da2916 Notification toasts, nicer loading, add new user support 2025-09-04 12:11:39 +10:00
41 changed files with 601 additions and 248 deletions

View File

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

View File

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

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,20 +119,20 @@ export default () => {
* @param {*} value
*/
set: (key, value) => {
token_data[key] = value;
tokenData[key] = value;
},
/**
* @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;
},
};

View File

@@ -30,6 +30,7 @@
"react-dom": "^19.1.1",
"react-intl": "^7.1.11",
"react-router-dom": "^7.8.2",
"react-toastify": "^11.0.5",
"rooks": "^9.2.0"
},
"devDependencies": {

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

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

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

View File

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

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",
@@ -51,6 +51,10 @@
"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-deleted": "User has been deleted",
"notification.user-saved": "User has been saved",
"offline": "Offline",
"online": "Online",
"password": "Password",
@@ -64,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",
@@ -78,10 +83,14 @@
"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",
"user.new-password": "New Password",
"user.nickname": "Nickname",
"user.switch-dark": "Switch to Dark mode",

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"
},
@@ -155,6 +155,18 @@
"notfound.title": {
"defaultMessage": "Oops… You just found an error page"
},
"notification.error": {
"defaultMessage": "Error"
},
"notification.user-deleted": {
"defaultMessage": "User has been deleted"
},
"notification.user-saved": {
"defaultMessage": "User has been saved"
},
"notification.success": {
"defaultMessage": "Success"
},
"offline": {
"defaultMessage": "Offline"
},
@@ -194,6 +206,12 @@
"redirection-hosts.title": {
"defaultMessage": "Redirection Hosts"
},
"role.admin": {
"defaultMessage": "Administrator"
},
"role.standard-user": {
"defaultMessage": "Standard User"
},
"save": {
"defaultMessage": "Save"
},
@@ -203,9 +221,6 @@
"sign-in": {
"defaultMessage": "Sign in"
},
"standard-user": {
"defaultMessage": "Apache Helicopter"
},
"streams.actions-title": {
"defaultMessage": "Stream #{id}"
},
@@ -236,18 +251,30 @@
"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"
},
"user.logout": {
"defaultMessage": "Logout"
},
"user.new": {
"defaultMessage": "New User"
},
"user.new-password": {
"defaultMessage": "New Password"
},

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

@@ -2,31 +2,30 @@ 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);
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 +38,191 @@ 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 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 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>
) : 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>
<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">{intl.formatMessage({ id: "user.flags.title" })}</h3>
<div className="divide-y">
<div>
<label className="row" htmlFor="isAdmin">
<span className="col">
{intl.formatMessage({ id: "role.admin" })}
</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">
{intl.formatMessage({ id: "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>
) : 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>
);
}

View File

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

View 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;
}
}

View 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 };

View 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 };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,8 +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 }: Props) {
export default function Table({ data, isFetching, currentUserId, onEditUser, onDeleteUser, onNewUser }: Props) {
const columnHelper = createColumnHelper<User>();
const columns = useMemo(
() => [
@@ -33,14 +35,20 @@ export default function Table({ data, isFetching, currentUserId, onEditUser }: P
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
@@ -48,7 +56,7 @@ export default function Table({ data, isFetching, currentUserId, onEditUser }: P
id: "roles",
header: intl.formatMessage({ id: "column.roles" }),
cell: (info: any) => {
return JSON.stringify(info.getValue());
return <RolesFormatter roles={info.getValue()} />;
},
}),
columnHelper.display({
@@ -95,7 +103,14 @@ export default function Table({ data, isFetching, currentUserId, onEditUser }: P
{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>
@@ -110,7 +125,7 @@ export default function Table({ data, isFetching, currentUserId, onEditUser }: P
},
}),
],
[columnHelper, currentUserId, onEditUser],
[columnHelper, currentUserId, onEditUser, onDeleteUser],
);
const tableInstance = useReactTable<User>({
@@ -124,5 +139,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} />}
/>
);
}

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);
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" />
@@ -42,7 +50,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,8 +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>
);

View File

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

View File

@@ -1064,6 +1064,11 @@ classnames@^2.3.2, classnames@^2.5.1:
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b"
integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==
clsx@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
convert-source-map@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
@@ -1607,6 +1612,13 @@ react-router@7.8.2:
cookie "^1.0.1"
set-cookie-parser "^2.6.0"
react-toastify@^11.0.5:
version "11.0.5"
resolved "https://registry.yarnpkg.com/react-toastify/-/react-toastify-11.0.5.tgz#ce4c42d10eeb433988ab2264d3e445c4e9d13313"
integrity sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==
dependencies:
clsx "^2.1.1"
react-transition-group@^4.4.5:
version "4.4.5"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1"