diff --git a/README.md b/README.md index ac9ab6f..d8bffd4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next ## 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 nvm use @@ -21,21 +23,10 @@ npm run dev 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) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b0f42d7..f8b745f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,15 @@ "@mui/icons-material": "^5.15.0", "@mui/material": "^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-auth": "^4.24.5", "react": "^18", "react-dom": "^18", - "react-hook-form": "^7.49.2" + "react-hook-form": "^7.49.2", + "react-i18next": "^13.5.0" }, "devDependencies": { "@types/node": "^20", @@ -1068,6 +1072,43 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "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": { "version": "8.11.2", "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", "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": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", @@ -3012,6 +3061,44 @@ "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": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", @@ -3610,6 +3697,16 @@ "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": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4422,6 +4519,27 @@ "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": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -5087,6 +5205,14 @@ "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": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", diff --git a/package.json b/package.json index f83f509..677cae9 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,15 @@ "@mui/icons-material": "^5.15.0", "@mui/material": "^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-auth": "^4.24.5", "react": "^18", "react-dom": "^18", - "react-hook-form": "^7.49.2" + "react-hook-form": "^7.49.2", + "react-i18next": "^13.5.0" }, "devDependencies": { "@types/node": "^20", diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 59ee896..5f24ae0 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,4 +1,4 @@ -const Dashboard: React.FC = () => { +const Dashboard: React.FC = async () => { return "Dashboard (protected)"; }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 705cdc8..9eb564b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; - +import { detectLanguage } from "@/i18n"; import ThemeRegistry from "@/theme/ThemeRegistry"; export const metadata: Metadata = { @@ -7,13 +7,15 @@ export const metadata: Metadata = { description: "Generated by create next app", }; -export default function RootLayout({ +export default async function RootLayout({ children, }: { children: React.ReactNode; }) { + const lang = await detectLanguage(); + return ( - + {children} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 1708823..7f9d7d7 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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 { redirect } from "next/navigation"; import { authOptions } from "@/config/authConfig"; +import { I18nProvider } from "@/i18n"; +import LoginPage from "@/components/LoginPage/LoginPage"; const Login: React.FC = async () => { const session = await getServerSession(authOptions); @@ -12,16 +11,9 @@ const Login: React.FC = async () => { } return ( - - - Hero - - - - - - - + + + ); }; diff --git a/src/app/login/LoginForm.tsx b/src/components/LoginPage/LoginForm.tsx similarity index 73% rename from src/app/login/LoginForm.tsx rename to src/components/LoginPage/LoginForm.tsx index 9a8244a..38b1bab 100644 --- a/src/app/login/LoginForm.tsx +++ b/src/components/LoginPage/LoginForm.tsx @@ -5,10 +5,12 @@ 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 { TFunction } from "i18next"; import { signIn } from "next-auth/react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { SubmitHandler, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; type LoginFields = { username: string; @@ -16,17 +18,21 @@ type LoginFields = { }; // 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) { case "CredentialsSignin": - return "Invalid username or password."; + return t("Invalid username or password."); case "Default": default: - return "Something went wrong. Please try again later."; + return t("Something went wrong. Please try again later."); } }; const LoginForm: React.FC = () => { + const { t } = useTranslation("login"); const { register, handleSubmit, @@ -60,23 +66,23 @@ const LoginForm: React.FC = () => { component="form" onSubmit={handleSubmit(onSubmit)} > - Sign In + {t("Sign In")} {serverError && ( - {getHumanFriendlyErrorMessage(serverError)} + {getHumanFriendlyErrorMessage(t, serverError)} )} ); diff --git a/src/components/LoginPage/LoginPage.tsx b/src/components/LoginPage/LoginPage.tsx new file mode 100644 index 0000000..497d188 --- /dev/null +++ b/src/components/LoginPage/LoginPage.tsx @@ -0,0 +1,20 @@ +import Grid from "@mui/material/Grid"; +import Paper from "@mui/material/Paper"; +import LoginForm from "./LoginForm"; + +const LoginPage = () => { + return ( + + + test + + + + + + + + ); +}; + +export default LoginPage; diff --git a/src/components/LoginPage/index.ts b/src/components/LoginPage/index.ts new file mode 100644 index 0000000..f815230 --- /dev/null +++ b/src/components/LoginPage/index.ts @@ -0,0 +1 @@ +export { default } from "./LoginPage"; diff --git a/src/config/authConfig.ts b/src/config/authConfig.ts index f2b689e..bc15325 100644 --- a/src/config/authConfig.ts +++ b/src/config/authConfig.ts @@ -1,7 +1,12 @@ -import { AuthOptions } from "next-auth"; +import { AuthOptions, Session } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import { LOGIN_API_PATH } from "./api"; +interface SessionWithTokens extends Session { + accessToken?: string; + refreshToken?: string; +} + export const authOptions: AuthOptions = { debug: process.env.NODE_ENV === "development", providers: [ @@ -33,10 +38,21 @@ export const authOptions: AuthOptions = { }, callbacks: { 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; }, }, }; diff --git a/src/i18n/I18nClientProvider.tsx b/src/i18n/I18nClientProvider.tsx new file mode 100644 index 0000000..0d2c13a --- /dev/null +++ b/src/i18n/I18nClientProvider.tsx @@ -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 = ({ + children, + language, + resources, + namespaces, +}) => { + const i18n = useMemo(() => { + 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 {children}; +}; + +export default I18nProvider; diff --git a/src/i18n/en/login.json b/src/i18n/en/login.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/src/i18n/en/login.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/i18n/en/translation.json b/src/i18n/en/translation.json new file mode 100644 index 0000000..080318d --- /dev/null +++ b/src/i18n/en/translation.json @@ -0,0 +1,3 @@ +{ + "test": "abc" +} \ No newline at end of file diff --git a/src/i18n/index.tsx b/src/i18n/index.tsx new file mode 100644 index 0000000..b404524 --- /dev/null +++ b/src/i18n/index.tsx @@ -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 => { + // 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 => { + 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 = 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 ( + + {children} + + ); +}; diff --git a/src/i18n/zh/login.json b/src/i18n/zh/login.json new file mode 100644 index 0000000..da69471 --- /dev/null +++ b/src/i18n/zh/login.json @@ -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": "登入" +} \ No newline at end of file diff --git a/src/i18n/zh/translation.json b/src/i18n/zh/translation.json new file mode 100644 index 0000000..cd1d02f --- /dev/null +++ b/src/i18n/zh/translation.json @@ -0,0 +1,3 @@ +{ + "test": "def" +} \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts index 856504c..a701511 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,8 +1,30 @@ -import { withAuth } from "next-auth/middleware"; +import { NextRequestWithAuth, withAuth } from "next-auth/middleware"; 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, }); -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 +}