From cd7ad1f3d37e4b5cf5adc45813ce194faae276bc Mon Sep 17 00:00:00 2001 From: Sonny Date: Mon, 25 Dec 2023 04:52:54 +0100 Subject: [PATCH] feat: admin dashboard --- package-lock.json | 6 + package.json | 3 +- .../migration.sql | 2 + prisma/schema.prisma | 1 + public/locales/en/admin.json | 9 ++ public/locales/fr/admin.json | 9 ++ src/components/Navbar/Navbar.tsx | 15 +- src/components/RoundedImage/RoundedImage.tsx | 4 + .../RoundedImage/rounded-image.module.scss | 6 + .../SideNavigation/UserCard/UserCard.tsx | 22 ++- src/constants/paths.ts | 1 + src/hooks/useUser.tsx | 8 + src/i18n/resources.ts | 2 + src/lib/user/getUsers.ts | 18 +++ src/pages/_app.tsx | 6 + src/pages/admin.tsx | 140 ++++++++++++++++++ src/pages/api/auth/[...nextauth].ts | 6 +- src/pages/terms.tsx | 1 - src/styles/admin.module.scss | 5 + src/styles/table.scss | 32 ++++ src/utils/session.ts | 25 +++- 21 files changed, 303 insertions(+), 18 deletions(-) create mode 100644 prisma/migrations/20231224162232_add_is_admin_field/migration.sql create mode 100644 public/locales/en/admin.json create mode 100644 public/locales/fr/admin.json create mode 100644 src/hooks/useUser.tsx create mode 100644 src/lib/user/getUsers.ts create mode 100644 src/pages/admin.tsx create mode 100644 src/styles/admin.module.scss create mode 100644 src/styles/table.scss diff --git a/package-lock.json b/package-lock.json index 500511f..83d4f1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a2ffa62..5841a68 100644 --- a/package.json +++ b/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/prisma/migrations/20231224162232_add_is_admin_field/migration.sql b/prisma/migrations/20231224162232_add_is_admin_field/migration.sql new file mode 100644 index 0000000..f2a6c35 --- /dev/null +++ b/prisma/migrations/20231224162232_add_is_admin_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `user` ADD COLUMN `is_admin` BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2d5592a..d2d4df1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,6 +17,7 @@ model User { email String @unique name String? image String? + is_admin Boolean @default(false) categories Category[] links Link[] diff --git a/public/locales/en/admin.json b/public/locales/en/admin.json new file mode 100644 index 0000000..56fd234 --- /dev/null +++ b/public/locales/en/admin.json @@ -0,0 +1,9 @@ +{ + "role": "Role", + "created_at": "Created at", + "updated_at": "Updated at", + "admin": "Administrator", + "user": "User", + "users": "Users", + "stats": "Statistics" +} diff --git a/public/locales/fr/admin.json b/public/locales/fr/admin.json new file mode 100644 index 0000000..fc15ec6 --- /dev/null +++ b/public/locales/fr/admin.json @@ -0,0 +1,9 @@ +{ + "role": "Rôle", + "created_at": "Création", + "updated_at": "Mise à jour", + "admin": "Administrateur", + "user": "Utilisateur", + "users": "Utilisateurs", + "stats": "Statistiques" +} diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx index 5696475..008004d 100644 --- a/src/components/Navbar/Navbar.tsx +++ b/src/components/Navbar/Navbar.tsx @@ -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() { {status === 'authenticated' ? ( <> + {user?.is_admin && ( +
  • + Admin +
  • + )}
  • - {data.user.name} + {user?.name}
  • {t('common:logout')} diff --git a/src/components/RoundedImage/RoundedImage.tsx b/src/components/RoundedImage/RoundedImage.tsx index 099c437..004928b 100644 --- a/src/components/RoundedImage/RoundedImage.tsx +++ b/src/components/RoundedImage/RoundedImage.tsx @@ -7,6 +7,10 @@ export default function RoundedImage({ height = 24, alt, }: (typeof Image)['defaultProps']) { + if (!src) { + console.warn('No src provided'); + return <>; + } return (
    - {avatarLabel} - {data.user.name} + {user.name}
    + {user.is_admin && ( + + + + )}
    ); diff --git a/src/constants/paths.ts b/src/constants/paths.ts index 8a1440e..0ff70bf 100644 --- a/src/constants/paths.ts +++ b/src/constants/paths.ts @@ -4,6 +4,7 @@ const PATHS = { HOME: '/', PRIVACY: '/privacy', TERMS: '/terms', + ADMIN: '/admin', CATEGORY: { CREATE: '/category/create', EDIT: '/category/edit', diff --git a/src/hooks/useUser.tsx b/src/hooks/useUser.tsx new file mode 100644 index 0000000..7384a3e --- /dev/null +++ b/src/hooks/useUser.tsx @@ -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 }; +} diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 079e094..54ee575 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -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; diff --git a/src/lib/user/getUsers.ts b/src/lib/user/getUsers.ts new file mode 100644 index 0000000..abb9af8 --- /dev/null +++ b/src/lib/user/getUsers.ts @@ -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, + }, + }, + }, + }); +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 4bd8a4b..77dcff7 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -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, diff --git a/src/pages/admin.tsx b/src/pages/admin.tsx new file mode 100644 index 0000000..055d4d0 --- /dev/null +++ b/src/pages/admin.tsx @@ -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 ( + + + + + {t('common:users')} + {t('common:stats')} + + + +

    + {users.length} {t('admin:users')} +

    + + + + + + + + + + + + + + + {users.length !== 0 && + users.map( + ({ + id, + image, + name, + email, + _count, + is_admin, + createdAt, + updatedAt, + }) => ( + + + + + + + + + + + ), + )} + +
    #{t('common:name')}{t('common:email')}{t('common:category.categories')}{t('common:link.links')}{t('admin:role')}{t('admin:created_at')}{t('admin:updated_at')}
    {id} + {image && ( + + )} + {name ?? '-'} + {email}{_count.categories}{_count.links} + {is_admin ? ( + + {t('admin:admin')} + + ) : ( + + {t('admin:user')} + + )} + {dayjs(createdAt).fromNow()}{dayjs(updatedAt).fromNow()}
    +
    + {/* // stats */} +
    +
    + ); +} + +type TableItem = { children: ReactNode; fixed?: boolean }; +function TH({ children, fixed = false }: TableItem) { + return ( + +
    {children}
    + + ); +} + +function TD({ children, fixed = false }: TableItem) { + return ( + +
    {children}
    + + ); +} + +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)), + }, + }; + }, +); diff --git a/src/pages/api/auth/[...nextauth].ts b/src/pages/api/auth/[...nextauth].ts index ba41785..d8b6732 100644 --- a/src/pages/api/auth/[...nextauth].ts +++ b/src/pages/api/auth/[...nextauth].ts @@ -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)) { diff --git a/src/pages/terms.tsx b/src/pages/terms.tsx index 869d8d9..cbc89b9 100644 --- a/src/pages/terms.tsx +++ b/src/pages/terms.tsx @@ -36,7 +36,6 @@ export default function Terms() {

    {t('terms:personal_data.collect.title')}

    }} /> diff --git a/src/styles/admin.module.scss b/src/styles/admin.module.scss new file mode 100644 index 0000000..4d0ed82 --- /dev/null +++ b/src/styles/admin.module.scss @@ -0,0 +1,5 @@ +.admin { + width: 100%; + padding: 1em; + overflow: auto; +} diff --git a/src/styles/table.scss b/src/styles/table.scss new file mode 100644 index 0000000..dc71447 --- /dev/null +++ b/src/styles/table.scss @@ -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; + } +} diff --git a/src/utils/session.ts b/src/utils/session.ts index 632504b..dfc8a8c 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -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> & { + session: Session; + }; +}; + +export function withAuthentication( + serverSidePropsFunc: ( + context: AuthenticationContext, + ) => Promise, +): (context: GetServerSidePropsContext) => Promise { return async (context: GetServerSidePropsContext) => { const { req, res } = context;