mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
feat(console): audit log filters (#1004)
* feat(console): audit log table * feat(console): audit log filters
This commit is contained in:
parent
498b3708ef
commit
a0d562f7f2
11 changed files with 183 additions and 9 deletions
packages
console/src
components
icons
pages
AuditLogs
UserDetails/components
phrases/src/locales
|
@ -37,6 +37,9 @@
|
|||
}
|
||||
|
||||
&.small {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
|
||||
> svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
|
@ -44,6 +47,9 @@
|
|||
}
|
||||
|
||||
&.medium {
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
|
||||
> svg {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
|
@ -51,6 +57,9 @@
|
|||
}
|
||||
|
||||
&.large {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
|
||||
> svg {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
|
|
|
@ -36,6 +36,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
.clear {
|
||||
position: absolute;
|
||||
right: _.unit(2);
|
||||
top: 6px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
right: _.unit(2);
|
||||
|
@ -45,4 +52,14 @@
|
|||
fill: var(--color-icon);
|
||||
}
|
||||
}
|
||||
|
||||
&.clearable:hover {
|
||||
.clear {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { ReactNode, useRef, useState } from 'react';
|
||||
import React, { ReactEventHandler, ReactNode, useRef, useState } from 'react';
|
||||
|
||||
import { KeyboardArrowDown, KeyboardArrowUp } from '@/icons/Arrow';
|
||||
import Close from '@/icons/Close';
|
||||
|
||||
import Dropdown, { DropdownItem } from '../Dropdown';
|
||||
import IconButton from '../IconButton';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Option = {
|
||||
|
@ -14,12 +16,22 @@ type Option = {
|
|||
type Props = {
|
||||
value?: string;
|
||||
options: Option[];
|
||||
onChange?: (value: string) => void;
|
||||
onChange?: (value?: string) => void;
|
||||
isReadOnly?: boolean;
|
||||
hasError?: boolean;
|
||||
placeholder?: ReactNode;
|
||||
isClearable?: boolean;
|
||||
};
|
||||
|
||||
const Select = ({ value, options, onChange, isReadOnly, hasError }: Props) => {
|
||||
const Select = ({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
isReadOnly,
|
||||
hasError,
|
||||
placeholder,
|
||||
isClearable,
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLInputElement>(null);
|
||||
const current = options.find((option) => value && option.value === value);
|
||||
|
@ -29,6 +41,12 @@ const Select = ({ value, options, onChange, isReadOnly, hasError }: Props) => {
|
|||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleClear: ReactEventHandler<HTMLButtonElement> = (event) => {
|
||||
onChange?.(undefined);
|
||||
setIsOpen(false);
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
|
@ -37,7 +55,8 @@ const Select = ({ value, options, onChange, isReadOnly, hasError }: Props) => {
|
|||
styles.select,
|
||||
isOpen && styles.open,
|
||||
isReadOnly && styles.readOnly,
|
||||
hasError && styles.error
|
||||
hasError && styles.error,
|
||||
isClearable && value && styles.clearable
|
||||
)}
|
||||
role="button"
|
||||
onClick={() => {
|
||||
|
@ -46,7 +65,12 @@ const Select = ({ value, options, onChange, isReadOnly, hasError }: Props) => {
|
|||
}
|
||||
}}
|
||||
>
|
||||
{current?.title}
|
||||
{current?.title ?? placeholder}
|
||||
{isClearable && (
|
||||
<IconButton className={styles.clear} size="small" onClick={handleClear}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
)}
|
||||
<div className={styles.arrow}>{isOpen ? <KeyboardArrowUp /> : <KeyboardArrowDown />}</div>
|
||||
</div>
|
||||
<Dropdown
|
||||
|
|
12
packages/console/src/icons/Fail.tsx
Normal file
12
packages/console/src/icons/Fail.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
|
||||
const Fail = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M14.2664 11.9937L19.5317 6.7284C20.1561 6.10454 20.1561 5.09229 19.5317 4.4679C18.9073 3.84403 17.8961 3.84403 17.2717 4.4679L12.0065 9.73317L6.74119 4.46843C6.11732 3.84403 5.10508 3.84403 4.48068 4.46843C3.85682 5.09229 3.85682 6.10454 4.48068 6.72893L9.74596 11.9937L4.4679 17.2717C3.84403 17.8956 3.84403 18.9078 4.4679 19.5317C4.77956 19.8439 5.18925 20 5.59788 20C6.00651 20 6.4162 19.8439 6.7284 19.5317L12.0059 14.2542L17.2707 19.5189C17.5829 19.8311 17.992 19.9872 18.4007 19.9872C18.8093 19.9872 19.2184 19.8311 19.5306 19.5189C20.155 18.8951 20.155 17.8828 19.5306 17.2589L14.2664 11.9937Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default Fail;
|
|
@ -0,0 +1,33 @@
|
|||
import { adminConsoleApplicationId, Application } from '@logto/schemas';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import Select from '@/components/Select';
|
||||
|
||||
type Props = {
|
||||
value?: string;
|
||||
onChange: (value?: string) => void;
|
||||
};
|
||||
|
||||
const ApplicationSelector = ({ value, onChange }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { data } = useSWR<Application[]>('/api/applications');
|
||||
const options =
|
||||
data?.map(({ id, name }) => ({
|
||||
value: id,
|
||||
title: name,
|
||||
})) ?? [];
|
||||
|
||||
return (
|
||||
<Select
|
||||
isClearable
|
||||
value={value}
|
||||
options={[{ value: adminConsoleApplicationId, title: 'Admin Console' }, ...options]}
|
||||
placeholder={t('logs.application')}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApplicationSelector;
|
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Select from '@/components/Select';
|
||||
import { logEventTitle } from '@/consts/logs';
|
||||
|
||||
type Props = {
|
||||
value?: string;
|
||||
onChange: (value?: string) => void;
|
||||
};
|
||||
|
||||
const EventSelector = ({ value, onChange }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const options = Object.entries(logEventTitle).map(([value, title]) => ({
|
||||
value,
|
||||
title,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Select
|
||||
isClearable
|
||||
value={value}
|
||||
options={options}
|
||||
placeholder={t('logs.event')}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventSelector;
|
|
@ -9,6 +9,27 @@
|
|||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
align-items: center;
|
||||
|
||||
.title {
|
||||
color: var(--color-caption);
|
||||
font: var(--font-body-medium);
|
||||
}
|
||||
|
||||
.eventSelector {
|
||||
width: 300px;
|
||||
margin-left: _.unit(2);
|
||||
}
|
||||
|
||||
.applicationSelector {
|
||||
width: 250px;
|
||||
margin-left: _.unit(2);
|
||||
}
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-top: _.unit(4);
|
||||
flex: 1;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { LogDTO, LogResult } from '@logto/schemas';
|
||||
import { conditionalString } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
@ -16,7 +17,9 @@ import UserName from '@/components/UserName';
|
|||
import { RequestError } from '@/hooks/use-api';
|
||||
import * as tableStyles from '@/scss/table.module.scss';
|
||||
|
||||
import ApplicationSelector from './components/ApplicationSelector';
|
||||
import EventName from './components/EventName';
|
||||
import EventSelector from './components/EventSelector';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const pageSize = 20;
|
||||
|
@ -25,8 +28,12 @@ const AuditLogs = () => {
|
|||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const [query, setQuery] = useSearchParams();
|
||||
const pageIndex = Number(query.get('page') ?? '1');
|
||||
const event = query.get('event');
|
||||
const applicationId = query.get('applicationId');
|
||||
const { data, error, mutate } = useSWR<[LogDTO[], number], RequestError>(
|
||||
`/api/logs?page=${pageIndex}&page_size=${pageSize}`
|
||||
`/api/logs?page=${pageIndex}&page_size=${pageSize}${conditionalString(
|
||||
event && `&logType=${event}`
|
||||
)}${conditionalString(applicationId && `&applicationId=${applicationId}`)}`
|
||||
);
|
||||
const isLoading = !data && !error;
|
||||
const navigate = useNavigate();
|
||||
|
@ -37,6 +44,25 @@ const AuditLogs = () => {
|
|||
<div className={styles.headline}>
|
||||
<CardTitle title="logs.title" subtitle="logs.subtitle" />
|
||||
</div>
|
||||
<div className={styles.filter}>
|
||||
<div className={styles.title}>{t('logs.filter_by')}</div>
|
||||
<div className={styles.eventSelector}>
|
||||
<EventSelector
|
||||
value={event ?? undefined}
|
||||
onChange={(value) => {
|
||||
setQuery({ event: value ?? '' });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.applicationSelector}>
|
||||
<ApplicationSelector
|
||||
value={applicationId ?? undefined}
|
||||
onChange={(value) => {
|
||||
setQuery({ applicationId: value ?? '' });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classNames(styles.table, tableStyles.scrollable)}>
|
||||
<table>
|
||||
<colgroup>
|
||||
|
@ -61,8 +87,8 @@ const AuditLogs = () => {
|
|||
onRetry={async () => mutate(undefined, true)}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <TableLoading columns={2} />}
|
||||
{logs?.length === 0 && <TableEmpty columns={2} />}
|
||||
{isLoading && <TableLoading columns={4} />}
|
||||
{logs?.length === 0 && <TableEmpty columns={4} />}
|
||||
{logs?.map(({ type, payload, createdAt, id }) => (
|
||||
<tr
|
||||
key={id}
|
||||
|
|
|
@ -34,7 +34,7 @@ const RoleSelect = ({ value, onChange }: Props) => {
|
|||
throw new Error('Unsupported user role value');
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
const handleChange = (value?: string) => {
|
||||
onChange?.(value === roleAdmin ? ['admin'] : []);
|
||||
};
|
||||
|
||||
|
|
|
@ -538,6 +538,7 @@ const translation = {
|
|||
user: 'User',
|
||||
application: 'Application',
|
||||
time: 'Time',
|
||||
filter_by: 'Filter by',
|
||||
},
|
||||
session_expired: {
|
||||
title: 'Session Expired',
|
||||
|
|
|
@ -534,6 +534,7 @@ const translation = {
|
|||
user: '用户',
|
||||
application: '应用',
|
||||
time: '时间',
|
||||
filter_by: '过滤',
|
||||
},
|
||||
session_expired: {
|
||||
title: '会话已过期',
|
||||
|
|
Loading…
Add table
Reference in a new issue