| @@ -2,7 +2,9 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next | |||||
| ## Setting Up the Environment | ## Setting Up the Environment | ||||
| It is recommended to use the same node and npm versions for development. An easy way to do so would be to use `nvm`. After installing `nvm`, run: | |||||
| It is recommended to use the same node and npm versions for development. An easy way to do so would be to use `nvm` ([Linux/MacOS](https://github.com/nvm-sh/nvm), [Windows](https://github.com/coreybutler/nvm-windows)). | |||||
| After installing `nvm`, run: | |||||
| ```bash | ```bash | ||||
| nvm use | nvm use | ||||
| @@ -21,21 +23,10 @@ npm run dev | |||||
| Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. | ||||
| You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. | |||||
| This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. | |||||
| ## Learn More | |||||
| To learn more about Next.js, take a look at the following resources: | |||||
| - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. | |||||
| - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. | |||||
| You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! | |||||
| ## Deploy on Vercel | |||||
| The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. | |||||
| ## References | |||||
| Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. | |||||
| This project uses the following libraries: | |||||
| - [NextJS](https://nextjs.org/docs) | |||||
| - [Next-Auth](https://next-auth.js.org/getting-started/example) | |||||
| - [Material UI](https://mui.com/material-ui/getting-started/) | |||||
| - [i18next](https://www.i18next.com/overview/getting-started) | |||||
| @@ -16,11 +16,15 @@ | |||||
| "@mui/icons-material": "^5.15.0", | "@mui/icons-material": "^5.15.0", | ||||
| "@mui/material": "^5.15.0", | "@mui/material": "^5.15.0", | ||||
| "@mui/material-nextjs": "^5.15.0", | "@mui/material-nextjs": "^5.15.0", | ||||
| "@unly/universal-language-detector": "^2.0.3", | |||||
| "i18next": "^23.7.11", | |||||
| "i18next-resources-to-backend": "^1.2.0", | |||||
| "next": "14.0.4", | "next": "14.0.4", | ||||
| "next-auth": "^4.24.5", | "next-auth": "^4.24.5", | ||||
| "react": "^18", | "react": "^18", | ||||
| "react-dom": "^18", | "react-dom": "^18", | ||||
| "react-hook-form": "^7.49.2" | |||||
| "react-hook-form": "^7.49.2", | |||||
| "react-i18next": "^13.5.0" | |||||
| }, | }, | ||||
| "devDependencies": { | "devDependencies": { | ||||
| "@types/node": "^20", | "@types/node": "^20", | ||||
| @@ -1068,6 +1072,43 @@ | |||||
| "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", | "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", | ||||
| "dev": true | "dev": true | ||||
| }, | }, | ||||
| "node_modules/@unly/iso3166-1": { | |||||
| "version": "1.0.2", | |||||
| "resolved": "https://registry.npmjs.org/@unly/iso3166-1/-/iso3166-1-1.0.2.tgz", | |||||
| "integrity": "sha512-aL/7cmcfjpwOaKFr9XHcWP/Z7lQjKLm5NMcjncT96aeSJxfblmPLnH/8lnX0GrWWFA2/CseCxnA73u5eiZcClA==" | |||||
| }, | |||||
| "node_modules/@unly/universal-language-detector": { | |||||
| "version": "2.0.3", | |||||
| "resolved": "https://registry.npmjs.org/@unly/universal-language-detector/-/universal-language-detector-2.0.3.tgz", | |||||
| "integrity": "sha512-UMmZIQS2XfJko8/GOrJc9ojO4UIF9HJM59NyQgcH+Ncrb0WBHL2L7EZ/7iZ/YcdoRnioonRECUQmmUELWt9z5Q==", | |||||
| "dependencies": { | |||||
| "@unly/iso3166-1": "1.0.2", | |||||
| "@unly/utils": "1.0.3", | |||||
| "accept-language-parser": "1.5.0", | |||||
| "i18next": "19.0.2", | |||||
| "i18next-browser-languagedetector": "4.0.1", | |||||
| "lodash.get": "4.4.2", | |||||
| "lodash.includes": "4.3.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/@unly/universal-language-detector/node_modules/i18next": { | |||||
| "version": "19.0.2", | |||||
| "resolved": "https://registry.npmjs.org/i18next/-/i18next-19.0.2.tgz", | |||||
| "integrity": "sha512-fBa43Ann2udP1CQAz3IQpOZ1dGAkmi3mMfzisOhH17igneSRbvZ7P2RNbL+L1iRYKMufBmVwnC7G3gqcyviZ9g==", | |||||
| "dependencies": { | |||||
| "@babel/runtime": "^7.3.1" | |||||
| } | |||||
| }, | |||||
| "node_modules/@unly/utils": { | |||||
| "version": "1.0.3", | |||||
| "resolved": "https://registry.npmjs.org/@unly/utils/-/utils-1.0.3.tgz", | |||||
| "integrity": "sha512-QTRknIDX56FvzGcIpBum5D/oRSlX3dkZ+l1op1jsFlYCTd925OGUb991V7zsFv3ePcqFfvfqfR5cNVv+w4JAOw==" | |||||
| }, | |||||
| "node_modules/accept-language-parser": { | |||||
| "version": "1.5.0", | |||||
| "resolved": "https://registry.npmjs.org/accept-language-parser/-/accept-language-parser-1.5.0.tgz", | |||||
| "integrity": "sha512-QhyTbMLYo0BBGg1aWbeMG4ekWtds/31BrEU+DONOg/7ax23vxpL03Pb7/zBmha2v7vdD3AyzZVWBVGEZxKOXWw==" | |||||
| }, | |||||
| "node_modules/acorn": { | "node_modules/acorn": { | ||||
| "version": "8.11.2", | "version": "8.11.2", | ||||
| "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", | "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", | ||||
| @@ -3003,6 +3044,14 @@ | |||||
| "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", | "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", | ||||
| "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" | "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" | ||||
| }, | }, | ||||
| "node_modules/html-parse-stringify": { | |||||
| "version": "3.0.1", | |||||
| "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", | |||||
| "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", | |||||
| "dependencies": { | |||||
| "void-elements": "3.1.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/human-signals": { | "node_modules/human-signals": { | ||||
| "version": "4.3.1", | "version": "4.3.1", | ||||
| "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", | "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", | ||||
| @@ -3012,6 +3061,44 @@ | |||||
| "node": ">=14.18.0" | "node": ">=14.18.0" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/i18next": { | |||||
| "version": "23.7.11", | |||||
| "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.7.11.tgz", | |||||
| "integrity": "sha512-A/vOkw8vY99YHU9A1Td3I1dcTiYaPnwBWzrpVzfXUXSYgogK3cmBcmop/0cnXPc6QpUWIyqaugKNxRUEZVk9Nw==", | |||||
| "funding": [ | |||||
| { | |||||
| "type": "individual", | |||||
| "url": "https://locize.com" | |||||
| }, | |||||
| { | |||||
| "type": "individual", | |||||
| "url": "https://locize.com/i18next.html" | |||||
| }, | |||||
| { | |||||
| "type": "individual", | |||||
| "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" | |||||
| } | |||||
| ], | |||||
| "dependencies": { | |||||
| "@babel/runtime": "^7.23.2" | |||||
| } | |||||
| }, | |||||
| "node_modules/i18next-browser-languagedetector": { | |||||
| "version": "4.0.1", | |||||
| "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-4.0.1.tgz", | |||||
| "integrity": "sha512-RxSoX6mB8cab0CTIQ+klCS764vYRj+Jk621cnFVsINvcdlb/cdi3vQFyrPwmnowB7ReUadjHovgZX+RPIzHVQQ==", | |||||
| "dependencies": { | |||||
| "@babel/runtime": "^7.5.5" | |||||
| } | |||||
| }, | |||||
| "node_modules/i18next-resources-to-backend": { | |||||
| "version": "1.2.0", | |||||
| "resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.0.tgz", | |||||
| "integrity": "sha512-8f1l03s+QxDmCfpSXCh9V+AFcxAwIp0UaroWuyOx+hmmv8484GcELHs+lnu54FrNij8cDBEXvEwhzZoXsKcVpg==", | |||||
| "dependencies": { | |||||
| "@babel/runtime": "^7.23.2" | |||||
| } | |||||
| }, | |||||
| "node_modules/ignore": { | "node_modules/ignore": { | ||||
| "version": "5.3.0", | "version": "5.3.0", | ||||
| "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", | "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", | ||||
| @@ -3610,6 +3697,16 @@ | |||||
| "url": "https://github.com/sponsors/sindresorhus" | "url": "https://github.com/sponsors/sindresorhus" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/lodash.get": { | |||||
| "version": "4.4.2", | |||||
| "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", | |||||
| "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==" | |||||
| }, | |||||
| "node_modules/lodash.includes": { | |||||
| "version": "4.3.0", | |||||
| "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", | |||||
| "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" | |||||
| }, | |||||
| "node_modules/lodash.merge": { | "node_modules/lodash.merge": { | ||||
| "version": "4.6.2", | "version": "4.6.2", | ||||
| "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", | "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", | ||||
| @@ -4422,6 +4519,27 @@ | |||||
| "react": "^16.8.0 || ^17 || ^18" | "react": "^16.8.0 || ^17 || ^18" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/react-i18next": { | |||||
| "version": "13.5.0", | |||||
| "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-13.5.0.tgz", | |||||
| "integrity": "sha512-CFJ5NDGJ2MUyBohEHxljOq/39NQ972rh1ajnadG9BjTk+UXbHLq4z5DKEbEQBDoIhUmmbuS/fIMJKo6VOax1HA==", | |||||
| "dependencies": { | |||||
| "@babel/runtime": "^7.22.5", | |||||
| "html-parse-stringify": "^3.0.1" | |||||
| }, | |||||
| "peerDependencies": { | |||||
| "i18next": ">= 23.2.3", | |||||
| "react": ">= 16.8.0" | |||||
| }, | |||||
| "peerDependenciesMeta": { | |||||
| "react-dom": { | |||||
| "optional": true | |||||
| }, | |||||
| "react-native": { | |||||
| "optional": true | |||||
| } | |||||
| } | |||||
| }, | |||||
| "node_modules/react-is": { | "node_modules/react-is": { | ||||
| "version": "18.2.0", | "version": "18.2.0", | ||||
| "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", | "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", | ||||
| @@ -5087,6 +5205,14 @@ | |||||
| "uuid": "dist/bin/uuid" | "uuid": "dist/bin/uuid" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/void-elements": { | |||||
| "version": "3.1.0", | |||||
| "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", | |||||
| "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", | |||||
| "engines": { | |||||
| "node": ">=0.10.0" | |||||
| } | |||||
| }, | |||||
| "node_modules/watchpack": { | "node_modules/watchpack": { | ||||
| "version": "2.4.0", | "version": "2.4.0", | ||||
| "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", | "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", | ||||
| @@ -17,11 +17,15 @@ | |||||
| "@mui/icons-material": "^5.15.0", | "@mui/icons-material": "^5.15.0", | ||||
| "@mui/material": "^5.15.0", | "@mui/material": "^5.15.0", | ||||
| "@mui/material-nextjs": "^5.15.0", | "@mui/material-nextjs": "^5.15.0", | ||||
| "@unly/universal-language-detector": "^2.0.3", | |||||
| "i18next": "^23.7.11", | |||||
| "i18next-resources-to-backend": "^1.2.0", | |||||
| "next": "14.0.4", | "next": "14.0.4", | ||||
| "next-auth": "^4.24.5", | "next-auth": "^4.24.5", | ||||
| "react": "^18", | "react": "^18", | ||||
| "react-dom": "^18", | "react-dom": "^18", | ||||
| "react-hook-form": "^7.49.2" | |||||
| "react-hook-form": "^7.49.2", | |||||
| "react-i18next": "^13.5.0" | |||||
| }, | }, | ||||
| "devDependencies": { | "devDependencies": { | ||||
| "@types/node": "^20", | "@types/node": "^20", | ||||
| @@ -1,4 +1,4 @@ | |||||
| const Dashboard: React.FC = () => { | |||||
| const Dashboard: React.FC = async () => { | |||||
| return "Dashboard (protected)"; | return "Dashboard (protected)"; | ||||
| }; | }; | ||||
| @@ -1,5 +1,5 @@ | |||||
| import type { Metadata } from "next"; | import type { Metadata } from "next"; | ||||
| import { detectLanguage } from "@/i18n"; | |||||
| import ThemeRegistry from "@/theme/ThemeRegistry"; | import ThemeRegistry from "@/theme/ThemeRegistry"; | ||||
| export const metadata: Metadata = { | export const metadata: Metadata = { | ||||
| @@ -7,13 +7,15 @@ export const metadata: Metadata = { | |||||
| description: "Generated by create next app", | description: "Generated by create next app", | ||||
| }; | }; | ||||
| export default function RootLayout({ | |||||
| export default async function RootLayout({ | |||||
| children, | children, | ||||
| }: { | }: { | ||||
| children: React.ReactNode; | children: React.ReactNode; | ||||
| }) { | }) { | ||||
| const lang = await detectLanguage(); | |||||
| return ( | return ( | ||||
| <html lang="en"> | |||||
| <html lang={lang}> | |||||
| <body> | <body> | ||||
| <ThemeRegistry>{children}</ThemeRegistry> | <ThemeRegistry>{children}</ThemeRegistry> | ||||
| </body> | </body> | ||||
| @@ -1,9 +1,8 @@ | |||||
| import Grid from "@mui/material/Grid"; | |||||
| import Paper from "@mui/material/Paper"; | |||||
| import LoginForm from "./LoginForm"; | |||||
| import { getServerSession } from "next-auth"; | import { getServerSession } from "next-auth"; | ||||
| import { redirect } from "next/navigation"; | import { redirect } from "next/navigation"; | ||||
| import { authOptions } from "@/config/authConfig"; | import { authOptions } from "@/config/authConfig"; | ||||
| import { I18nProvider } from "@/i18n"; | |||||
| import LoginPage from "@/components/LoginPage/LoginPage"; | |||||
| const Login: React.FC = async () => { | const Login: React.FC = async () => { | ||||
| const session = await getServerSession(authOptions); | const session = await getServerSession(authOptions); | ||||
| @@ -12,16 +11,9 @@ const Login: React.FC = async () => { | |||||
| } | } | ||||
| return ( | return ( | ||||
| <Grid container height="100vh"> | |||||
| <Grid item sm> | |||||
| Hero | |||||
| </Grid> | |||||
| <Grid item xs={12} sm={8} lg={5}> | |||||
| <Paper square sx={{ height: "100%" }}> | |||||
| <LoginForm /> | |||||
| </Paper> | |||||
| </Grid> | |||||
| </Grid> | |||||
| <I18nProvider namespaces={["login"]}> | |||||
| <LoginPage /> | |||||
| </I18nProvider> | |||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -5,10 +5,12 @@ import Button from "@mui/material/Button"; | |||||
| import Stack from "@mui/material/Stack"; | import Stack from "@mui/material/Stack"; | ||||
| import TextField from "@mui/material/TextField"; | import TextField from "@mui/material/TextField"; | ||||
| import Typography from "@mui/material/Typography"; | import Typography from "@mui/material/Typography"; | ||||
| import { TFunction } from "i18next"; | |||||
| import { signIn } from "next-auth/react"; | import { signIn } from "next-auth/react"; | ||||
| import { useRouter } from "next/navigation"; | import { useRouter } from "next/navigation"; | ||||
| import { useState } from "react"; | import { useState } from "react"; | ||||
| import { SubmitHandler, useForm } from "react-hook-form"; | import { SubmitHandler, useForm } from "react-hook-form"; | ||||
| import { useTranslation } from "react-i18next"; | |||||
| type LoginFields = { | type LoginFields = { | ||||
| username: string; | username: string; | ||||
| @@ -16,17 +18,21 @@ type LoginFields = { | |||||
| }; | }; | ||||
| // Error codes in https://next-auth.js.org/configuration/pages#sign-in-page | // Error codes in https://next-auth.js.org/configuration/pages#sign-in-page | ||||
| const getHumanFriendlyErrorMessage = (serverError: string): string => { | |||||
| const getHumanFriendlyErrorMessage = ( | |||||
| t: TFunction, | |||||
| serverError: string, | |||||
| ): string => { | |||||
| switch (serverError) { | switch (serverError) { | ||||
| case "CredentialsSignin": | case "CredentialsSignin": | ||||
| return "Invalid username or password."; | |||||
| return t("Invalid username or password."); | |||||
| case "Default": | case "Default": | ||||
| default: | default: | ||||
| return "Something went wrong. Please try again later."; | |||||
| return t("Something went wrong. Please try again later."); | |||||
| } | } | ||||
| }; | }; | ||||
| const LoginForm: React.FC = () => { | const LoginForm: React.FC = () => { | ||||
| const { t } = useTranslation("login"); | |||||
| const { | const { | ||||
| register, | register, | ||||
| handleSubmit, | handleSubmit, | ||||
| @@ -60,23 +66,23 @@ const LoginForm: React.FC = () => { | |||||
| component="form" | component="form" | ||||
| onSubmit={handleSubmit(onSubmit)} | onSubmit={handleSubmit(onSubmit)} | ||||
| > | > | ||||
| <Typography variant="h1">Sign In</Typography> | |||||
| <Typography variant="h1">{t("Sign In")}</Typography> | |||||
| <TextField | <TextField | ||||
| label="Username" | |||||
| {...register("username", { required: "Please enter a username" })} | |||||
| label={t("Username")} | |||||
| {...register("username", { required: t("Please enter a username") })} | |||||
| error={Boolean(errors.username)} | error={Boolean(errors.username)} | ||||
| helperText={errors.username?.message} | helperText={errors.username?.message} | ||||
| /> | /> | ||||
| <TextField | <TextField | ||||
| label="Password" | |||||
| label={t("Password")} | |||||
| type="password" | type="password" | ||||
| {...register("password", { required: "Please enter a password" })} | |||||
| {...register("password", { required: t("Please enter a password") })} | |||||
| error={Boolean(errors.password)} | error={Boolean(errors.password)} | ||||
| helperText={errors.password?.message} | helperText={errors.password?.message} | ||||
| /> | /> | ||||
| {serverError && ( | {serverError && ( | ||||
| <FormHelperText error> | <FormHelperText error> | ||||
| {getHumanFriendlyErrorMessage(serverError)} | |||||
| {getHumanFriendlyErrorMessage(t, serverError)} | |||||
| </FormHelperText> | </FormHelperText> | ||||
| )} | )} | ||||
| <Button | <Button | ||||
| @@ -85,7 +91,7 @@ const LoginForm: React.FC = () => { | |||||
| type="submit" | type="submit" | ||||
| disabled={isSubmitting} | disabled={isSubmitting} | ||||
| > | > | ||||
| Login | |||||
| {t("Login")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| ); | ); | ||||
| @@ -0,0 +1,20 @@ | |||||
| import Grid from "@mui/material/Grid"; | |||||
| import Paper from "@mui/material/Paper"; | |||||
| import LoginForm from "./LoginForm"; | |||||
| const LoginPage = () => { | |||||
| return ( | |||||
| <Grid container height="100vh"> | |||||
| <Grid item sm> | |||||
| test | |||||
| </Grid> | |||||
| <Grid item xs={12} sm={8} lg={5}> | |||||
| <Paper square sx={{ height: "100%" }}> | |||||
| <LoginForm /> | |||||
| </Paper> | |||||
| </Grid> | |||||
| </Grid> | |||||
| ); | |||||
| }; | |||||
| export default LoginPage; | |||||
| @@ -0,0 +1 @@ | |||||
| export { default } from "./LoginPage"; | |||||
| @@ -1,7 +1,12 @@ | |||||
| import { AuthOptions } from "next-auth"; | |||||
| import { AuthOptions, Session } from "next-auth"; | |||||
| import CredentialsProvider from "next-auth/providers/credentials"; | import CredentialsProvider from "next-auth/providers/credentials"; | ||||
| import { LOGIN_API_PATH } from "./api"; | import { LOGIN_API_PATH } from "./api"; | ||||
| interface SessionWithTokens extends Session { | |||||
| accessToken?: string; | |||||
| refreshToken?: string; | |||||
| } | |||||
| export const authOptions: AuthOptions = { | export const authOptions: AuthOptions = { | ||||
| debug: process.env.NODE_ENV === "development", | debug: process.env.NODE_ENV === "development", | ||||
| providers: [ | providers: [ | ||||
| @@ -33,10 +38,21 @@ export const authOptions: AuthOptions = { | |||||
| }, | }, | ||||
| callbacks: { | callbacks: { | ||||
| jwt(params) { | jwt(params) { | ||||
| return params.token; | |||||
| // Add the data from user to the token | |||||
| const { token, user } = params; | |||||
| const newToken = { ...token, ...user }; | |||||
| return newToken; | |||||
| }, | }, | ||||
| session(params) { | |||||
| return params.session; | |||||
| session({ session, token }) { | |||||
| const sessionWithToken: SessionWithTokens = { | |||||
| ...session, | |||||
| // Add the data from the token to the session | |||||
| accessToken: token.accessToken as string | undefined, | |||||
| refreshToken: token.refreshToken as string | undefined, | |||||
| }; | |||||
| return sessionWithToken; | |||||
| }, | }, | ||||
| }, | }, | ||||
| }; | }; | ||||
| @@ -0,0 +1,43 @@ | |||||
| "use client"; | |||||
| import { createInstance, i18n } from "i18next"; | |||||
| import React, { useMemo } from "react"; | |||||
| import { I18nextProvider } from "react-i18next"; | |||||
| interface Props { | |||||
| children: React.ReactNode; | |||||
| language: string; | |||||
| resources: { [ns: string]: any }; | |||||
| namespaces: string[]; | |||||
| } | |||||
| export const I18nContext = React.createContext(null); | |||||
| const I18nProvider: React.FC<Props> = ({ | |||||
| children, | |||||
| language, | |||||
| resources, | |||||
| namespaces, | |||||
| }) => { | |||||
| const i18n = useMemo<i18n>(() => { | |||||
| const instance = createInstance(); | |||||
| instance.init({ | |||||
| resources: { | |||||
| [language]: resources, | |||||
| }, | |||||
| fallbackLng: language, | |||||
| interpolation: { | |||||
| escapeValue: false, | |||||
| }, | |||||
| ns: namespaces, | |||||
| }); | |||||
| return instance as i18n; | |||||
| // No need to check dependencies since this | |||||
| // should only be created once from the server | |||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||||
| }, []); | |||||
| return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>; | |||||
| }; | |||||
| export default I18nProvider; | |||||
| @@ -0,0 +1 @@ | |||||
| {} | |||||
| @@ -0,0 +1,3 @@ | |||||
| { | |||||
| "test": "abc" | |||||
| } | |||||
| @@ -0,0 +1,92 @@ | |||||
| import { cookies, headers } from "next/headers"; | |||||
| import { createInstance, i18n, LanguageDetectorAsyncModule } from "i18next"; | |||||
| import resourcesToBackend from "i18next-resources-to-backend"; | |||||
| import { getServerSession } from "next-auth"; | |||||
| import { authOptions } from "@/config/authConfig"; | |||||
| import I18nClientProvider from "./I18nClientProvider"; | |||||
| import universalLanguageDetect from "@unly/universal-language-detector"; | |||||
| const FALLBACK_LANG = "en"; | |||||
| const SUPPORTED_LANGUAGES = ["en", "zh"]; | |||||
| export const detectLanguage = async (): Promise<string> => { | |||||
| // Logic to get language preference from cookies/headers/session | |||||
| const cookiesList = cookies(); | |||||
| const cookiesObj = cookiesList | |||||
| .getAll() | |||||
| .reduce<{ [name: string]: string }>( | |||||
| (acc, cookie) => ({ ...acc, [cookie.name]: cookie.value }), | |||||
| {}, | |||||
| ); | |||||
| const headersList = headers(); | |||||
| const session = await getServerSession(authOptions); | |||||
| const lang = universalLanguageDetect({ | |||||
| supportedLanguages: SUPPORTED_LANGUAGES, | |||||
| fallbackLanguage: FALLBACK_LANG, | |||||
| acceptLanguageHeader: headersList.get("accept-language") || undefined, | |||||
| serverCookies: cookiesObj, | |||||
| }); | |||||
| return lang; | |||||
| }; | |||||
| const languageDetector: LanguageDetectorAsyncModule = { | |||||
| type: "languageDetector", | |||||
| detect: detectLanguage, | |||||
| async: true, | |||||
| }; | |||||
| const initI18next = async (namespaces: string[]): Promise<i18n> => { | |||||
| const i18nInstance = createInstance(); | |||||
| await i18nInstance | |||||
| .use(languageDetector) | |||||
| .use( | |||||
| resourcesToBackend((language: string, namespace: string) => { | |||||
| return import(`./${language}/${namespace}.json`); | |||||
| }), | |||||
| ) | |||||
| .init({ | |||||
| fallbackLng: "en", | |||||
| interpolation: { | |||||
| escapeValue: false, | |||||
| }, | |||||
| ns: namespaces, | |||||
| }); | |||||
| return i18nInstance as i18n; | |||||
| }; | |||||
| export const getServerI18n = async (...namespaces: string[]) => { | |||||
| return initI18next(namespaces); | |||||
| }; | |||||
| interface Props { | |||||
| children: React.ReactNode; | |||||
| namespaces: string[]; | |||||
| } | |||||
| // Provides the resources for the client | |||||
| export const I18nProvider: React.FC<Props> = async ({ | |||||
| children, | |||||
| namespaces, | |||||
| }) => { | |||||
| const i18n = await getServerI18n(...namespaces); | |||||
| const language = i18n.language; | |||||
| const resources = namespaces.reduce<{ [ns: string]: any }>( | |||||
| (acc, ns) => ({ | |||||
| ...acc, | |||||
| [ns]: i18n.getResourceBundle(language, ns), | |||||
| }), | |||||
| {}, | |||||
| ); | |||||
| return ( | |||||
| <I18nClientProvider | |||||
| language={language} | |||||
| resources={resources} | |||||
| namespaces={namespaces} | |||||
| > | |||||
| {children} | |||||
| </I18nClientProvider> | |||||
| ); | |||||
| }; | |||||
| @@ -0,0 +1,10 @@ | |||||
| { | |||||
| "Invalid username or password.": "帳號或密碼錯誤。", | |||||
| "Something went wrong. Please try again later.": "出了些問題。請稍後再試。", | |||||
| "Username": "帳號", | |||||
| "Password": "密碼", | |||||
| "Please enter a username": "請輸入帳號", | |||||
| "Please enter a password": "請輸入密碼", | |||||
| "Login": "登入", | |||||
| "Sign In": "登入" | |||||
| } | |||||
| @@ -0,0 +1,3 @@ | |||||
| { | |||||
| "test": "def" | |||||
| } | |||||
| @@ -1,8 +1,30 @@ | |||||
| import { withAuth } from "next-auth/middleware"; | |||||
| import { NextRequestWithAuth, withAuth } from "next-auth/middleware"; | |||||
| import { authOptions } from "@/config/authConfig"; | import { authOptions } from "@/config/authConfig"; | ||||
| import { NextFetchEvent, NextResponse } from "next/server"; | |||||
| export default withAuth({ | |||||
| const PUBLIC_ROUTES = ["/login"]; | |||||
| const LANG_QUERY_PARAM = "lang"; | |||||
| const authMiddleware = withAuth({ | |||||
| pages: authOptions.pages, | pages: authOptions.pages, | ||||
| }); | }); | ||||
| export const config = { matcher: ["/dashboard"] }; | |||||
| export default async function middleware( | |||||
| req: NextRequestWithAuth, | |||||
| event: NextFetchEvent, | |||||
| ) { | |||||
| const langPref = req.nextUrl.searchParams.get(LANG_QUERY_PARAM); | |||||
| if (langPref) { | |||||
| // Redirect to same url without the lang query param + set cookies | |||||
| const newUrl = new URL(req.nextUrl); | |||||
| newUrl.searchParams.delete(LANG_QUERY_PARAM); | |||||
| const response = NextResponse.redirect(newUrl); | |||||
| response.cookies.set("i18next", langPref); | |||||
| return response; | |||||
| } | |||||
| // Matcher for using the auth middleware | |||||
| return PUBLIC_ROUTES.some((route) => req.nextUrl.pathname.startsWith(route)) | |||||
| ? NextResponse.next() // Return normal response | |||||
| : await authMiddleware(req, event); // Let auth middleware handle response | |||||
| } | |||||