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;