feat: admin dashboard

This commit is contained in:
Sonny
2023-12-25 04:52:54 +01:00
committed by Sonny
parent 480ca9bfa6
commit cd7ad1f3d3
21 changed files with 303 additions and 18 deletions

6
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@types/react-toggle": "^4.0.5",
"accept-language": "^3.0.18",
"clsx": "^2.0.0",
"dayjs": "^1.11.10",
"framer-motion": "^10.16.16",
"i18next": "^23.7.11",
"next": "^14.0.4",
@@ -4623,6 +4624,11 @@
"integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
"dev": true
},
"node_modules/dayjs": {
"version": "1.11.10",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
"integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
},
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",

View File

@@ -17,6 +17,7 @@
"@types/react-toggle": "^4.0.5",
"accept-language": "^3.0.18",
"clsx": "^2.0.0",
"dayjs": "^1.11.10",
"framer-motion": "^10.16.16",
"i18next": "^23.7.11",
"next": "^14.0.4",
@@ -57,4 +58,4 @@
"lint-staged": {
"*.js": "eslint --cache --fix"
}
}
}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `user` ADD COLUMN `is_admin` BOOLEAN NOT NULL DEFAULT false;

View File

@@ -17,6 +17,7 @@ model User {
email String @unique
name String?
image String?
is_admin Boolean @default(false)
categories Category[]
links Link[]

View File

@@ -0,0 +1,9 @@
{
"role": "Role",
"created_at": "Created at",
"updated_at": "Updated at",
"admin": "Administrator",
"user": "User",
"users": "Users",
"stats": "Statistics"
}

View File

@@ -0,0 +1,9 @@
{
"role": "Rôle",
"created_at": "Création",
"updated_at": "Mise à jour",
"admin": "Administrateur",
"user": "Utilisateur",
"users": "Utilisateurs",
"stats": "Statistiques"
}

View File

@@ -1,4 +1,5 @@
import PATHS from 'constants/paths';
import useUser from 'hooks/useUser';
import { useSession } from 'next-auth/react';
import { useTranslation } from 'next-i18next';
import LinkTag from 'next/link';
@@ -6,11 +7,12 @@ import RoundedImage from '../RoundedImage/RoundedImage';
import styles from './navbar.module.scss';
export default function Navbar() {
const { data, status } = useSession();
const { status } = useSession();
const { user } = useUser();
const { t } = useTranslation();
const avatarLabel = t('common:avatar', {
name: data?.user?.name,
name: user?.name,
});
return (
@@ -27,12 +29,17 @@ export default function Navbar() {
</li>
{status === 'authenticated' ? (
<>
{user?.is_admin && (
<li>
<LinkTag href={PATHS.ADMIN}>Admin</LinkTag>
</li>
)}
<li className={styles['user']}>
<RoundedImage
src={data.user.image}
src={user?.image}
alt={avatarLabel}
/>
{data.user.name}
{user?.name}
</li>
<li>
<LinkTag href={PATHS.LOGOUT}>{t('common:logout')}</LinkTag>

View File

@@ -7,6 +7,10 @@ export default function RoundedImage({
height = 24,
alt,
}: (typeof Image)['defaultProps']) {
if (!src) {
console.warn('No src provided');
return <></>;
}
return (
<div className={styles['rounded-image']}>
<Image

View File

@@ -1,3 +1,9 @@
.rounded-image {
display: flex;
align-items: center;
justify-content: center;
}
.rounded-image img {
border-radius: 50%;
}

View File

@@ -1,28 +1,36 @@
import RoundedImage from 'components/RoundedImage/RoundedImage';
import SettingsModal from 'components/Settings/SettingsModal';
import { useSession } from 'next-auth/react';
import PATHS from 'constants/paths';
import useUser from 'hooks/useUser';
import { useTranslation } from 'next-i18next';
import Image from 'next/image';
import Link from 'next/link';
import { MdOutlineAdminPanelSettings } from 'react-icons/md';
import styles from './user-card.module.scss';
export default function UserCard() {
const { data } = useSession({ required: true });
const { user } = useUser();
const { t } = useTranslation();
const avatarLabel = t('common:avatar', {
name: data.user.name,
name: user.name,
});
return (
<div className={styles['user-card-wrapper']}>
<div className={styles['user-card']}>
<Image
src={data.user.image}
<RoundedImage
src={user.image}
width={28}
height={28}
alt={avatarLabel}
title={avatarLabel}
/>
{data.user.name}
{user.name}
</div>
{user.is_admin && (
<Link href={PATHS.ADMIN}>
<MdOutlineAdminPanelSettings />
</Link>
)}
<SettingsModal />
</div>
);

View File

@@ -4,6 +4,7 @@ const PATHS = {
HOME: '/',
PRIVACY: '/privacy',
TERMS: '/terms',
ADMIN: '/admin',
CATEGORY: {
CREATE: '/category/create',
EDIT: '/category/edit',

8
src/hooks/useUser.tsx Normal file
View File

@@ -0,0 +1,8 @@
import { User } from '@prisma/client';
import { Session } from 'next-auth';
import { useSession } from 'next-auth/react';
export default function useUser() {
const { data } = useSession();
return data as Session & { user?: User };
}

View File

@@ -3,6 +3,7 @@ import home from '../../public/locales/en/home.json';
import login from '../../public/locales/en/login.json';
import privacy from '../../public/locales/en/privacy.json';
import terms from '../../public/locales/en/terms.json';
import admin from '../../public/locales/en/admin.json';
const resources = {
common,
@@ -10,6 +11,7 @@ const resources = {
home,
privacy,
terms,
admin,
} as const;
export default resources;

18
src/lib/user/getUsers.ts Normal file
View File

@@ -0,0 +1,18 @@
import { Session } from 'next-auth';
import prisma from 'utils/prisma';
export default async function getUsers(session: Session) {
if (!session?.user) {
return null;
}
return await prisma.user.findMany({
include: {
_count: {
select: {
categories: true,
links: true,
},
},
},
});
}

View File

@@ -1,6 +1,9 @@
import ErrorBoundary from 'components/ErrorBoundary/ErrorBoundary';
import * as Keys from 'constants/keys';
import PATHS from 'constants/paths';
import dayjs from 'dayjs';
import 'dayjs/locale/en';
import 'dayjs/locale/fr';
import { SessionProvider } from 'next-auth/react';
import { appWithTranslation, useTranslation } from 'next-i18next';
import { DefaultSeo } from 'next-seo';
@@ -10,12 +13,15 @@ import 'nprogress/nprogress.css';
import { useEffect } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import 'styles/globals.scss';
import 'styles/table.scss';
import config from '../../config';
import nextI18nextConfig from '../../next-i18next.config';
function MyApp({ Component, pageProps: { session, ...pageProps } }) {
const router = useRouter();
const { i18n } = useTranslation();
// TODO: use dynamic locale import
dayjs.locale(i18n.language);
useHotkeys(Keys.CLOSE_SEARCH_KEY, () => router.push(PATHS.HOME), {
enabled: router.pathname !== PATHS.HOME,

140
src/pages/admin.tsx Normal file
View File

@@ -0,0 +1,140 @@
import { User } from '@prisma/client';
import clsx from 'clsx';
import Navbar from 'components/Navbar/Navbar';
import PageTransition from 'components/PageTransition';
import RoundedImage from 'components/RoundedImage/RoundedImage';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { getServerSideTranslation } from 'i18n';
import getUsers from 'lib/user/getUsers';
import { useTranslation } from 'next-i18next';
import { ReactNode } from 'react';
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
import 'react-tabs/style/react-tabs.css';
import styles from 'styles/admin.module.scss';
import { withAuthentication } from 'utils/session';
dayjs.extend(relativeTime);
type UserExtended = User & { _count: { categories: number; links: number } };
export default function AdminDashboard({ users }: { users: UserExtended[] }) {
const { t } = useTranslation('common');
return (
<PageTransition className={styles['admin']}>
<Navbar />
<Tabs>
<TabList>
<Tab>{t('common:users')}</Tab>
<Tab>{t('common:stats')}</Tab>
</TabList>
<TabPanel>
<p>
{users.length} {t('admin:users')}
</p>
<table>
<thead>
<tr>
<TH>#</TH>
<TH>{t('common:name')}</TH>
<TH>{t('common:email')}</TH>
<TH>{t('common:category.categories')}</TH>
<TH>{t('common:link.links')}</TH>
<TH>{t('admin:role')}</TH>
<TH>{t('admin:created_at')}</TH>
<TH>{t('admin:updated_at')}</TH>
</tr>
</thead>
<tbody>
{users.length !== 0 &&
users.map(
({
id,
image,
name,
email,
_count,
is_admin,
createdAt,
updatedAt,
}) => (
<tr key={id}>
<TD>{id}</TD>
<TD fixed>
{image && (
<RoundedImage
src={image}
width={24}
height={24}
alt={name}
title={name}
/>
)}
{name ?? '-'}
</TD>
<TD>{email}</TD>
<TD>{_count.categories}</TD>
<TD>{_count.links}</TD>
<TD>
{is_admin ? (
<span style={{ color: 'red' }}>
{t('admin:admin')}
</span>
) : (
<span style={{ color: 'green' }}>
{t('admin:user')}
</span>
)}
</TD>
<TD>{dayjs(createdAt).fromNow()}</TD>
<TD>{dayjs(updatedAt).fromNow()}</TD>
</tr>
),
)}
</tbody>
</table>
</TabPanel>
<TabPanel>{/* // stats */}</TabPanel>
</Tabs>
</PageTransition>
);
}
type TableItem = { children: ReactNode; fixed?: boolean };
function TH({ children, fixed = false }: TableItem) {
return (
<th>
<div className={clsx('cell', fixed && 'fixed')}>{children}</div>
</th>
);
}
function TD({ children, fixed = false }: TableItem) {
return (
<td>
<div className={clsx('cell', fixed && 'fixed')}>{children}</div>
</td>
);
}
export const getServerSideProps = withAuthentication(
async ({ session, locale, user }) => {
if (!user.is_admin) {
return {
redirect: {
destination: '/',
},
};
}
const users = await getUsers(session);
return {
props: {
session,
...(await getServerSideTranslation(locale, ['admin'])),
users: JSON.parse(JSON.stringify(users)),
},
};
},
);

View File

@@ -39,7 +39,11 @@ export const authOptions = {
const user = await prisma.user.findFirst({
where: { email: session.user.email },
});
return user ? session : undefined;
if (user) {
session.user = JSON.parse(JSON.stringify(user));
return session;
}
return user;
},
async signIn({ account: accountParam, user }) {
if (!checkProvider(accountParam.provider)) {

View File

@@ -36,7 +36,6 @@ export default function Terms() {
<h3>{t('terms:personal_data.collect.title')}</h3>
<p>
<Trans
// @ts-ignore
i18nKey='terms:personal_data.collect.description'
components={{ a: <LinkTag href={PATHS.PRIVACY} /> }}
/>

View File

@@ -0,0 +1,5 @@
.admin {
width: 100%;
padding: 1em;
overflow: auto;
}

32
src/styles/table.scss Normal file
View File

@@ -0,0 +1,32 @@
@import 'colors.scss';
table {
height: auto;
width: 100%;
border-collapse: collapse;
width: 100%;
}
td,
th {
padding: 1em;
}
th {
background-color: $lightest-blue;
}
tr:nth-child(even) {
background-color: #fff;
}
table .cell {
width: 100%;
display: flex;
align-items: center;
gap: 0.25em;
&:not(.fixed) {
justify-content: center;
}
}

View File

@@ -1,19 +1,36 @@
import { User } from '@prisma/client';
import PATHS from 'constants/paths';
import { getServerSideTranslation } from 'i18n';
import getUser from 'lib/user/getUser';
import {
GetServerSidePropsContext,
NextApiRequest,
NextApiResponse,
} from 'next';
import { Session } from 'next-auth';
import { getServerSession } from 'next-auth/next';
import PATHS from 'constants/paths';
import getUser from 'lib/user/getUser';
import { authOptions } from 'pages/api/auth/[...nextauth]';
export async function getSession(req: NextApiRequest, res: NextApiResponse) {
return await getServerSession(req, res, authOptions);
}
export function withAuthentication(serverSidePropsFunc) {
type AuthenticationContext = GetServerSidePropsContext & {
session: Session;
user: User;
};
type AuthenticationReturnType = {
redirect?: { destination: string };
props?: Awaited<ReturnType<typeof getServerSideTranslation>> & {
session: Session;
};
};
export function withAuthentication(
serverSidePropsFunc: (
context: AuthenticationContext,
) => Promise<AuthenticationReturnType>,
): (context: GetServerSidePropsContext) => Promise<AuthenticationReturnType> {
return async (context: GetServerSidePropsContext) => {
const { req, res } = context;