mirror of
https://github.com/logto-io/logto.git
synced 2025-03-24 22:41:28 -05:00
feat(console): log details page (#1064)
This commit is contained in:
parent
8b1e9c2a81
commit
04211957e1
15 changed files with 299 additions and 7 deletions
|
@ -21,6 +21,7 @@ const ApiResources = React.lazy(async () => import('@/pages/ApiResources'));
|
|||
const ApplicationDetails = React.lazy(async () => import('@/pages/ApplicationDetails'));
|
||||
const Applications = React.lazy(async () => import('@/pages/Applications'));
|
||||
const AuditLogs = React.lazy(async () => import('@/pages/AuditLogs'));
|
||||
const AuditLogDetails = React.lazy(async () => import('@/pages/AuditLogDetails'));
|
||||
const Callback = React.lazy(async () => import('@/pages/Callback'));
|
||||
const ConnectorDetails = React.lazy(async () => import('@/pages/ConnectorDetails'));
|
||||
const Connectors = React.lazy(async () => import('@/pages/Connectors'));
|
||||
|
@ -76,6 +77,7 @@ const Main = () => {
|
|||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="audit-logs">
|
||||
<Route index element={<AuditLogs />} />
|
||||
<Route path=":logId" element={<AuditLogDetails />} />
|
||||
</Route>
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
</Route>
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
.link {
|
||||
text-decoration: none;
|
||||
color: var(--color-text-link);
|
||||
}
|
|
@ -1,21 +1,33 @@
|
|||
import { Application } from '@logto/schemas';
|
||||
import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
applicationId: string;
|
||||
isLink?: boolean;
|
||||
};
|
||||
|
||||
const ApplicationName = ({ applicationId }: Props) => {
|
||||
const ApplicationName = ({ applicationId, isLink = false }: Props) => {
|
||||
const isAdminConsole = applicationId === adminConsoleApplicationId;
|
||||
|
||||
const { data } = useSWR<Application>(!isAdminConsole && `/api/applications/${applicationId}`);
|
||||
|
||||
const name = isAdminConsole ? 'Admin Console' : data?.name;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
return <span>{name || '-'}</span>;
|
||||
const name = (isAdminConsole ? 'Admin Console' : data?.name) || '-';
|
||||
|
||||
if (isLink && !isAdminConsole) {
|
||||
return (
|
||||
<Link className={styles.link} to={`/applications/${applicationId}`} target="_blank">
|
||||
{name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{name}</span>;
|
||||
};
|
||||
|
||||
export default ApplicationName;
|
||||
|
|
|
@ -9,4 +9,9 @@
|
|||
color: var(--color-caption);
|
||||
margin-left: _.unit(1);
|
||||
}
|
||||
|
||||
.link {
|
||||
text-decoration: none;
|
||||
color: var(--color-text-link);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { User } from '@logto/schemas';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { RequestError } from '@/hooks/use-api';
|
||||
|
@ -9,13 +10,15 @@ import * as styles from './index.module.scss';
|
|||
|
||||
type Props = {
|
||||
userId: string;
|
||||
isLink?: boolean;
|
||||
};
|
||||
|
||||
const UserName = ({ userId }: Props) => {
|
||||
const UserName = ({ userId, isLink = false }: Props) => {
|
||||
const { data, error } = useSWR<User, RequestError>(`/api/users/${userId}`);
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const isLoading = !data && !error;
|
||||
const name = data?.name ?? t('users.unnamed');
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
|
@ -23,7 +26,13 @@ const UserName = ({ userId }: Props) => {
|
|||
|
||||
return (
|
||||
<div className={styles.userName}>
|
||||
<span>{data?.name ?? t('users.unnamed')}</span>
|
||||
{isLink ? (
|
||||
<Link to={`/users/${userId}`} target="_blank" className={styles.link}>
|
||||
{name}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{name}</span>
|
||||
)}
|
||||
<span className={styles.userId}>{userId}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -16,6 +16,6 @@ export const logEventTitle: LogEventTitle = Object.freeze({
|
|||
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',
|
||||
RefreshTokenExchangeToken: 'Exchange token by refresh token',
|
||||
RevokeToken: 'Revoke token',
|
||||
});
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
|
||||
const Failed = () => (
|
||||
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M49.1918 10.5544C47.5974 9.52057 45.469 9.97445 44.4363 11.5676L25.1691 41.2661L15.7728 32.5781C14.3779 31.2887 12.2036 31.3735 10.9141 32.7684C9.62469 34.1622 9.70951 36.3387 11.1044 37.627L23.4418 49.0338C23.4418 49.0338 23.796 49.3386 23.9599 49.4452C24.5376 49.8212 25.1863 50 25.8282 50C26.9537 50 28.0575 49.4475 28.7165 48.432L50.205 15.3099C51.2388 13.7167 50.7849 11.5871 49.1918 10.5544Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default Failed;
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
|
||||
const Success = () => (
|
||||
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M49.1918 10.5544C47.5974 9.52057 45.469 9.97445 44.4363 11.5676L25.1691 41.2661L15.7728 32.5781C14.3779 31.2887 12.2036 31.3735 10.9141 32.7684C9.62469 34.1622 9.70951 36.3387 11.1044 37.627L23.4418 49.0338C23.4418 49.0338 23.796 49.3386 23.9599 49.4452C24.5376 49.8212 25.1863 50 25.8282 50C26.9537 50 28.0575 49.4475 28.7165 48.432L50.205 15.3099C51.2388 13.7167 50.7849 11.5871 49.1918 10.5544Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default Success;
|
|
@ -0,0 +1,18 @@
|
|||
.icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
|
||||
&.success {
|
||||
color: var(--color-success-60);
|
||||
}
|
||||
|
||||
&.fail {
|
||||
color: var(--color-error-40);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--color-icon);
|
||||
font: var(--font-body-medium);
|
||||
text-align: center;
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Failed from './Failed';
|
||||
import Success from './Success';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
isSuccess: boolean;
|
||||
};
|
||||
|
||||
const EventIcon = ({ isSuccess }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={classNames(styles.icon, isSuccess ? styles.success : styles.fail)}>
|
||||
{isSuccess ? <Success /> : <Failed />}
|
||||
</div>
|
||||
<div className={styles.label}>
|
||||
{t(isSuccess ? 'log_details.success' : 'log_details.failed')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventIcon;
|
56
packages/console/src/pages/AuditLogDetails/index.module.scss
Normal file
56
packages/console/src/pages/AuditLogDetails/index.module.scss
Normal file
|
@ -0,0 +1,56 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.backLink {
|
||||
margin: _.unit(1) 0 0 _.unit(1);
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: _.unit(6);
|
||||
display: flex;
|
||||
|
||||
> *:not(:first-child) {
|
||||
margin-left: _.unit(6);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
|
||||
.eventName {
|
||||
color: var(--color-text);
|
||||
font: var(--font-title-large);
|
||||
}
|
||||
|
||||
.basicInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding-top: _.unit(4);
|
||||
|
||||
> * {
|
||||
width: 270px;
|
||||
margin: 0 _.unit(4) _.unit(4) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.infoItem {
|
||||
color: var(--color-text);
|
||||
font: var(--font-body-medium);
|
||||
|
||||
.label {
|
||||
color: var(--color-caption);
|
||||
font: var(--font-subhead-2);
|
||||
margin-bottom: _.unit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
> :not(:first-child) {
|
||||
margin-top: _.unit(4);
|
||||
}
|
||||
|
||||
.main {
|
||||
padding-bottom: _.unit(4);
|
||||
}
|
||||
}
|
104
packages/console/src/pages/AuditLogDetails/index.tsx
Normal file
104
packages/console/src/pages/AuditLogDetails/index.tsx
Normal file
|
@ -0,0 +1,104 @@
|
|||
import { LogDTO } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import dayjs from 'dayjs';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import ApplicationName from '@/components/ApplicationName';
|
||||
import Card from '@/components/Card';
|
||||
import CodeEditor from '@/components/CodeEditor';
|
||||
import DetailsSkeleton from '@/components/DetailsSkeleton';
|
||||
import LinkButton from '@/components/LinkButton';
|
||||
import TabNav, { TabNavItem } from '@/components/TabNav';
|
||||
import UserName from '@/components/UserName';
|
||||
import { logEventTitle } from '@/consts/logs';
|
||||
import { RequestError } from '@/hooks/use-api';
|
||||
import Back from '@/icons/Back';
|
||||
import * as detailsStyles from '@/scss/details.module.scss';
|
||||
|
||||
import EventIcon from './components/EventIcon';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const AuditLogDetails = () => {
|
||||
const { logId } = useParams();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { data, error } = useSWR<LogDTO, RequestError>(logId && `/api/logs/${logId}`);
|
||||
const isLoading = !data && !error;
|
||||
|
||||
return (
|
||||
<div className={detailsStyles.container}>
|
||||
<LinkButton
|
||||
to="/audit-logs"
|
||||
icon={<Back />}
|
||||
title="admin_console.log_details.back_to_logs"
|
||||
className={styles.backLink}
|
||||
/>
|
||||
{isLoading && <DetailsSkeleton />}
|
||||
{!data && error && <div>{`error occurred: ${error.body?.message ?? error.message}`}</div>}
|
||||
{data && (
|
||||
<>
|
||||
<Card className={styles.header}>
|
||||
<EventIcon isSuccess={data.payload.result === 'Success'} />
|
||||
<div className={styles.content}>
|
||||
<div className={styles.eventName}>{logEventTitle[data.type]}</div>
|
||||
<div className={styles.basicInfo}>
|
||||
<div className={styles.infoItem}>
|
||||
<div className={styles.label}>{t('log_details.event_type')}</div>
|
||||
<div>{data.type}</div>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<div className={styles.label}>{t('log_details.application')}</div>
|
||||
<div>
|
||||
{data.payload.applicationId ? (
|
||||
<ApplicationName isLink applicationId={data.payload.applicationId} />
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<div className={styles.label}>{t('log_details.ip_address')}</div>
|
||||
<div>{data.payload.ip ?? '-'}</div>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<div className={styles.label}>{t('log_details.user')}</div>
|
||||
<div>
|
||||
{data.payload.userId ? <UserName isLink userId={data.payload.userId} /> : '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<div className={styles.label}>{t('log_details.log_id')}</div>
|
||||
<div>{data.id}</div>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<div className={styles.label}>{t('log_details.time')}</div>
|
||||
<div>{dayjs(data.createdAt).toDate().toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.infoItem}>
|
||||
<div className={styles.label}>{t('log_details.user_agent')}</div>
|
||||
<div>{data.payload.userAgent}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={classNames(styles.body, detailsStyles.body)}>
|
||||
<TabNav>
|
||||
<TabNavItem href={`/audit-logs/${logId ?? ''}`}>
|
||||
{t('log_details.tab_details')}
|
||||
</TabNavItem>
|
||||
</TabNav>
|
||||
<div className={styles.main}>
|
||||
<CodeEditor language="json" value={JSON.stringify(data.payload, null, 2)} />
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditLogDetails;
|
|
@ -545,6 +545,20 @@ const translation = {
|
|||
time: 'Time',
|
||||
filter_by: 'Filter by',
|
||||
},
|
||||
log_details: {
|
||||
back_to_logs: 'Back to Audit Logs',
|
||||
success: 'Success',
|
||||
failed: 'Failed',
|
||||
event_type: 'Event Type',
|
||||
application: 'Application',
|
||||
ip_address: 'IP Address',
|
||||
user: 'User',
|
||||
log_id: 'Log ID',
|
||||
time: 'Time',
|
||||
user_agent: 'User Agent',
|
||||
tab_details: 'Details',
|
||||
raw_data: 'Raw data',
|
||||
},
|
||||
session_expired: {
|
||||
title: 'Session Expired',
|
||||
subtitle:
|
||||
|
|
|
@ -529,6 +529,20 @@ const translation = {
|
|||
time: '时间',
|
||||
filter_by: '过滤',
|
||||
},
|
||||
log_details: {
|
||||
back_to_logs: '返回审计日志',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
event_type: '事件类型',
|
||||
application: '应用',
|
||||
ip_address: 'IP 地址',
|
||||
user: '用户',
|
||||
log_id: '日志 ID',
|
||||
time: '时间',
|
||||
user_agent: '用户代理',
|
||||
tab_details: '详情',
|
||||
raw_data: '原始数据',
|
||||
},
|
||||
session_expired: {
|
||||
title: '会话已过期',
|
||||
subtitle: '会话可能已过期,您已被登出. 请点击下方按钮重新登录到管理界面.',
|
||||
|
|
|
@ -145,5 +145,7 @@ export type LogDTO = Omit<Log, 'payload'> & {
|
|||
userId?: string;
|
||||
applicationId?: string;
|
||||
result?: string;
|
||||
userAgent?: string;
|
||||
ip?: string;
|
||||
};
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue