diff --git a/.env.development b/.env.development index e6c84a3..fbd7112 100644 --- a/.env.development +++ b/.env.development @@ -1 +1,4 @@ -API_HOST=localhost \ No newline at end of file +API_HOST=localhost +API_PORT=8090 +API_PROTOCOL=http +NEXTAUTH_SECRET=secret \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 020c9fc..b0f42d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,13 +11,16 @@ "@emotion/cache": "^11.11.0", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", - "@fontsource/roboto": "^5.0.8", + "@fontsource/inter": "^5.0.16", + "@fontsource/plus-jakarta-sans": "^5.0.18", "@mui/icons-material": "^5.15.0", "@mui/material": "^5.15.0", "@mui/material-nextjs": "^5.15.0", "next": "14.0.4", + "next-auth": "^4.24.5", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "react-hook-form": "^7.49.2" }, "devDependencies": { "@types/node": "^20", @@ -285,10 +288,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@fontsource/roboto": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.0.8.tgz", - "integrity": "sha512-XxPltXs5R31D6UZeLIV1td3wTXU3jzd3f2DLsXI8tytMGBkIsGcc9sIyiupRtA8y73HAhuSCeweOoBqf6DbWCA==" + "node_modules/@fontsource/inter": { + "version": "5.0.16", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.0.16.tgz", + "integrity": "sha512-qF0aH5UiZvCmneX5orJbVRoc2VTyLTV3X/7laMp03Qt28L+B9tFlZODOGUL64wDWc69YVdi1LeJB0cIgd51lvw==" + }, + "node_modules/@fontsource/plus-jakarta-sans": { + "version": "5.0.18", + "resolved": "https://registry.npmjs.org/@fontsource/plus-jakarta-sans/-/plus-jakarta-sans-5.0.18.tgz", + "integrity": "sha512-poMuIcQ8F7WGXF4mNUviDk49Ewdf0pU7wmCzWQNbWEtus+L46BSp+4OqbWy0LWJEmMLI9F5hUHaSo2maLJwrQw==" }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", @@ -662,6 +670,126 @@ "node": ">= 10" } }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.4.tgz", + "integrity": "sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.4.tgz", + "integrity": "sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.4.tgz", + "integrity": "sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.4.tgz", + "integrity": "sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.4.tgz", + "integrity": "sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.4.tgz", + "integrity": "sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.4.tgz", + "integrity": "sha512-zLeNEAPULsl0phfGb4kdzF/cAVIfaC7hY+kt0/d+y9mzcZHsMS3hAS829WbJ31DkSlVKQeHEjZHIdhN+Pg7Gyg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.4.tgz", + "integrity": "sha512-yEh2+R8qDlDCjxVpzOTEpBLQTEFAcP2A8fUFLaWNap9GitYKkKv1//y2S6XY6zsR4rCOPRpU7plYDR+az2n30A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.walk": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", diff --git a/package.json b/package.json index 5ad444a..f83f509 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,16 @@ "@emotion/cache": "^11.11.0", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", - "@fontsource/roboto": "^5.0.8", + "@fontsource/inter": "^5.0.16", + "@fontsource/plus-jakarta-sans": "^5.0.18", "@mui/icons-material": "^5.15.0", "@mui/material": "^5.15.0", "@mui/material-nextjs": "^5.15.0", "next": "14.0.4", + "next-auth": "^4.24.5", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "react-hook-form": "^7.49.2" }, "devDependencies": { "@types/node": "^20", diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..2fa7df5 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import { authOptions } from "@/config/authConfig"; +import NextAuth from "next-auth"; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..59ee896 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,5 @@ +const Dashboard: React.FC = () => { + return "Dashboard (protected)"; +}; + +export default Dashboard; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1a4652a..705cdc8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,11 +1,6 @@ import type { Metadata } from "next"; -import { AppRouterCacheProvider } from "@mui/material-nextjs/v14-appRouter"; -import { CssBaseline } from "@mui/material"; -import "@fontsource/roboto/300.css"; -import "@fontsource/roboto/400.css"; -import "@fontsource/roboto/500.css"; -import "@fontsource/roboto/700.css"; +import ThemeRegistry from "@/theme/ThemeRegistry"; export const metadata: Metadata = { title: "Create Next App", @@ -20,8 +15,7 @@ export default function RootLayout({ return ( - - {children} + {children} ); diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx new file mode 100644 index 0000000..9a8244a --- /dev/null +++ b/src/app/login/LoginForm.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { FormHelperText } from "@mui/material"; +import Button from "@mui/material/Button"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { signIn } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { SubmitHandler, useForm } from "react-hook-form"; + +type LoginFields = { + username: string; + password: string; +}; + +// Error codes in https://next-auth.js.org/configuration/pages#sign-in-page +const getHumanFriendlyErrorMessage = (serverError: string): string => { + switch (serverError) { + case "CredentialsSignin": + return "Invalid username or password."; + case "Default": + default: + return "Something went wrong. Please try again later."; + } +}; + +const LoginForm: React.FC = () => { + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm(); + + const [serverError, setServerError] = useState(); + + const router = useRouter(); + + const onSubmit: SubmitHandler = async (data) => { + const res = await signIn("credentials", { + redirect: false, + ...data, + }); + + if (res?.error) { + setServerError(res.error); + return; + } + const callbackUrl = + new URLSearchParams(window.location.search).get("callbackUrl") || "/"; + + router.push(callbackUrl); + }; + + return ( + + Sign In + + + {serverError && ( + + {getHumanFriendlyErrorMessage(serverError)} + + )} + + + ); +}; + +export default LoginForm; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..1708823 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,28 @@ +import Grid from "@mui/material/Grid"; +import Paper from "@mui/material/Paper"; +import LoginForm from "./LoginForm"; +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; +import { authOptions } from "@/config/authConfig"; + +const Login: React.FC = async () => { + const session = await getServerSession(authOptions); + if (session?.user) { + redirect("/"); + } + + return ( + + + Hero + + + + + + + + ); +}; + +export default Login; diff --git a/src/app/page.tsx b/src/app/page.tsx index 4743b42..9271349 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,7 @@ -const Home: React.FC = () => { - return "home page"; +import { permanentRedirect } from "next/navigation"; + +const Home: React.FC = async () => { + permanentRedirect("/dashboard"); }; export default Home; diff --git a/src/config/api.ts b/src/config/api.ts new file mode 100644 index 0000000..d920df6 --- /dev/null +++ b/src/config/api.ts @@ -0,0 +1,2 @@ +export const BASE_API_URL = `${process.env.API_PROTOCOL}://${process.env.API_HOST}:${process.env.API_PORT}`; +export const LOGIN_API_PATH = `${BASE_API_URL}/api/login`; diff --git a/src/config/authConfig.ts b/src/config/authConfig.ts new file mode 100644 index 0000000..f2b689e --- /dev/null +++ b/src/config/authConfig.ts @@ -0,0 +1,42 @@ +import { AuthOptions } from "next-auth"; +import CredentialsProvider from "next-auth/providers/credentials"; +import { LOGIN_API_PATH } from "./api"; + +export const authOptions: AuthOptions = { + debug: process.env.NODE_ENV === "development", + providers: [ + CredentialsProvider({ + id: "credentials", + name: "Credentials", + credentials: { + username: { label: "Username", type: "text" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials, req) { + const res = await fetch(LOGIN_API_PATH, { + method: "POST", + body: JSON.stringify(credentials), + headers: { "Content-Type": "application/json" }, + }); + + const user = await res.json(); + + if (res.ok && user) { + return user; + } + return null; + }, + }), + ], + pages: { + signIn: "/login", + }, + callbacks: { + jwt(params) { + return params.token; + }, + session(params) { + return params.session; + }, + }, +}; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..856504c --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,8 @@ +import { withAuth } from "next-auth/middleware"; +import { authOptions } from "@/config/authConfig"; + +export default withAuth({ + pages: authOptions.pages, +}); + +export const config = { matcher: ["/dashboard"] }; diff --git a/src/theme/EmotionCache.tsx b/src/theme/EmotionCache.tsx new file mode 100644 index 0000000..8f13bef --- /dev/null +++ b/src/theme/EmotionCache.tsx @@ -0,0 +1,100 @@ +"use client"; +import * as React from "react"; +import createCache from "@emotion/cache"; +import { useServerInsertedHTML } from "next/navigation"; +import { CacheProvider as DefaultCacheProvider } from "@emotion/react"; +import type { + EmotionCache, + Options as OptionsOfCreateCache, +} from "@emotion/cache"; + +// Copied from https://github.com/mui/material-ui/blob/master/examples/material-ui-nextjs-ts/src/components/ThemeRegistry/EmotionCache.tsx +export type NextAppDirEmotionCacheProviderProps = { + /** This is the options passed to createCache() from 'import createCache from "@emotion/cache"' */ + options: Omit; + /** By default from 'import { CacheProvider } from "@emotion/react"' */ + CacheProvider?: (props: { + value: EmotionCache; + children: React.ReactNode; + }) => React.JSX.Element | null; + children: React.ReactNode; +}; + +// Adapted from https://github.com/garronej/tss-react/blob/main/src/next/appDir.tsx +export default function NextAppDirEmotionCacheProvider( + props: NextAppDirEmotionCacheProviderProps, +) { + const { options, CacheProvider = DefaultCacheProvider, children } = props; + + const [registry] = React.useState(() => { + const cache = createCache(options); + cache.compat = true; + const prevInsert = cache.insert; + let inserted: { name: string; isGlobal: boolean }[] = []; + cache.insert = (...args) => { + const [selector, serialized] = args; + if (cache.inserted[serialized.name] === undefined) { + inserted.push({ + name: serialized.name, + isGlobal: !selector, + }); + } + return prevInsert(...args); + }; + const flush = () => { + const prevInserted = inserted; + inserted = []; + return prevInserted; + }; + return { cache, flush }; + }); + + useServerInsertedHTML(() => { + const inserted = registry.flush(); + if (inserted.length === 0) { + return null; + } + let styles = ""; + let dataEmotionAttribute = registry.cache.key; + + const globals: { + name: string; + style: string; + }[] = []; + + inserted.forEach(({ name, isGlobal }) => { + const style = registry.cache.inserted[name]; + + if (typeof style !== "boolean") { + if (isGlobal) { + globals.push({ name, style }); + } else { + styles += style; + dataEmotionAttribute += ` ${name}`; + } + } + }); + + return ( + + {globals.map(({ name, style }) => ( +