diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..68ecdba --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules\\typescript\\lib" +} \ No newline at end of file diff --git a/next.config.mjs b/next.config.mjs index 63d904b..ce0e518 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -17,10 +17,6 @@ const nextConfig = { permanent: false, basePath: false }] : []), { - source: '/', - destination: '/dashboard', - permanent: false - }, { source: '/chuni', destination: '/chuni/dashboard', permanent: false @@ -36,7 +32,11 @@ const nextConfig = { experimental: { instrumentationHook: true }, - productionBrowserSourceMaps: true + productionBrowserSourceMaps: true, + webpack: config => { + config.externals = [...config.externals, 'bcrypt']; + return config; + } }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index a844ade..d946c4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "mysql2": "^3.9.2", "next": "14.1.4", "next-auth": "^5.0.0-beta.15", + "next-client-cookies": "^1.1.0", "next-themes": "^0.3.0", "react": "^18", "react-day-picker": "^8.10.0", @@ -227,7 +228,6 @@ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==", "optional": true, - "peer": true, "dependencies": { "@emotion/memoize": "0.7.4" } @@ -236,8 +236,7 @@ "version": "0.7.4", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", - "optional": true, - "peer": true + "optional": true }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", @@ -7754,6 +7753,14 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8523,6 +8530,18 @@ } } }, + "node_modules/next-client-cookies": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-client-cookies/-/next-client-cookies-1.1.0.tgz", + "integrity": "sha512-e/rqQTXHSFuvUJELMeCDgM7dWW6IUNOGr7noWyRSgE/5l033UaqseDrjShfRZYG45JnrYKoX653OdXTJ8cn+NA==", + "dependencies": { + "js-cookie": "^3.0.5" + }, + "peerDependencies": { + "next": ">= 13.0.0", + "react": ">= 16.8.0" + } + }, "node_modules/next-themes": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.3.0.tgz", diff --git a/package.json b/package.json index 9da6869..5772da8 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "mysql2": "^3.9.2", "next": "14.1.4", "next-auth": "^5.0.0-beta.15", + "next-client-cookies": "^1.1.0", "next-themes": "^0.3.0", "react": "^18", "react-day-picker": "^8.10.0", diff --git a/src/auth.ts b/src/auth.ts index 67c2332..32435a0 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,7 +1,6 @@ import NextAuth from 'next-auth'; import CredentialsProvider from 'next-auth/providers/credentials'; import { db, GeneratedDB } from '@/db'; -import bcrypt from 'bcrypt'; import { DBUserPayload } from '@/types/user'; import { cache } from 'react'; import { SelectQueryBuilder, sql } from 'kysely'; @@ -42,12 +41,15 @@ const nextAuth = NextAuth({ callbacks: { async jwt({ token, user }) { token.user ??= user; - const dbUser = await selectUserProps(db.selectFrom('aime_user as u') - .where('u.id', '=', (token.user as any).id)); - if (dbUser) { - const { password, ...payload } = dbUser; - token.user = { ...(token.user as any), ...payload }; + if (db) { + const dbUser = await selectUserProps(db.selectFrom('aime_user as u') + .where('u.id', '=', (token.user as any).id)); + + if (dbUser) { + const { password, ...payload } = dbUser; + token.user = { ...(token.user as any), ...payload }; + } } return token; @@ -70,6 +72,13 @@ const nextAuth = NextAuth({ (user as any).visibility = 0; } + const now = new Date(); + (user as any).last_login_date = now; + await db.updateTable('aime_user') + .set({ last_login_date: now }) + .where('id', '=', (user as any).id) + .executeTakeFirst(); + return true; } }, @@ -80,6 +89,8 @@ const nextAuth = NextAuth({ password: { label: 'Password', type: 'password' } }, async authorize({ username, password }, req) { + const bcrypt = await import('bcrypt'); + if (typeof username !== 'string' || typeof password !== 'string') return null; @@ -97,10 +108,11 @@ const nextAuth = NextAuth({ })] }); -export const auth = cache(nextAuth.auth); +export const auth = process.env.NEXT_RUNTIME !== 'edge' ? cache(nextAuth.auth) : (null as never); export const { handlers: { GET, POST }, signIn, - signOut + signOut, + auth: uncachedAuth } = nextAuth; diff --git a/src/components/header-sidebar.tsx b/src/components/header-sidebar.tsx index 68b744e..63be5cf 100644 --- a/src/components/header-sidebar.tsx +++ b/src/components/header-sidebar.tsx @@ -1,40 +1,89 @@ 'use client'; -import { Button, Divider, Navbar } from '@nextui-org/react'; +import { Button, Divider, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Navbar } from '@nextui-org/react'; import { Bars3Icon, ChevronLeftIcon, XMarkIcon } from '@heroicons/react/20/solid'; import { Fragment, useState } from 'react'; import Link from 'next/link'; import { ThemeSwitcherDropdown, ThemeSwitcherSwitch } from '@/components/theme-switcher'; import { AdjustmentsHorizontalIcon } from '@heroicons/react/24/solid'; import { login, logout } from '@/actions/auth'; -import { usePathname, useSearchParams } from 'next/navigation'; +import { usePathname, useRouter } from 'next/navigation'; import { UserPayload } from '@/types/user'; -import { MAIN_ROUTES, ROUTES, UserOnly } from '@/routes'; +import { MAIN_ROUTES, ROUTES, Subroute, UserOnly } from '@/routes'; import { useUser } from '@/helpers/use-user'; import { useBreakpoint } from '@/helpers/use-breakpoint'; +import { useCookies } from 'next-client-cookies'; +import { UserPermissions } from '@/types/permissions'; +import { hasPermission } from '@/helpers/permissions'; +import { ChevronDownIcon } from '@heroicons/react/24/outline'; export type HeaderSidebarProps = { - children?: React.ReactNode + children?: React.ReactNode, }; -const filterUserOnly = (user: UserPayload | null | undefined, { userOnly }: { userOnly?: UserOnly }) => { - if (!userOnly) return true; - if (typeof userOnly === 'string') return user?.[userOnly]; - return user; +const filterRoute = (user: UserPayload | null | undefined, { userOnly, permissions }: { userOnly?: UserOnly, permissions?: (UserPermissions | UserPermissions[])[] }) => { + if (typeof userOnly === 'string' && !user?.[userOnly]) + return false; + if (typeof userOnly === 'boolean' && !user) + return false; + if (permissions?.length && !hasPermission(user?.permissions, ...permissions)) + return false; + return true; }; export const HeaderSidebar = ({ children }: HeaderSidebarProps) => { const user = useUser(); - const path = usePathname(); - const params = useSearchParams(); + const pathname = usePathname(); const [isMenuOpen, setMenuOpen] = useState(false); const breakpoint = useBreakpoint(); + const cookies = useCookies(); + const router = useRouter(); - const from = params?.get('from'); - const filter = filterUserOnly.bind(null, user); + const path = pathname === '/' ? (user?.homepage ?? '/dashboard') : pathname; + + const from = cookies.get('actaeon-navigated-from'); + const filter = filterRoute.bind(null, user); const routeGroup = ROUTES.find(route => route.title === from || path?.startsWith(route.url))!; + const renderHeaderLink = (route: Subroute) => { + const linkStyle = path?.startsWith(route.url) ? + 'font-semibold text-primary' : 'hover:text-secondary'; + + if (route.routes?.length) + return ( + + + + + {route.routes.filter(filter) + .map(route => ( { + router.push(route.url); + cookies.set('actaeon-navigated-from', routeGroup.title); + }} + onMouseEnter={() => router.prefetch(route.url)}> + {route.name} + ))} + + ); + + return ( { + cookies.set('actaeon-navigated-from', routeGroup.title); + }} + href={`${route.url}`} key={route.url} + className={`mr-4 transition ${linkStyle}`}> + {route.name} + ); + } + return (<> + {/* begin sidebar */}
setMenuOpen(false)} />
@@ -53,16 +102,31 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
{!filter(route) ?
{route.name} -
: setMenuOpen(false)} +
: { + setMenuOpen(false); + cookies.remove('actaeon-navigated-from'); + }} className={`text-2xl transition hover:text-secondary ${route === routeGroup ? 'font-bold' : 'font-semibold'}`}> {route.name} }
{route.routes?.filter(filter)?.map(subroute =>
- setMenuOpen(false)} - className={`text-xl transition hover:text-secondary ${path?.startsWith(subroute.url) ? 'font-semibold' : ''}`}> + {subroute.routes?.length ?
{subroute.name} - +
+ {subroute.routes.filter(filter).map(route => ( + {route.name} + ))} +
+
: { + setMenuOpen(false); + cookies.remove('actaeon-navigated-from'); + }} + className={`text-xl transition ${path?.startsWith(subroute.url) ? 'font-semibold text-primary' : 'hover:text-secondary'}`}> + {subroute.name} + }
)}
@@ -83,6 +147,9 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
+ {/* end sidebar */} + + {/* begin top navbar */}
@@ -92,18 +159,10 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => { { routeGroup.title }
- {routeGroup.routes?.filter(filter).map(route => - - {route.name} - ) - } + {routeGroup.routes?.filter(filter).map(renderHeaderLink)}
- {routeGroup !== MAIN_ROUTES &&
- {MAIN_ROUTES.routes.filter(filter).map(route => - {route.name} - )} + {routeGroup !== MAIN_ROUTES &&
+ {MAIN_ROUTES.routes.filter(filter).map(renderHeaderLink)}
}
@@ -123,5 +182,6 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => { {children}
+ {/* end top navbar */} ) }; diff --git a/src/components/providers.tsx b/src/components/providers.tsx index fb827d1..fbe1c5d 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -1,9 +1,12 @@ import { ReactNode } from 'react'; import { auth } from '@/auth'; import { SessionProvider } from 'next-auth/react'; +import { CookiesProvider } from 'next-client-cookies/server'; export async function Providers({ children }: { children: ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/src/db.ts b/src/db.ts index 4e84c8f..87d8087 100644 --- a/src/db.ts +++ b/src/db.ts @@ -3,6 +3,9 @@ import { createPool } from 'mysql2'; import { Generated, Kysely, MysqlDialect } from 'kysely'; const createDb = () => { + if (process.env.NEXT_RUNTIME === 'edge') + return null; + if ((globalThis as any).db) return (globalThis as any).db as Kysely; @@ -26,4 +29,4 @@ export type GeneratedDB = { } }; -export const db = createDb(); +export const db = createDb()!; diff --git a/src/middleware.ts b/src/middleware.ts index 28b21ff..ed21d0a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,9 +1,24 @@ import { NextRequest, NextResponse } from 'next/server'; +import { uncachedAuth } from '@/auth'; -export function middleware(request: NextRequest) { +export const middleware = uncachedAuth((request: NextRequest) => { const headers = new Headers(request.headers); headers.set('x-path', request.nextUrl.basePath + request.nextUrl.pathname); - return NextResponse.next({ + + const options: ResponseInit & { request: { headers: Headers; }; } = { request: { headers } - }); -} + }; + + if (request.nextUrl.pathname === '/') { + const newUrl = request.nextUrl.clone(); + newUrl.pathname = request.auth?.user?.homepage ?? '/dashboard'; + return NextResponse.rewrite(newUrl, options); + } + + if (request.nextUrl.pathname === '/forbidden') + options.status = 403; + else if (request.nextUrl.pathname === '/auth/login' && request.nextUrl.searchParams.get('error')) + options.status = 401; + + return NextResponse.next(options); +}); diff --git a/src/routes.ts b/src/routes.ts index 11d27f9..e00d378 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,18 +1,22 @@ import { UserPayload } from '@/types/user'; +import { UserPermissions } from '@/types/permissions'; export type UserOnly = boolean | keyof UserPayload; -type Subroute = { +export type Subroute = { url: string, name: string, - userOnly?: UserOnly + userOnly?: UserOnly, + permissions?: (UserPermissions | UserPermissions[])[], + routes?: Omit[] }; -type Route = { +export type Route = { url: string, name: string, title: string, userOnly?: UserOnly, + permissions?: (UserPermissions | UserPermissions[])[], routes: Subroute[] }; @@ -26,6 +30,19 @@ export const MAIN_ROUTES: Route = { }, { url: '/arcade', name: 'Arcades' + }, { + url: '/admin', + name: 'Admin', + permissions: [[UserPermissions.USERMOD, UserPermissions.SYSADMIN]], + routes: [{ + url: '/admin/users', + name: 'Users', + permissions: [UserPermissions.USERMOD] + }, { + url: '/admin/system-config', + name: 'System Config', + permissions: [UserPermissions.SYSADMIN] + }] }] }; diff --git a/src/types/next-auth/index.d.ts b/src/types/next-auth/index.d.ts index 59d09fe..c591cec 100644 --- a/src/types/next-auth/index.d.ts +++ b/src/types/next-auth/index.d.ts @@ -14,3 +14,12 @@ declare module 'next-auth' { } } } + +declare module 'next/server' { + interface NextRequest { + auth: { + user: UserPayload, + expires: string + } | null + } +} \ No newline at end of file