0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(console): user logs (#1082)

This commit is contained in:
Wang Sijie 2022-06-10 10:33:46 +08:00 committed by GitHub
parent 1b8190addf
commit c4a0d7ae35
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 292 additions and 237 deletions

View file

@ -71,6 +71,7 @@ const Main = () => {
<Route path="users">
<Route index element={<Users />} />
<Route path=":id" element={<UserDetails />} />
<Route path=":id/logs" element={<UserDetails />} />
</Route>
<Route path="sign-in-experience">
<Route index element={<Navigate to="experience" />} />

View file

@ -0,0 +1,36 @@
@use '@/scss/underscore' as _;
.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;
}
.pagination {
margin-top: _.unit(4);
min-height: 32px;
}
.eventName {
width: 360px;
}

View file

@ -0,0 +1,152 @@
import { LogDTO, LogResult } from '@logto/schemas';
import { conditionalString } from '@silverhand/essentials';
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 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 ApplicationSelector from './components/ApplicationSelector';
import EventName from './components/EventName';
import EventSelector from './components/EventSelector';
import * as styles from './index.module.scss';
const pageSize = 20;
type Props = {
userId?: string;
};
const AuditLogTable = ({ userId }: Props) => {
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 queryString = [
`page=${pageIndex}`,
`page_size=${pageSize}`,
conditionalString(event && `logType=${event}`),
conditionalString(applicationId && `applicationId=${applicationId}`),
conditionalString(userId && `userId=${userId}`),
]
.filter(Boolean)
.join('&');
const { data, error, mutate } = useSWR<[LogDTO[], number], RequestError>(
`/api/logs?${queryString}`
);
const isLoading = !data && !error;
const navigate = useNavigate();
const [logs, totalCount] = data ?? [];
const updateQuery = (key: string, value: string) => {
const queries: Record<string, string> = {};
for (const [key, value] of query.entries()) {
// eslint-disable-next-line @silverhand/fp/no-mutation
queries[key] = value;
}
setQuery({
...queries,
[key]: value,
});
};
return (
<>
<div className={styles.filter}>
<div className={styles.title}>{t('logs.filter_by')}</div>
<div className={styles.eventSelector}>
<EventSelector
value={event ?? undefined}
onChange={(value) => {
updateQuery('event', value ?? '');
}}
/>
</div>
<div className={styles.applicationSelector}>
<ApplicationSelector
value={applicationId ?? undefined}
onChange={(value) => {
updateQuery('applicationId', value ?? '');
}}
/>
</div>
</div>
<div className={classNames(styles.table, tableStyles.scrollable)}>
<table className={classNames(logs?.length === 0 && tableStyles.empty)}>
<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={4} />}
{logs?.length === 0 && <TableEmpty columns={4} />}
{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) => {
updateQuery('page', String(page));
}}
/>
)}
</div>
</>
);
};
export default AuditLogTable;

View file

@ -8,38 +8,3 @@
display: flex;
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;
}
.pagination {
margin-top: _.unit(4);
min-height: 32px;
}
.eventName {
width: 360px;
}

View file

@ -1,130 +1,18 @@
import { LogDTO, LogResult } from '@logto/schemas';
import { conditionalString } from '@silverhand/essentials';
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 AuditLogTable from '@/components/AuditLogTable';
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 ApplicationSelector from './components/ApplicationSelector';
import EventName from './components/EventName';
import EventSelector from './components/EventSelector';
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 event = query.get('event');
const applicationId = query.get('applicationId');
const { data, error, mutate } = useSWR<[LogDTO[], number], RequestError>(
`/api/logs?page=${pageIndex}&page_size=${pageSize}${conditionalString(
event && `&logType=${event}`
)}${conditionalString(applicationId && `&applicationId=${applicationId}`)}`
);
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={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 className={classNames(!data && tableStyles.empty)}>
<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={4} />}
{logs?.length === 0 && <TableEmpty columns={4} />}
{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>
<AuditLogTable />
</Card>
);
};

View file

@ -76,4 +76,8 @@
.textField {
@include _.form-text-field;
}
.logs {
padding: _.unit(6) 0;
}
}

View file

