diff --git a/components/AuditPage/AuditPage.module.scss b/components/AuditPage/AuditPage.module.scss new file mode 100644 index 0000000..c864d10 --- /dev/null +++ b/components/AuditPage/AuditPage.module.scss @@ -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; + } + } + } +} diff --git a/components/AuditPage/AuditPage.tsx b/components/AuditPage/AuditPage.tsx new file mode 100644 index 0000000..d03f008 --- /dev/null +++ b/components/AuditPage/AuditPage.tsx @@ -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 ( +
+ + From IP: {entry.actor_ip} + + {entry.location && ( + + Approx. location: {entry.location.city}, {entry.location.country} ( + {entry.location.ll.join(', ')}) + + )} + {entry.actor_ua && ( + + User Agent: {entry.actor_ua} + + )} + {entry.user_agent && ( + + Browser: {entry.user_agent.browser} {entry.user_agent.version} + {' on '} + {entry.user_agent.platform} ({entry.user_agent.os}) + + )} + {entry.content && ( + + Log details:{' '} + {revealed ? ( + entry.content + ) : ( + + )} + + )} +
+ ); +}; + +export const AuditLog = ({ entry }: { entry: any }) => { + const [expanded, setExpanded] = useState(false); + + return ( +
+
+ {entry.action} + + {new Date(entry.created_at).toString()} + + {entry.actor?.username} + +
+ {expanded && } +
+ ); +}; + +export const AuditLogs = ({}) => { + const [searchParams, setSearchParams] = useState(new URLSearchParams({})); + const [types, setTypes] = useState([]); + const [pageIndex, setPageIndex] = useState(1); + const { data } = useSWR( + `/api/admin/audit?page=${pageIndex}&${searchParams.toString()}` + ); + const filter = useSWR('/api/admin/audit/filter'); + + useEffect(() => { + if (!filter?.data || types.length) { + return; + } + + setTypes(Array.from({ 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((array, current, index) => { + return [...array, types[index] ? current : '']; + }, []) + .filter((item) => item) + .join(',') + ); + + setSearchParams(urlparams); + }, [filter.data, types]); + + if (!data || !filter.data) { + return <>; + } + + return ( +
+
+
+ {filter.data?.map((item: string, index: number) => ( +
+ { + const newTypes = types.slice(); + newTypes[index] = e.target.checked; + setTypes(newTypes); + }} + />{' '} + +
+ ))} +
+
+ +
+
+
+ Action + Time + Actor + Actions +
+
+ {data.list.map((entry: any) => ( + + ))} +
+ + +
+ ); +}; + +export const AuditPage = () => { + const { user } = useUser({ redirectTo: '/login' }); + + return ( + <> +
+ +

Audit logs

+ +
+ + ); +}; diff --git a/components/common/Button/Button.module.scss b/components/common/Button/Button.module.scss index b473530..a03d5c1 100644 --- a/components/common/Button/Button.module.scss +++ b/components/common/Button/Button.module.scss @@ -62,6 +62,16 @@ } } } - &.secondary { + &.link { + border: 0; + background: transparent; + color: #0090d8; + + &:not([disabled]) { + &:hover, + &:focus-visible { + text-decoration: underline; + } + } } } diff --git a/components/common/Header/Header.tsx b/components/common/Header/Header.tsx index b67cd5f..8076c48 100644 --- a/components/common/Header/Header.tsx +++ b/components/common/Header/Header.tsx @@ -21,6 +21,11 @@ const navItems = [ title: 'OAuth2', privileges: [['admin', 'admin:oauth2'], 'self:oauth2'], }, + { + path: '/audit', + title: 'Audit logs', + privileges: ['admin', 'admin:audit'], + }, // { // path: '/privileges', // title: 'Privileges', diff --git a/lib/constants.ts b/lib/constants.ts index c895fc6..9ad2b34 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -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 API_URL = `${SERVER_URL}/api`; 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_SECRET = process.env.CLIENT_SECRET as string; export const COOKIE_KEYS = [process.env.COOKIE_KEY] as string[]; diff --git a/pages/api/callback.ts b/pages/api/callback.ts index a27e771..4c3eb45 100644 --- a/pages/api/callback.ts +++ b/pages/api/callback.ts @@ -2,11 +2,9 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { getAccessToken } from '../../lib/api/remote'; 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'; -const redirect = `${PUBLIC_URL}/api/callback`; - export default async function handler( req: NextApiRequest, res: NextApiResponse @@ -25,7 +23,7 @@ export default async function handler( if ( parsedState.state !== stateToken || - parsedState.redirect_uri !== redirect + parsedState.redirect_uri !== REDIRECT_URL ) { return res.redirect('/'); } @@ -33,12 +31,14 @@ export default async function handler( cookies.set('authorization', getAuth.access_token, { expires: new Date(Date.now() + getAuth.expires_in * 1000), secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', signed: true, }); cookies.set('validation', undefined, { expires: new Date(0), secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', signed: true, }); } diff --git a/pages/api/login.ts b/pages/api/login.ts index c424908..2971a62 100644 --- a/pages/api/login.ts +++ b/pages/api/login.ts @@ -4,17 +4,15 @@ import { CLIENT_ID, COOKIE_KEYS, OAUTH_URL, - PUBLIC_URL, + REDIRECT_URL, } from '../../lib/constants'; import { encrypt, generateString } from '../../lib/utils/crypto'; -const redirect = `${PUBLIC_URL}/api/callback`; - export default function handler(req: NextApiRequest, res: NextApiResponse) { const stateToken = generateString(16); const state = encrypt( JSON.stringify({ - redirect_uri: redirect, + redirect_uri: REDIRECT_URL, state: stateToken, }) ); @@ -22,7 +20,7 @@ export default function handler(req: NextApiRequest, res: NextApiResponse) { const params = new URLSearchParams({ client_id: CLIENT_ID, response_type: 'code', - redirect_uri: redirect, + redirect_uri: REDIRECT_URL, scope: 'management', state, }); diff --git a/pages/audit.tsx b/pages/audit.tsx new file mode 100644 index 0000000..c9f191d --- /dev/null +++ b/pages/audit.tsx @@ -0,0 +1,3 @@ +import { AuditPage } from '../components/AuditPage/AuditPage'; + +export default AuditPage;