audit logs view start
This commit is contained in:
parent
409b512886
commit
b4838d4d8b
55
components/AuditPage/AuditPage.module.scss
Normal file
55
components/AuditPage/AuditPage.module.scss
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.log {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: rgb(0 0 0 / 1%);
|
||||||
|
|
||||||
|
&:nth-child(2n + 1) .head {
|
||||||
|
background-color: rgb(0 0 0 / 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child .head {
|
||||||
|
background-color: transparent;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.flagged .head {
|
||||||
|
background-color: rgb(255, 169, 169);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.5fr repeat(2, 1fr) 0.25fr;
|
||||||
|
|
||||||
|
span {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.header {
|
||||||
|
span {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
display: grid;
|
||||||
|
row-gap: 0.5rem;
|
||||||
|
padding: 8px;
|
||||||
|
background-color: rgb(0 0 0 / 2%);
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
173
components/AuditPage/AuditPage.tsx
Normal file
173
components/AuditPage/AuditPage.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import useUser from '../../lib/hooks/useUser';
|
||||||
|
import conditionalClass from '../../lib/utils/conditional-class';
|
||||||
|
import { Button } from '../common/Button/Button';
|
||||||
|
import { Container } from '../common/Container/Container';
|
||||||
|
import { Header } from '../common/Header/Header';
|
||||||
|
import { Paginator } from '../common/Paginator/Paginator';
|
||||||
|
import styles from './AuditPage.module.scss';
|
||||||
|
|
||||||
|
export const AuditLogDetail = ({ entry }: { entry: any }) => {
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className={styles.detail}>
|
||||||
|
<span className={[styles.data, styles.ip_address].join(' ')}>
|
||||||
|
From IP: {entry.actor_ip}
|
||||||
|
</span>
|
||||||
|
{entry.location && (
|
||||||
|
<span className={[styles.data, styles.location].join(' ')}>
|
||||||
|
Approx. location: {entry.location.city}, {entry.location.country} (
|
||||||
|
{entry.location.ll.join(', ')})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{entry.actor_ua && (
|
||||||
|
<span className={[styles.data, styles.user_agent].join(' ')}>
|
||||||
|
User Agent: {entry.actor_ua}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{entry.user_agent && (
|
||||||
|
<span className={[styles.data, styles.user_agent_info].join(' ')}>
|
||||||
|
Browser: {entry.user_agent.browser} {entry.user_agent.version}
|
||||||
|
{' on '}
|
||||||
|
{entry.user_agent.platform} ({entry.user_agent.os})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{entry.content && (
|
||||||
|
<span className={[styles.data, styles.content].join(' ')}>
|
||||||
|
Log details:{' '}
|
||||||
|
{revealed ? (
|
||||||
|
entry.content
|
||||||
|
) : (
|
||||||
|
<Button onClick={() => setRevealed(true)} variant="link">
|
||||||
|
Reveal potentially sensitive information
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuditLog = ({ entry }: { entry: any }) => {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
styles.log,
|
||||||
|
conditionalClass(entry.flagged, styles.flagged),
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div className={styles.head}>
|
||||||
|
<span className={styles.action}>{entry.action}</span>
|
||||||
|
<span className={styles.timestamp}>
|
||||||
|
{new Date(entry.created_at).toString()}
|
||||||
|
</span>
|
||||||
|
<span className={styles.user}>{entry.actor?.username}</span>
|
||||||
|
<Button onClick={() => setExpanded(!expanded)} variant="link">
|
||||||
|
{expanded ? 'Hide' : 'Show'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{expanded && <AuditLogDetail entry={entry} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuditLogs = ({}) => {
|
||||||
|
const [searchParams, setSearchParams] = useState(new URLSearchParams({}));
|
||||||
|
const [types, setTypes] = useState<boolean[]>([]);
|
||||||
|
const [pageIndex, setPageIndex] = useState(1);
|
||||||
|
const { data } = useSWR(
|
||||||
|
`/api/admin/audit?page=${pageIndex}&${searchParams.toString()}`
|
||||||
|
);
|
||||||
|
const filter = useSWR<string[]>('/api/admin/audit/filter');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filter?.data || types.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTypes(Array.from<boolean>({ length: filter.data.length }).fill(true));
|
||||||
|
}, [types, filter.data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filter?.data || !types.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlparams = new URLSearchParams();
|
||||||
|
urlparams.set(
|
||||||
|
'actions',
|
||||||
|
filter.data
|
||||||
|
.reduce<string[]>((array, current, index) => {
|
||||||
|
return [...array, types[index] ? current : ''];
|
||||||
|
}, [])
|
||||||
|
.filter((item) => item)
|
||||||
|
.join(',')
|
||||||
|
);
|
||||||
|
|
||||||
|
setSearchParams(urlparams);
|
||||||
|
}, [filter.data, types]);
|
||||||
|
|
||||||
|
if (!data || !filter.data) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<div className={styles.boxes}>
|
||||||
|
{filter.data?.map((item: string, index: number) => (
|
||||||
|
<div className={styles.box} key={item}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={'chbx' + item}
|
||||||
|
checked={types[index]}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newTypes = types.slice();
|
||||||
|
newTypes[index] = e.target.checked;
|
||||||
|
setTypes(newTypes);
|
||||||
|
}}
|
||||||
|
/>{' '}
|
||||||
|
<label htmlFor={'chbx' + item}>{item}</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.logs}>
|
||||||
|
<div className={styles.log}>
|
||||||
|
<div className={[styles.head, styles.header].join(' ')}>
|
||||||
|
<span className={styles.action}>Action</span>
|
||||||
|
<span className={styles.timestamp}>Time</span>
|
||||||
|
<span className={styles.user}>Actor</span>
|
||||||
|
<span className={styles.actions}>Actions</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{data.list.map((entry: any) => (
|
||||||
|
<AuditLog entry={entry} key={entry.id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Paginator
|
||||||
|
setPage={setPageIndex}
|
||||||
|
pagination={data.pagination}
|
||||||
|
></Paginator>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuditPage = () => {
|
||||||
|
const { user } = useUser({ redirectTo: '/login' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header user={user}></Header>
|
||||||
|
<Container>
|
||||||
|
<h1>Audit logs</h1>
|
||||||
|
<AuditLogs />
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -62,6 +62,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.secondary {
|
&.link {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #0090d8;
|
||||||
|
|
||||||
|
&:not([disabled]) {
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,11 @@ const navItems = [
|
|||||||
title: 'OAuth2',
|
title: 'OAuth2',
|
||||||
privileges: [['admin', 'admin:oauth2'], 'self:oauth2'],
|
privileges: [['admin', 'admin:oauth2'], 'self:oauth2'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/audit',
|
||||||
|
title: 'Audit logs',
|
||||||
|
privileges: ['admin', 'admin:audit'],
|
||||||
|
},
|
||||||
// {
|
// {
|
||||||
// path: '/privileges',
|
// path: '/privileges',
|
||||||
// title: 'Privileges',
|
// title: 'Privileges',
|
||||||
|
@ -2,6 +2,7 @@ export const PUBLIC_URL = process.env.NEXT_PUBLIC_URL as string;
|
|||||||
export const SERVER_URL = process.env.SERVER_URL as string;
|
export const SERVER_URL = process.env.SERVER_URL as string;
|
||||||
export const API_URL = `${SERVER_URL}/api`;
|
export const API_URL = `${SERVER_URL}/api`;
|
||||||
export const OAUTH_URL = `${SERVER_URL}/oauth2`;
|
export const OAUTH_URL = `${SERVER_URL}/oauth2`;
|
||||||
|
export const REDIRECT_URL = `${PUBLIC_URL}/api/callback`;
|
||||||
export const CLIENT_ID = process.env.CLIENT_ID as string;
|
export const CLIENT_ID = process.env.CLIENT_ID as string;
|
||||||
export const CLIENT_SECRET = process.env.CLIENT_SECRET as string;
|
export const CLIENT_SECRET = process.env.CLIENT_SECRET as string;
|
||||||
export const COOKIE_KEYS = [process.env.COOKIE_KEY] as string[];
|
export const COOKIE_KEYS = [process.env.COOKIE_KEY] as string[];
|
||||||
|
@ -2,11 +2,9 @@ import { NextApiRequest, NextApiResponse } from 'next';
|
|||||||
import { getAccessToken } from '../../lib/api/remote';
|
import { getAccessToken } from '../../lib/api/remote';
|
||||||
|
|
||||||
import Cookies from 'cookies';
|
import Cookies from 'cookies';
|
||||||
import { COOKIE_KEYS, PUBLIC_URL } from '../../lib/constants';
|
import { COOKIE_KEYS, REDIRECT_URL } from '../../lib/constants';
|
||||||
import { decrypt } from '../../lib/utils/crypto';
|
import { decrypt } from '../../lib/utils/crypto';
|
||||||
|
|
||||||
const redirect = `${PUBLIC_URL}/api/callback`;
|
|
||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse
|
res: NextApiResponse
|
||||||
@ -25,7 +23,7 @@ export default async function handler(
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
parsedState.state !== stateToken ||
|
parsedState.state !== stateToken ||
|
||||||
parsedState.redirect_uri !== redirect
|
parsedState.redirect_uri !== REDIRECT_URL
|
||||||
) {
|
) {
|
||||||
return res.redirect('/');
|
return res.redirect('/');
|
||||||
}
|
}
|
||||||
@ -33,12 +31,14 @@ export default async function handler(
|
|||||||
cookies.set('authorization', getAuth.access_token, {
|
cookies.set('authorization', getAuth.access_token, {
|
||||||
expires: new Date(Date.now() + getAuth.expires_in * 1000),
|
expires: new Date(Date.now() + getAuth.expires_in * 1000),
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
signed: true,
|
signed: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
cookies.set('validation', undefined, {
|
cookies.set('validation', undefined, {
|
||||||
expires: new Date(0),
|
expires: new Date(0),
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
signed: true,
|
signed: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -4,17 +4,15 @@ import {
|
|||||||
CLIENT_ID,
|
CLIENT_ID,
|
||||||
COOKIE_KEYS,
|
COOKIE_KEYS,
|
||||||
OAUTH_URL,
|
OAUTH_URL,
|
||||||
PUBLIC_URL,
|
REDIRECT_URL,
|
||||||
} from '../../lib/constants';
|
} from '../../lib/constants';
|
||||||
import { encrypt, generateString } from '../../lib/utils/crypto';
|
import { encrypt, generateString } from '../../lib/utils/crypto';
|
||||||
|
|
||||||
const redirect = `${PUBLIC_URL}/api/callback`;
|
|
||||||
|
|
||||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const stateToken = generateString(16);
|
const stateToken = generateString(16);
|
||||||
const state = encrypt(
|
const state = encrypt(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
redirect_uri: redirect,
|
redirect_uri: REDIRECT_URL,
|
||||||
state: stateToken,
|
state: stateToken,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -22,7 +20,7 @@ export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_id: CLIENT_ID,
|
client_id: CLIENT_ID,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
redirect_uri: redirect,
|
redirect_uri: REDIRECT_URL,
|
||||||
scope: 'management',
|
scope: 'management',
|
||||||
state,
|
state,
|
||||||
});
|
});
|
||||||
|
3
pages/audit.tsx
Normal file
3
pages/audit.tsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { AuditPage } from '../components/AuditPage/AuditPage';
|
||||||
|
|
||||||
|
export default AuditPage;
|
Reference in New Issue
Block a user