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 (
-
- {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')}
+
+
+
+
+ | # |
+ {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')} |
+
+
+
+ {users.length !== 0 &&
+ users.map(
+ ({
+ id,
+ image,
+ name,
+ email,
+ _count,
+ is_admin,
+ createdAt,
+ updatedAt,
+ }) => (
+
+ | {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;