@ -6,10 +6,11 @@ import { Controller, useController, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import { useParams } from 'react-router-dom';
import { useLocation, useParams } from 'react-router-dom';
import useSWR from 'swr';
import ActionMenu, { ActionMenuItem } from '@/components/ActionMenu';
import AuditLogTable from '@/components/AuditLogTable';
import Button from '@/components/Button';
import Card from '@/components/Card';
import CodeEditor from '@/components/CodeEditor';
@ -48,6 +49,8 @@ type FormData = {
};
const UserDetails = () => {
const location = useLocation();
const isLogs = location.pathname.endsWith('/logs');
const { id } = useParams();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [isDeleteFormOpen, setIsDeleteFormOpen] = useState(false);
@ -186,95 +189,101 @@ const UserDetails = () => {
<TabNavItem href={`/users/${id}`}>{t('user_details.tab_settings')}</TabNavItem>
<TabNavItem href={`/users/${id}/logs`}>{t('user_details.tab_logs')}</TabNavItem>
</TabNav>
<form className={styles.form} onSubmit={onSubmit}>
<div className={styles.fields}>
{getValues('primaryEmail') && (
<FormField
title="admin_console.user_details.field_email"
className={styles.textField}
>
<TextInput readOnly {...register('primaryEmail')} />
</FormField>
)}
{getValues('primaryPhone') && (
<FormField
title="admin_console.user_details.field_phone"
className={styles.textField}
>
<TextInput readOnly {...register('primaryPhone')} />
</FormField>
)}
{getValues('username') && (
<FormField
title="admin_console.user_details.field_username"
className={styles.textField}
>
<TextInput readOnly {...register('username')} />
</FormField>
)}
<FormField
title="admin_console.user_details.field_name"
className={styles.textField}
>
<TextInput {...register('name')} />
</FormField>
<FormField
title="admin_console.user_details.field_avatar"
className={styles.textField}
>
<TextInput
{...register('avatar', {
validate: (value) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
hasError={Boolean(errors.avatar)}
errorMessage={errors.avatar?.message}
/>
</FormField>
<FormField
title="admin_console.user_details.field_roles"
className={styles.textField}
>
<Controller
name="roleNames"
control={control}
render={({ field: { value, onChange } }) => (
<RoleSelect value={value} onChange={onChange} />
)}
/>
</FormField>
<FormField
title="admin_console.user_details.field_connectors"
className={styles.textField}
>
<UserConnectors
userId={data.id}
connectors={data.identities}
onDelete={() => {
void mutate();
}}
/>
</FormField>
<FormField
isRequired
title="admin_console.user_details.field_custom_data"
className={styles.textField}
>
<CodeEditor language="json" value={value} onChange={onChange} />
</FormField>
{isLogs ? (
<div className={styles.logs}>
<AuditLogTable userId={data.id} />
</div>
<div className={detailsStyles.footer}>
<div className={detailsStyles.footerMain}>
<Button
isLoading={isSubmitting}
htmlType="submit"
type="primary"
title="admin_console.user_details.save_changes"
size="large"
/>
) : (
<form className={styles.form} onSubmit={onSubmit}>
<div className={styles.fields}>
{getValues('primaryEmail') && (
<FormField
title="admin_console.user_details.field_email"
className={styles.textField}
>
<TextInput readOnly {...register('primaryEmail')} />
</FormField>
)}
{getValues('primaryPhone') && (
<FormField
title="admin_console.user_details.field_phone"
className={styles.textField}
>
<TextInput readOnly {...register('primaryPhone')} />
</FormField>
)}
{getValues('username') && (
<FormField
title="admin_console.user_details.field_username"
className={styles.textField}
>
<TextInput readOnly {...register('username')} />
</FormField>
)}
<FormField
title="admin_console.user_details.field_name"
className={styles.textField}
>
<TextInput {...register('name')} />
</FormField>
<FormField
title="admin_console.user_details.field_avatar"
className={styles.textField}
>
<TextInput
{...register('avatar', {
validate: (value) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
hasError={Boolean(errors.avatar)}
errorMessage={errors.avatar?.message}
/>
</FormField>
<FormField
title="admin_console.user_details.field_roles"
className={styles.textField}
>
<Controller
name="roleNames"
control={control}
render={({ field: { value, onChange } }) => (
<RoleSelect value={value} onChange={onChange} />
)}
/>
</FormField>
<FormField
title="admin_console.user_details.field_connectors"
className={styles.textField}
>
<UserConnectors
userId={data.id}
connectors={data.identities}
onDelete={() => {
void mutate();
}}
/>
</FormField>
<FormField
isRequired
title="admin_console.user_details.field_custom_data"
className={styles.textField}
>
<CodeEditor language="json" value={value} onChange={onChange} />
</FormField>
</div>
</div>
</form>
<div className={detailsStyles.footer}>
<div className={detailsStyles.footerMain}>
<Button
isLoading={isSubmitting}
htmlType="submit"
type="primary"
title="admin_console.user_details.save_changes"
size="large"
/>
</div>
</div>
</form>
)}
</Card>
</>
)}