mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2025-10-04 03:40:10 +00:00
Compare commits
2 Commits
538d28d32d
...
2b88f56d22
Author | SHA1 | Date | |
---|---|---|---|
|
2b88f56d22 | ||
|
e44748e46f |
@@ -36,6 +36,35 @@ const internalAuditLog = {
|
||||
return await query;
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Access} access
|
||||
* @param {Object} [data]
|
||||
* @param {Integer} [data.id] Defaults to the token user
|
||||
* @param {Array} [data.expand]
|
||||
* @return {Promise}
|
||||
*/
|
||||
get: async (access, data) => {
|
||||
await access.can("auditlog:list");
|
||||
|
||||
const query = auditLogModel
|
||||
.query()
|
||||
.andWhere("id", data.id)
|
||||
.allowGraph("[user]")
|
||||
.first();
|
||||
|
||||
if (typeof data.expand !== "undefined" && data.expand !== null) {
|
||||
query.withGraphFetched(`[${data.expand.join(", ")}]`);
|
||||
}
|
||||
|
||||
const row = await query;
|
||||
|
||||
if (!row?.id) {
|
||||
throw new errs.ItemNotFoundError(data.id);
|
||||
}
|
||||
|
||||
return row;
|
||||
},
|
||||
|
||||
/**
|
||||
* This method should not be publicly used, it doesn't check certain things. It will be assumed
|
||||
* that permission to add to audit log is already considered, however the access token is used for
|
||||
|
@@ -52,4 +52,56 @@ router
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Specific audit log entry
|
||||
*
|
||||
* /api/audit-log/123
|
||||
*/
|
||||
router
|
||||
.route("/:event_id")
|
||||
.options((_, res) => {
|
||||
res.sendStatus(204);
|
||||
})
|
||||
.all(jwtdecode())
|
||||
|
||||
/**
|
||||
* GET /api/audit-log/123
|
||||
*
|
||||
* Retrieve a specific entry
|
||||
*/
|
||||
.get(async (req, res, next) => {
|
||||
try {
|
||||
const data = await validator(
|
||||
{
|
||||
required: ["event_id"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
event_id: {
|
||||
$ref: "common#/properties/id",
|
||||
},
|
||||
expand: {
|
||||
$ref: "common#/properties/expand",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
event_id: req.params.event_id,
|
||||
expand:
|
||||
typeof req.query.expand === "string"
|
||||
? req.query.expand.split(",")
|
||||
: null,
|
||||
},
|
||||
);
|
||||
|
||||
const item = await internalAuditLog.get(res.locals.access, {
|
||||
id: data.event_id,
|
||||
expand: data.expand,
|
||||
});
|
||||
res.status(200).send(item);
|
||||
} catch (err) {
|
||||
logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`);
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
@@ -23,6 +23,7 @@
|
||||
"country-flag-icons": "^1.5.19",
|
||||
"date-fns": "^4.1.0",
|
||||
"formik": "^2.4.6",
|
||||
"generate-password-browser": "^1.1.0",
|
||||
"humps": "^2.0.1",
|
||||
"query-string": "^9.2.2",
|
||||
"react": "^19.1.1",
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import * as api from "./base";
|
||||
import type { AuditLogExpansion } from "./getAuditLogs";
|
||||
import type { AuditLog } from "./models";
|
||||
|
||||
export async function getAuditLog(expand?: string[], params = {}): Promise<AuditLog[]> {
|
||||
export async function getAuditLog(id: number, expand?: AuditLogExpansion[], params = {}): Promise<AuditLog> {
|
||||
return await api.get({
|
||||
url: "/audit-log",
|
||||
url: `/audit-log/${id}`,
|
||||
params: {
|
||||
expand: expand?.join(","),
|
||||
...params,
|
||||
|
14
frontend/src/api/backend/getAuditLogs.ts
Normal file
14
frontend/src/api/backend/getAuditLogs.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import * as api from "./base";
|
||||
import type { AuditLog } from "./models";
|
||||
|
||||
export type AuditLogExpansion = "user";
|
||||
|
||||
export async function getAuditLogs(expand?: AuditLogExpansion[], params = {}): Promise<AuditLog[]> {
|
||||
return await api.get({
|
||||
url: "/audit-log",
|
||||
params: {
|
||||
expand: expand?.join(","),
|
||||
...params,
|
||||
},
|
||||
});
|
||||
}
|
@@ -16,6 +16,7 @@ export * from "./downloadCertificate";
|
||||
export * from "./getAccessList";
|
||||
export * from "./getAccessLists";
|
||||
export * from "./getAuditLog";
|
||||
export * from "./getAuditLogs";
|
||||
export * from "./getCertificate";
|
||||
export * from "./getCertificates";
|
||||
export * from "./getDeadHost";
|
||||
|
@@ -40,6 +40,8 @@ export interface AuditLog {
|
||||
objectId: number;
|
||||
action: string;
|
||||
meta: Record<string, any>;
|
||||
// Expansions:
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export interface AccessList {
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import { intlFormat, parseISO } from "date-fns";
|
||||
import { intl } from "src/locale";
|
||||
import { DateTimeFormat, intl } from "src/locale";
|
||||
|
||||
interface Props {
|
||||
domains: string[];
|
||||
@@ -17,7 +16,7 @@ export function DomainsFormatter({ domains, createdOn }: Props) {
|
||||
</div>
|
||||
{createdOn ? (
|
||||
<div className="text-secondary mt-1">
|
||||
{intl.formatMessage({ id: "created-on" }, { date: intlFormat(parseISO(createdOn)) })}
|
||||
{intl.formatMessage({ id: "created-on" }, { date: DateTimeFormat(createdOn) })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
53
frontend/src/components/Table/Formatter/EventFormatter.tsx
Normal file
53
frontend/src/components/Table/Formatter/EventFormatter.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { IconUser } from "@tabler/icons-react";
|
||||
import type { AuditLog } from "src/api/backend";
|
||||
import { DateTimeFormat, intl } from "src/locale";
|
||||
|
||||
const getEventTitle = (event: AuditLog) => (
|
||||
<span>{intl.formatMessage({ id: `event.${event.action}-${event.objectType}` })}</span>
|
||||
);
|
||||
|
||||
const getEventValue = (event: AuditLog) => {
|
||||
switch (event.objectType) {
|
||||
case "user":
|
||||
return event.meta?.name;
|
||||
default:
|
||||
return `UNKNOWN EVENT TYPE: ${event.objectType}`;
|
||||
}
|
||||
};
|
||||
|
||||
const getColorForAction = (action: string) => {
|
||||
switch (action) {
|
||||
case "created":
|
||||
return "text-lime";
|
||||
case "deleted":
|
||||
return "text-red";
|
||||
default:
|
||||
return "text-blue";
|
||||
}
|
||||
};
|
||||
|
||||
const getIcon = (row: AuditLog) => {
|
||||
const c = getColorForAction(row.action);
|
||||
let ico = null;
|
||||
switch (row.objectType) {
|
||||
case "user":
|
||||
ico = <IconUser size={16} className={c} />;
|
||||
break;
|
||||
}
|
||||
|
||||
return ico;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
row: AuditLog;
|
||||
}
|
||||
export function EventFormatter({ row }: Props) {
|
||||
return (
|
||||
<div className="flex-fill">
|
||||
<div className="font-weight-medium">
|
||||
{getIcon(row)} {getEventTitle(row)} — <span className="badge">{getEventValue(row)}</span>
|
||||
</div>
|
||||
<div className="text-secondary mt-1">{DateTimeFormat(row.createdOn)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,5 +1,4 @@
|
||||
import { intlFormat, parseISO } from "date-fns";
|
||||
import { intl } from "src/locale";
|
||||
import { DateTimeFormat, intl } from "src/locale";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
@@ -16,7 +15,7 @@ export function ValueWithDateFormatter({ value, createdOn, disabled }: Props) {
|
||||
<div className={`text-secondary mt-1 ${disabled ? "text-red" : ""}`}>
|
||||
{disabled
|
||||
? intl.formatMessage({ id: "disabled" })
|
||||
: intl.formatMessage({ id: "created-on" }, { date: intlFormat(parseISO(createdOn)) })}
|
||||
: intl.formatMessage({ id: "created-on" }, { date: DateTimeFormat(createdOn) })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
@@ -1,6 +1,7 @@
|
||||
export * from "./CertificateFormatter";
|
||||
export * from "./DomainsFormatter";
|
||||
export * from "./EmailFormatter";
|
||||
export * from "./EventFormatter";
|
||||
export * from "./GravatarFormatter";
|
||||
export * from "./RolesFormatter";
|
||||
export * from "./StatusFormatter";
|
||||
|
@@ -1,4 +1,6 @@
|
||||
export * from "./useAccessLists";
|
||||
export * from "./useAuditLog";
|
||||
export * from "./useAuditLogs";
|
||||
export * from "./useDeadHosts";
|
||||
export * from "./useHealth";
|
||||
export * from "./useHostReport";
|
||||
|
17
frontend/src/hooks/useAuditLog.ts
Normal file
17
frontend/src/hooks/useAuditLog.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { type AuditLog, getAuditLog } from "src/api/backend";
|
||||
|
||||
const fetchAuditLog = (id: number) => {
|
||||
return getAuditLog(id, ["user"]);
|
||||
};
|
||||
|
||||
const useAuditLog = (id: number, options = {}) => {
|
||||
return useQuery<AuditLog, Error>({
|
||||
queryKey: ["audit-log", id],
|
||||
queryFn: () => fetchAuditLog(id),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export { useAuditLog };
|
17
frontend/src/hooks/useAuditLogs.ts
Normal file
17
frontend/src/hooks/useAuditLogs.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { type AuditLog, type AuditLogExpansion, getAuditLogs } from "src/api/backend";
|
||||
|
||||
const fetchAuditLogs = (expand?: AuditLogExpansion[]) => {
|
||||
return getAuditLogs(expand);
|
||||
};
|
||||
|
||||
const useAuditLogs = (expand?: AuditLogExpansion[], options = {}) => {
|
||||
return useQuery<AuditLog[], Error>({
|
||||
queryKey: ["audit-logs", { expand }],
|
||||
queryFn: () => fetchAuditLogs(expand),
|
||||
staleTime: 10 * 1000,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export { fetchAuditLogs, useAuditLogs };
|
15
frontend/src/locale/DateTimeFormat.ts
Normal file
15
frontend/src/locale/DateTimeFormat.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { intlFormat, parseISO } from "date-fns";
|
||||
|
||||
const DateTimeFormat = (isoDate: string) =>
|
||||
intlFormat(parseISO(isoDate), {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
hour12: true,
|
||||
});
|
||||
|
||||
export { DateTimeFormat };
|
@@ -1 +1,2 @@
|
||||
export * from "./DateTimeFormat";
|
||||
export * from "./IntlProvider";
|
||||
|
@@ -12,13 +12,16 @@
|
||||
"action.edit": "Edit",
|
||||
"action.enable": "Enable",
|
||||
"action.permissions": "Permissions",
|
||||
"action.view-details": "View Details",
|
||||
"auditlog.title": "Audit Log",
|
||||
"cancel": "Cancel",
|
||||
"certificates.title": "SSL Certificates",
|
||||
"close": "Close",
|
||||
"column.access": "Access",
|
||||
"column.authorization": "Authorization",
|
||||
"column.destination": "Destination",
|
||||
"column.email": "Email",
|
||||
"column.event": "Event",
|
||||
"column.http-code": "Access",
|
||||
"column.incoming-port": "Incoming Port",
|
||||
"column.name": "Name",
|
||||
@@ -41,6 +44,9 @@
|
||||
"empty-subtitle": "Why don't you create one?",
|
||||
"error.invalid-auth": "Invalid email or password",
|
||||
"error.passwords-must-match": "Passwords must match",
|
||||
"event.created-user": "Created User",
|
||||
"event.deleted-user": "Deleted User",
|
||||
"event.updated-user": "Updated User",
|
||||
"footer.github-fork": "Fork me on Github",
|
||||
"hosts.title": "Hosts",
|
||||
"http-only": "HTTP Only",
|
||||
@@ -58,6 +64,9 @@
|
||||
"offline": "Offline",
|
||||
"online": "Online",
|
||||
"password": "Password",
|
||||
"password.generate": "Generate random password",
|
||||
"password.hide": "Hide Password",
|
||||
"password.show": "Show Password",
|
||||
"permissions.hidden": "Hidden",
|
||||
"permissions.manage": "Manage",
|
||||
"permissions.view": "View Only",
|
||||
@@ -101,6 +110,7 @@
|
||||
"user.new": "New User",
|
||||
"user.new-password": "New Password",
|
||||
"user.nickname": "Nickname",
|
||||
"user.set-password": "Set Password",
|
||||
"user.set-permissions": "Set Permissions for {name}",
|
||||
"user.switch-dark": "Switch to Dark mode",
|
||||
"user.switch-light": "Switch to Light mode",
|
||||
|
@@ -41,12 +41,18 @@
|
||||
"auditlog.title": {
|
||||
"defaultMessage": "Audit Log"
|
||||
},
|
||||
"action.view-details": {
|
||||
"defaultMessage": "View Details"
|
||||
},
|
||||
"cancel": {
|
||||
"defaultMessage": "Cancel"
|
||||
},
|
||||
"certificates.title": {
|
||||
"defaultMessage": "SSL Certificates"
|
||||
},
|
||||
"close": {
|
||||
"defaultMessage": "Close"
|
||||
},
|
||||
"created-on": {
|
||||
"defaultMessage": "Created: {date}"
|
||||
},
|
||||
@@ -62,6 +68,9 @@
|
||||
"column.email": {
|
||||
"defaultMessage": "Email"
|
||||
},
|
||||
"column.event": {
|
||||
"defaultMessage": "Event"
|
||||
},
|
||||
"column.http-code": {
|
||||
"defaultMessage": "Access"
|
||||
},
|
||||
@@ -122,6 +131,15 @@
|
||||
"error.invalid-auth": {
|
||||
"defaultMessage": "Invalid email or password"
|
||||
},
|
||||
"event.created-user": {
|
||||
"defaultMessage": "Created User"
|
||||
},
|
||||
"event.deleted-user": {
|
||||
"defaultMessage": "Deleted User"
|
||||
},
|
||||
"event.updated-user": {
|
||||
"defaultMessage": "Updated User"
|
||||
},
|
||||
"footer.github-fork": {
|
||||
"defaultMessage": "Fork me on Github"
|
||||
},
|
||||
@@ -176,6 +194,15 @@
|
||||
"password": {
|
||||
"defaultMessage": "Password"
|
||||
},
|
||||
"password.generate": {
|
||||
"defaultMessage": "Generate random password"
|
||||
},
|
||||
"password.hide": {
|
||||
"defaultMessage": "Hide Password"
|
||||
},
|
||||
"password.show": {
|
||||
"defaultMessage": "Show Password"
|
||||
},
|
||||
"permissions.hidden": {
|
||||
"defaultMessage": "Hidden"
|
||||
},
|
||||
@@ -305,6 +332,9 @@
|
||||
"user.nickname": {
|
||||
"defaultMessage": "Nickname"
|
||||
},
|
||||
"user.set-password": {
|
||||
"defaultMessage": "Set Password"
|
||||
},
|
||||
"user.set-permissions": {
|
||||
"defaultMessage": "Set Permissions for {name}"
|
||||
},
|
||||
|
50
frontend/src/modals/EventDetailsModal.tsx
Normal file
50
frontend/src/modals/EventDetailsModal.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Alert } from "react-bootstrap";
|
||||
import Modal from "react-bootstrap/Modal";
|
||||
import { Button, EventFormatter, GravatarFormatter, Loading } from "src/components";
|
||||
import { useAuditLog } from "src/hooks";
|
||||
import { intl } from "src/locale";
|
||||
|
||||
interface Props {
|
||||
id: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
export function EventDetailsModal({ id, onClose }: Props) {
|
||||
const { data, isLoading, error } = useAuditLog(id);
|
||||
|
||||
return (
|
||||
<Modal show onHide={onClose} animation={false}>
|
||||
{!isLoading && error && (
|
||||
<Alert variant="danger" className="m-3">
|
||||
{error?.message || "Unknown error"}
|
||||
</Alert>
|
||||
)}
|
||||
{isLoading && <Loading noLogo />}
|
||||
{!isLoading && data && (
|
||||
<>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{intl.formatMessage({ id: "action.view-details" })}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<div className="row">
|
||||
<div className="col-md-2">
|
||||
<GravatarFormatter url={data.user?.avatar || ""} />
|
||||
</div>
|
||||
<div className="col-md-10">
|
||||
<EventFormatter row={data} />
|
||||
</div>
|
||||
<hr className="mt-4 mb-3" />
|
||||
<pre>
|
||||
<code>{JSON.stringify(data.meta, null, 2)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button data-bs-dismiss="modal" onClick={onClose}>
|
||||
{intl.formatMessage({ id: "close" })}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
@@ -83,7 +83,11 @@ export function PermissionsModal({ userId, onClose }: Props) {
|
||||
|
||||
return (
|
||||
<Modal show onHide={onClose} animation={false}>
|
||||
{!isLoading && error && <Alert variant="danger">{error?.message || "Unknown error"}</Alert>}
|
||||
{!isLoading && error && (
|
||||
<Alert variant="danger" className="m-3">
|
||||
{error?.message || "Unknown error"}
|
||||
</Alert>
|
||||
)}
|
||||
{isLoading && <Loading noLogo />}
|
||||
{!isLoading && data && (
|
||||
<Formik
|
||||
|
132
frontend/src/modals/SetPasswordModal.tsx
Normal file
132
frontend/src/modals/SetPasswordModal.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Field, Form, Formik } from "formik";
|
||||
import { generate } from "generate-password-browser";
|
||||
import { useState } from "react";
|
||||
import { Alert } from "react-bootstrap";
|
||||
import Modal from "react-bootstrap/Modal";
|
||||
import { updateAuth } from "src/api/backend";
|
||||
import { Button } from "src/components";
|
||||
import { intl } from "src/locale";
|
||||
import { validateString } from "src/modules/Validations";
|
||||
|
||||
interface Props {
|
||||
userId: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
export function SetPasswordModal({ userId, onClose }: Props) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting }: any) => {
|
||||
setError(null);
|
||||
try {
|
||||
await updateAuth(userId, values.new);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(intl.formatMessage({ id: err.message }));
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show onHide={onClose} animation={false}>
|
||||
<Formik
|
||||
initialValues={
|
||||
{
|
||||
new: "",
|
||||
} as any
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{intl.formatMessage({ id: "user.set-password" })}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Alert variant="danger" show={!!error} onClose={() => setError(null)} dismissible>
|
||||
{error}
|
||||
</Alert>
|
||||
<div className="mb-3">
|
||||
<Field name="new" validate={validateString(8, 100)}>
|
||||
{({ field, form }: any) => (
|
||||
<>
|
||||
<p className="text-end">
|
||||
<small>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
form.setFieldValue(
|
||||
field.name,
|
||||
generate({
|
||||
length: 12,
|
||||
numbers: true,
|
||||
}),
|
||||
);
|
||||
setShowPassword(true);
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: "password.generate",
|
||||
})}
|
||||
</a>{" "}
|
||||
—{" "}
|
||||
<a
|
||||
href="#"
|
||||
className="text-xs"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowPassword(!showPassword);
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: showPassword ? "password.hide" : "password.show",
|
||||
})}
|
||||
</a>
|
||||
</small>
|
||||
</p>
|
||||
<div className="form-floating mb-3">
|
||||
<input
|
||||
id="new"
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
className={`form-control ${form.errors.new && form.touched.new ? "is-invalid" : ""}`}
|
||||
placeholder={intl.formatMessage({ id: "user.new-password" })}
|
||||
{...field}
|
||||
/>
|
||||
<label htmlFor="new">
|
||||
{intl.formatMessage({ id: "user.new-password" })}
|
||||
</label>
|
||||
|
||||
{form.errors.new ? (
|
||||
<div className="invalid-feedback">
|
||||
{form.errors.new && form.touched.new ? form.errors.new : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
@@ -49,7 +49,11 @@ export function UserModal({ userId, onClose }: Props) {
|
||||
|
||||
return (
|
||||
<Modal show onHide={onClose} animation={false}>
|
||||
{!isLoading && error && <Alert variant="danger">{error?.message || "Unknown error"}</Alert>}
|
||||
{!isLoading && error && (
|
||||
<Alert variant="danger" className="m-3">
|
||||
{error?.message || "Unknown error"}
|
||||
</Alert>
|
||||
)}
|
||||
{(isLoading || currentIsLoading) && <Loading noLogo />}
|
||||
{!isLoading && !currentIsLoading && data && currentUser && (
|
||||
<Formik
|
||||
|
@@ -1,4 +1,6 @@
|
||||
export * from "./ChangePasswordModal";
|
||||
export * from "./DeleteConfirmModal";
|
||||
export * from "./EventDetailsModal";
|
||||
export * from "./PermissionsModal";
|
||||
export * from "./SetPasswordModal";
|
||||
export * from "./UserModal";
|
||||
|
74
frontend/src/pages/AuditLog/Table.tsx
Normal file
74
frontend/src/pages/AuditLog/Table.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useMemo } from "react";
|
||||
import type { AuditLog } from "src/api/backend";
|
||||
import { EventFormatter, GravatarFormatter } from "src/components";
|
||||
import { TableLayout } from "src/components/Table/TableLayout";
|
||||
import { intl } from "src/locale";
|
||||
|
||||
interface Props {
|
||||
data: AuditLog[];
|
||||
isFetching?: boolean;
|
||||
onSelectItem?: (id: number) => void;
|
||||
}
|
||||
export default function Table({ data, isFetching, onSelectItem }: Props) {
|
||||
const columnHelper = createColumnHelper<AuditLog>();
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
columnHelper.accessor((row: AuditLog) => row.user, {
|
||||
id: "user.avatar",
|
||||
cell: (info: any) => {
|
||||
const value = info.getValue();
|
||||
return <GravatarFormatter url={value.avatar} name={value.name} />;
|
||||
},
|
||||
meta: {
|
||||
className: "w-1",
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row: AuditLog) => row.user?.name, {
|
||||
id: "user.name",
|
||||
header: intl.formatMessage({ id: "column.name" }),
|
||||
}),
|
||||
columnHelper.accessor((row: AuditLog) => row, {
|
||||
id: "objectType",
|
||||
header: intl.formatMessage({ id: "column.event" }),
|
||||
cell: (info: any) => {
|
||||
return <EventFormatter row={info.getValue()} />;
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "id",
|
||||
cell: (info: any) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-action btn-sm px-1"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onSelectItem?.(info.row.original.id);
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage({ id: "action.view-details" })}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
className: "text-end w-1",
|
||||
},
|
||||
}),
|
||||
],
|
||||
[columnHelper, onSelectItem],
|
||||
);
|
||||
|
||||
const tableInstance = useReactTable<AuditLog>({
|
||||
columns,
|
||||
data,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
rowCount: data.length,
|
||||
meta: {
|
||||
isFetching,
|
||||
},
|
||||
enableSortingRemoval: false,
|
||||
});
|
||||
|
||||
return <TableLayout tableInstance={tableInstance} />;
|
||||
}
|
53
frontend/src/pages/AuditLog/TableWrapper.tsx
Normal file
53
frontend/src/pages/AuditLog/TableWrapper.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import Alert from "react-bootstrap/Alert";
|
||||
import { LoadingPage } from "src/components";
|
||||
import { useAuditLogs } from "src/hooks";
|
||||
import { intl } from "src/locale";
|
||||
import { EventDetailsModal } from "src/modals";
|
||||
import Table from "./Table";
|
||||
|
||||
export default function TableWrapper() {
|
||||
const [eventId, setEventId] = useState(0);
|
||||
const { isFetching, isLoading, isError, error, data } = useAuditLogs(["user"]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card mt-4">
|
||||
<div className="card-status-top bg-purple" />
|
||||
<div className="card-table">
|
||||
<div className="card-header">
|
||||
<div className="row w-full">
|
||||
<div className="col">
|
||||
<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "auditlog.title" })}</h2>
|
||||
</div>
|
||||
<div className="col-md-auto col-sm-12">
|
||||
<div className="ms-auto d-flex flex-wrap btn-list">
|
||||
<div className="input-group input-group-flat w-auto">
|
||||
<span className="input-group-text input-group-text-sm">
|
||||
<IconSearch size={16} />
|
||||
</span>
|
||||
<input
|
||||
id="advanced-table-search"
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Table data={data ?? []} isFetching={isFetching} onSelectItem={setEventId} />
|
||||
{eventId ? <EventDetailsModal id={eventId} onClose={() => setEventId(0)} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,10 +1,10 @@
|
||||
import { HasPermission } from "src/components";
|
||||
import AuditTable from "./AuditTable";
|
||||
import TableWrapper from "./TableWrapper";
|
||||
|
||||
const AuditLog = () => {
|
||||
return (
|
||||
<HasPermission permission="admin" type="manage" pageLoading loadingNoLogo>
|
||||
<AuditTable />
|
||||
<TableWrapper />
|
||||
</HasPermission>
|
||||
);
|
||||
};
|
||||
|
@@ -134,6 +134,8 @@ More for api, then implement here:
|
||||
- Properly implement refresh tokens
|
||||
- Add error message_18n for all backend errors
|
||||
- minor: certificates expand with hosts needs to omit 'is_deleted'
|
||||
- properly wrap all logger.debug called in isDebug check
|
||||
|
||||
`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
@@ -125,7 +125,7 @@ export default function Table({
|
||||
}}
|
||||
>
|
||||
<IconLock size={16} />
|
||||
{intl.formatMessage({ id: "user.change-password" })}
|
||||
{intl.formatMessage({ id: "user.set-password" })}
|
||||
</a>
|
||||
<div className="dropdown-divider" />
|
||||
<a
|
||||
|
@@ -5,13 +5,14 @@ import { deleteUser } from "src/api/backend";
|
||||
import { Button, LoadingPage } from "src/components";
|
||||
import { useUser, useUsers } from "src/hooks";
|
||||
import { intl } from "src/locale";
|
||||
import { DeleteConfirmModal, PermissionsModal, UserModal } from "src/modals";
|
||||
import { DeleteConfirmModal, PermissionsModal, SetPasswordModal, UserModal } from "src/modals";
|
||||
import { showSuccess } from "src/notifications";
|
||||
import Table from "./Table";
|
||||
|
||||
export default function TableWrapper() {
|
||||
const [editUserId, setEditUserId] = useState(0 as number | "new");
|
||||
const [editUserPermissionsId, setEditUserPermissionsId] = useState(0);
|
||||
const [editUserPasswordId, setEditUserPasswordId] = useState(0);
|
||||
const [deleteUserId, setDeleteUserId] = useState(0);
|
||||
const { isFetching, isLoading, isError, error, data } = useUsers(["permissions"]);
|
||||
const { data: currentUser } = useUser("me");
|
||||
@@ -64,6 +65,7 @@ export default function TableWrapper() {
|
||||
currentUserId={currentUser?.id}
|
||||
onEditUser={(id: number) => setEditUserId(id)}
|
||||
onEditPermissions={(id: number) => setEditUserPermissionsId(id)}
|
||||
onSetPassword={(id: number) => setEditUserPasswordId(id)}
|
||||
onDeleteUser={(id: number) => setDeleteUserId(id)}
|
||||
onNewUser={() => setEditUserId("new")}
|
||||
/>
|
||||
@@ -81,6 +83,9 @@ export default function TableWrapper() {
|
||||
{intl.formatMessage({ id: "user.delete.content" })}
|
||||
</DeleteConfirmModal>
|
||||
) : null}
|
||||
{editUserPasswordId ? (
|
||||
<SetPasswordModal userId={editUserPasswordId} onClose={() => setEditUserPasswordId(0)} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@@ -1004,6 +1004,11 @@ assertion-error@^2.0.1:
|
||||
resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7"
|
||||
integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==
|
||||
|
||||
base64-js@^1.3.1:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
bootstrap@5.3.7:
|
||||
version "5.3.7"
|
||||
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.3.7.tgz#8640065036124d961d885d80b5945745e1154d90"
|
||||
@@ -1026,6 +1031,14 @@ browserslist@^4.24.0:
|
||||
node-releases "^2.0.19"
|
||||
update-browserslist-db "^1.1.3"
|
||||
|
||||
buffer@^6.0.3:
|
||||
version "6.0.3"
|
||||
resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6"
|
||||
integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==
|
||||
dependencies:
|
||||
base64-js "^1.3.1"
|
||||
ieee754 "^1.2.1"
|
||||
|
||||
cac@^6.7.14:
|
||||
version "6.7.14"
|
||||
resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959"
|
||||
@@ -1254,6 +1267,14 @@ fsevents@~2.3.2, fsevents@~2.3.3:
|
||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
|
||||
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
|
||||
|
||||
generate-password-browser@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/generate-password-browser/-/generate-password-browser-1.1.0.tgz#ec1661d0f3ce0b36e2ffdf6099578725a43d12e9"
|
||||
integrity sha512-qsQve0rVbCqGqAfKgZwjxKUfI1d1nyd22dz+kE8gn1iw1LxGkR+Slsl79XXfm2wxuK27IkopTs5KXcOEQnhg0w==
|
||||
dependencies:
|
||||
buffer "^6.0.3"
|
||||
randombytes "^2.0.5"
|
||||
|
||||
gensync@^1.0.0-beta.2:
|
||||
version "1.0.0-beta.2"
|
||||
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
||||
@@ -1285,6 +1306,11 @@ humps@^2.0.1:
|
||||
resolved "https://registry.yarnpkg.com/humps/-/humps-2.0.1.tgz#dd02ea6081bd0568dc5d073184463957ba9ef9aa"
|
||||
integrity sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==
|
||||
|
||||
ieee754@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
|
||||
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
|
||||
|
||||
immutable@^5.0.2:
|
||||
version "5.1.3"
|
||||
resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.3.tgz#e6486694c8b76c37c063cca92399fa64098634d4"
|
||||
@@ -1532,6 +1558,13 @@ raf@^3.4.1:
|
||||
dependencies:
|
||||
performance-now "^2.1.0"
|
||||
|
||||
randombytes@^2.0.5:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
|
||||
integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==
|
||||
dependencies:
|
||||
safe-buffer "^5.1.0"
|
||||
|
||||
react-bootstrap@^2.10.10:
|
||||
version "2.10.10"
|
||||
resolved "https://registry.yarnpkg.com/react-bootstrap/-/react-bootstrap-2.10.10.tgz#be0b0d951a69987152d75c0e6986c80425efdf21"
|
||||
@@ -1687,6 +1720,11 @@ rooks@^9.2.0:
|
||||
raf "^3.4.1"
|
||||
use-sync-external-store "^1.4.0"
|
||||
|
||||
safe-buffer@^5.1.0:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
|
||||
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
||||
|
||||
sass@^1.91.0:
|
||||
version "1.92.0"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.92.0.tgz#02d9ae21ce1763def2cd461449aac2eb56364796"
|
||||
|
Reference in New Issue
Block a user