mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(console): audit log table (#1000)
This commit is contained in:
parent
19380d0873
commit
fdd12de1cf
15 changed files with 321 additions and 1 deletions
|
@ -18,6 +18,7 @@ import ApiResourceDetails from './pages/ApiResourceDetails';
|
|||
import ApiResources from './pages/ApiResources';
|
||||
import ApplicationDetails from './pages/ApplicationDetails';
|
||||
import Applications from './pages/Applications';
|
||||
import AuditLogs from './pages/AuditLogs';
|
||||
import Callback from './pages/Callback';
|
||||
import ConnectorDetails from './pages/ConnectorDetails';
|
||||
import Connectors from './pages/Connectors';
|
||||
|
@ -84,6 +85,9 @@ const Main = () => {
|
|||
<Route index element={<Users />} />
|
||||
<Route path=":id" element={<UserDetails />} />
|
||||
</Route>
|
||||
<Route path="audit-logs">
|
||||
<Route index element={<AuditLogs />} />
|
||||
</Route>
|
||||
<Route path="sign-in-experience">
|
||||
<Route index element={<Navigate to="experience" />} />
|
||||
<Route path=":tab" element={<SignInExperience />} />
|
||||
|
|
12
packages/console/src/components/UserName/index.module.scss
Normal file
12
packages/console/src/components/UserName/index.module.scss
Normal file
|
@ -0,0 +1,12 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.userName {
|
||||
font: var(--body-medium);
|
||||
color: var(--color-text);
|
||||
|
||||
.userId {
|
||||
font: var(--body-small);
|
||||
color: var(--color-caption);
|
||||
margin-left: _.unit(1);
|
||||
}
|
||||
}
|
32
packages/console/src/components/UserName/index.tsx
Normal file
32
packages/console/src/components/UserName/index.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { User } from '@logto/schemas';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { RequestError } from '@/hooks/use-api';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
const UserName = ({ userId }: Props) => {
|
||||
const { data, error } = useSWR<User, RequestError>(`/api/users/${userId}`);
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const isLoading = !data && !error;
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.userName}>
|
||||
<span>{data?.name ?? t('users.unnamed')}</span>
|
||||
<span className={styles.userId}>{userId}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserName;
|
21
packages/console/src/consts/logs.ts
Normal file
21
packages/console/src/consts/logs.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
type LogEventTitle = Record<string, string>;
|
||||
|
||||
export const logEventTitle: LogEventTitle = Object.freeze({
|
||||
RegisterUsernamePassword: 'Register with username and password',
|
||||
RegisterEmailSendPasscode: 'Register with email (send passcode)',
|
||||
RegisterEmail: 'Register with email',
|
||||
RegisterSmsSendPasscode: 'Register with SMS (send passcode)',
|
||||
RegisterSms: 'Register with SMS',
|
||||
RegisterSocialBind: 'Bind social account',
|
||||
RegisterSocial: 'Register with social account',
|
||||
SignInUsernamePassword: 'Sign in with username and password',
|
||||
SignInEmailSendPasscode: 'Sign in with email (send passcode)',
|
||||
SignInEmail: 'Register with email',
|
||||
SignInSmsSendPasscode: 'Sign in with SMS (send passcode)',
|
||||
SignInSms: 'Sign in with SMS',
|
||||
SignInSocialBind: 'Sign in with social related account',
|
||||
SignInSocial: 'Sign in with social account',
|
||||
CodeExchangeToken: 'Exchange token by auth code',
|
||||
RefreshTokenExchangeToken: 'Exchagne token by refresh token',
|
||||
RevokeToken: 'Revoke token',
|
||||
});
|
12
packages/console/src/icons/Failed.tsx
Normal file
12
packages/console/src/icons/Failed.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
|
||||
const Failed = () => (
|
||||
<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 Failed;
|
12
packages/console/src/icons/Success.tsx
Normal file
12
packages/console/src/icons/Success.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
|
||||
const Success = () => (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M19.6767 4.22177C19.039 3.80823 18.1876 3.98978 17.7745 4.62705L10.0676 16.5065L6.30911 13.0313C5.75115 12.5155 4.88143 12.5494 4.36565 13.1074C3.84988 13.6649 3.8838 14.5355 4.44176 15.0508L9.37673 19.6135C9.37673 19.6135 9.5184 19.7355 9.58396 19.7781C9.81503 19.9285 10.0745 20 10.3313 20C10.7815 20 11.223 19.779 11.4866 19.3728L20.082 6.12396C20.4955 5.48668 20.314 4.63485 19.6767 4.22177Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default Success;
|
|
@ -0,0 +1,28 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.eventName {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
margin-right: _.unit(1);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
&.success {
|
||||
color: var(--color-success-60);
|
||||
}
|
||||
|
||||
&.fail {
|
||||
color: var(--color-error-40);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font: var(--font-body-medium);
|
||||
color: var(--color-text-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { logEventTitle } from '@/consts/logs';
|
||||
import Failed from '@/icons/Failed';
|
||||
import Success from '@/icons/Success';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
type: string;
|
||||
isSuccess: boolean;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
const EventName = ({ type, isSuccess, to }: Props) => {
|
||||
const title = logEventTitle[type] ?? type;
|
||||
|
||||
return (
|
||||
<div className={styles.eventName}>
|
||||
<div className={classNames(styles.icon, isSuccess ? styles.success : styles.fail)}>
|
||||
{isSuccess ? <Success /> : <Failed />}
|
||||
</div>
|
||||
<div>
|
||||
{to && (
|
||||
<Link
|
||||
className={styles.title}
|
||||
to={to}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
)}
|
||||
{!to && <div className={styles.title}>{title}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventName;
|
24
packages/console/src/pages/AuditLogs/index.module.scss
Normal file
24
packages/console/src/pages/AuditLogs/index.module.scss
Normal file
|
@ -0,0 +1,24 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.card {
|
||||
@include _.flex-column;
|
||||
}
|
||||
|
||||
.headline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-top: _.unit(4);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: _.unit(4);
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.eventName {
|
||||
width: 360px;
|
||||
}
|
106
packages/console/src/pages/AuditLogs/index.tsx
Normal file
106
packages/console/src/pages/AuditLogs/index.tsx
Normal file
|
@ -0,0 +1,106 @@
|
|||
import { LogDTO, LogResult } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import ApplicationName from '@/components/ApplicationName';
|
||||
import Card from '@/components/Card';
|
||||
import CardTitle from '@/components/CardTitle';
|
||||
import Pagination from '@/components/Pagination';
|
||||
import TableEmpty from '@/components/Table/TableEmpty';
|
||||
import TableError from '@/components/Table/TableError';
|
||||
import TableLoading from '@/components/Table/TableLoading';
|
||||
import UserName from '@/components/UserName';
|
||||
import { RequestError } from '@/hooks/use-api';
|
||||
import * as tableStyles from '@/scss/table.module.scss';
|
||||
|
||||
import EventName from './components/EventName';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
const AuditLogs = () => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const [query, setQuery] = useSearchParams();
|
||||
const pageIndex = Number(query.get('page') ?? '1');
|
||||
const { data, error, mutate } = useSWR<[LogDTO[], number], RequestError>(
|
||||
`/api/logs?page=${pageIndex}&page_size=${pageSize}`
|
||||
);
|
||||
const isLoading = !data && !error;
|
||||
const navigate = useNavigate();
|
||||
const [logs, totalCount] = data ?? [];
|
||||
|
||||
return (
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.headline}>
|
||||
<CardTitle title="logs.title" subtitle="logs.subtitle" />
|
||||
</div>
|
||||
<div className={classNames(styles.table, tableStyles.scrollable)}>
|
||||
<table>
|
||||
<colgroup>
|
||||
<col className={styles.eventName} />
|
||||
<col />
|
||||
<col />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('logs.event')}</th>
|
||||
<th>{t('logs.user')}</th>
|
||||
<th>{t('logs.application')}</th>
|
||||
<th>{t('logs.time')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!data && error && (
|
||||
<TableError
|
||||
columns={4}
|
||||
content={error.body?.message ?? error.message}
|
||||
onRetry={async () => mutate(undefined, true)}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <TableLoading columns={2} />}
|
||||
{logs?.length === 0 && <TableEmpty columns={2} />}
|
||||
{logs?.map(({ type, payload, createdAt, id }) => (
|
||||
<tr
|
||||
key={id}
|
||||
className={tableStyles.clickable}
|
||||
onClick={() => {
|
||||
navigate(`/audit-logs/${id}`);
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
<EventName type={type} isSuccess={payload.result === LogResult.Success} />
|
||||
</td>
|
||||
<td>{payload.userId ? <UserName userId={payload.userId} /> : '-'}</td>
|
||||
<td>
|
||||
{payload.applicationId ? (
|
||||
<ApplicationName applicationId={payload.applicationId} />
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td>{new Date(createdAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className={styles.pagination}>
|
||||
{!!totalCount && (
|
||||
<Pagination
|
||||
pageCount={Math.ceil(totalCount / pageSize)}
|
||||
pageIndex={pageIndex}
|
||||
onChange={(page) => {
|
||||
setQuery({ page: String(page) });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditLogs;
|
|
@ -7,6 +7,7 @@ import Modal from 'react-modal';
|
|||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import ApplicationName from '@/components/ApplicationName';
|
||||
import Button from '@/components/Button';
|
||||
import Card from '@/components/Card';
|
||||
import CardTitle from '@/components/CardTitle';
|
||||
|
@ -23,7 +24,6 @@ import Plus from '@/icons/Plus';
|
|||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import * as tableStyles from '@/scss/table.module.scss';
|
||||
|
||||
import ApplicationName from './components/ApplicationName';
|
||||
import CreateForm from './components/CreateForm';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
|
|
|
@ -531,6 +531,14 @@ const translation = {
|
|||
weekly_active_users: 'Weeky active users',
|
||||
monthly_active_users: 'Monthly active users',
|
||||
},
|
||||
logs: {
|
||||
title: 'Audit Logs',
|
||||
subtitle: 'View log data of authentication events made by your admin and users.',
|
||||
event: 'Event',
|
||||
user: 'User',
|
||||
application: 'Application',
|
||||
time: 'Time',
|
||||
},
|
||||
session_expired: {
|
||||
title: 'Session Expired',
|
||||
subtitle:
|
||||
|
|
|
@ -527,6 +527,14 @@ const translation = {
|
|||
weekly_active_users: '周活用户',
|
||||
monthly_active_users: '月活用户',
|
||||
},
|
||||
logs: {
|
||||
title: '审计日志',
|
||||
subtitle: '查看用户日志',
|
||||
event: '事件',
|
||||
user: '用户',
|
||||
application: '应用',
|
||||
time: '时间',
|
||||
},
|
||||
session_expired: {
|
||||
title: '会话已过期',
|
||||
subtitle: '会话可能已过期,您已被登出. 请点击下方按钮重新登录到管理界面.',
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { Log } from '../db-entries';
|
||||
|
||||
export enum LogResult {
|
||||
Success = 'Success',
|
||||
Error = 'Error',
|
||||
|
@ -137,3 +139,11 @@ export type LogPayloads = {
|
|||
export type LogType = keyof LogPayloads;
|
||||
|
||||
export type LogPayload = LogPayloads[LogType];
|
||||
|
||||
export type LogDTO = Omit<Log, 'payload'> & {
|
||||
payload: {
|
||||
userId?: string;
|
||||
applicationId?: string;
|
||||
result?: string;
|
||||
};
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue