diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..ccd1bc6 --- /dev/null +++ b/.env.dist @@ -0,0 +1 @@ +VITE_BACKEND_ENDPOINT=https://localhost:3001 diff --git a/.eslintrc.cjs b/.eslintrc.cjs index f5c4de7..f877798 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -22,6 +22,9 @@ module.exports = { '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-return':'off', + '@typescript-eslint/no-floating-promises':'off', + '@typescript-eslint/no-misused-promises':'off', 'react-refresh/only-export-components': [ 'warn', {allowConstantExport: true}, diff --git a/.gitignore b/.gitignore index a547bf3..7ceb59f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +.env diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b9c2626 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 behcet ilhan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 0d6babe..cdaf655 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,74 @@ -# React + TypeScript + Vite -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. -Currently, two official plugins are available: +
+ ReactRover Logo -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +# ReactRover +
-## Expanding the ESLint configuration +--- -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: -- Configure the top-level `parserOptions` property like this: +## Why ? -```js -export default { - // other rules... - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: ['./tsconfig.json', './tsconfig.node.json'], - tsconfigRootDir: __dirname, - }, -} +ReactRover aims to ease the initiation phase of any React project, presenting one of many possible solutions to streamline the process. It brings together a selection of tools and libraries—like Vite, Bun, MUI, and Tanstack—that have proven useful in reducing setup times and complexity. This toolkit is offered as a starting point, a way to quickly transition from concept to development, allowing you to focus more on creating and less on configuring. + +--- + +## What's Included ? + +ReactRover incorporates a carefully curated set of tools and libraries essential for modern React development. Below are the key components included in the toolkit, along with links to their official documentation: + +- **[Vite](https://vitejs.dev/)**: A modern frontend build tool that significantly improves the development experience with faster rebuilds and a lot of out-of-the-box features. +- **[Bun](https://bun.sh/)**: A fast all-in-one JavaScript runtime that includes a package manager and bundler. +- **[MUI (Material-UI)](https://mui.com/)**: MUI offers a comprehensive suite of free UI tools to help you ship new features faster. Start with Material UI, our fully-loaded component library, or bring your own design system to our production-ready components. +- **[Tanstack React Router](https://tanstack.com/router/latest)**: A flexible and lightweight routing library for React. +- **[Tanstack Query](https://tanstack.com/query/latest)**: A powerful data fetching and server state management tool in React applications. +- **[Formik](https://formik.org/)**: A small library that helps you with the 3 most annoying parts: getting values in and out of form state, validation and error messages, handling form submission. +- **[i18next](https://www.i18next.com/)**: An internationalization-framework written in and for JavaScript. +- **[React Toastify](https://fkhadra.github.io/react-toastify/)**: Allows you to add notifications to your app with ease. +- **[Zod](https://zod.dev/)**: TypeScript-first schema validation with static type inference. +- **[Axios](https://axios-http.com/)**: Promise based HTTP client for the browser and node.js. +- **[Axios Auth Refresh](https://www.npmjs.com/package/axios-auth-refresh)**: A small library that intercepts failed requests and retries them after refreshing an auth token. + + +Additionally, ReactRover supports built-in theme toggling for dark and light modes, as well as language switching capabilities, enhancing usability and customization for a diverse user base. + +--- + +## Prerequisites + +Before installing ReactRover, ensure that your system meets the following requirements: +- **Node.js**: You'll need [Node.js](https://nodejs.org/) version 16 or newer installed on your system. +- **Bun**: [Bun](https://bun.sh/) is crucial for some of ReactRover's build processes. Ensure it is installed on your system. +- **HTTPS Local Development**: For integration with the Mock Server, setting up HTTPS for local development is recommended. Tools like [mkcert](https://github.com/FiloSottile/mkcert) can assist in managing local certificates + +## Installation & Usage + +```sh +git clone https://github.com/behcetilhan/reactrover.git reactrover +cd reactrover +bun install ``` -- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` -- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list +Create `.env` file in root directory with `VITE_BACKEND_ENDPOINT` defined. For [Testing Server](https://github.com/your-username/mock-server), you can use the port defined in `.env.dist`. + +```sh +bun run dev +``` + +--- + +## Recommended Tool for Testing + +### Dedicated Mock Server for Authentication and Authorization + +For an optimal testing experience with ReactRover, you can use this simple mock server. This server is specifically designed to complement ReactRover by providing a minimalistic backend environment for authentication and authorization. By using this server, you can effectively test and develop secure login flows, token refresh mechanisms, and access controls as implemented in ReactRover. + +**Key features include**: +- JWT-based authentication and HttpOnly cookie management. +- Secure refresh token implementation for maintaining sessions. +- HTTPS configuration to ensure encrypted data transmission. +- Pre-configured protected routes to simulate access control. + +For detailed setup instructions and how to integrate this server with ReactRover, please visit the [Testing Server](https://github.com/your-username/mock-server). diff --git a/bun.lockb b/bun.lockb index 5e5956e..701161d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/index.html b/index.html index 990e82d..d0d9346 100644 --- a/index.html +++ b/index.html @@ -2,12 +2,12 @@ - + - Vite + React + TS + ReactRover -
+
diff --git a/package.json b/package.json index 67fb3f4..80a705b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "@emotion/react": "^11.11.4", + "@emotion/styled": "^11.11.5", + "@fontsource/roboto": "^5.0.12", + "@mui/icons-material": "^5.15.15", + "@mui/material": "^5.15.15", "@tanstack/react-query": "^5.28.14", "@tanstack/react-router": "^1.26.7", "axios": "^1.6.8", @@ -18,14 +23,19 @@ "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react": "^7.34.1", "formik": "^2.4.5", + "i18next": "^23.11.1", + "install": "^0.13.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-i18next": "^14.1.0", + "react-toastify": "^10.0.5", "zod": "^3.22.4", "zod-formik-adapter": "^1.3.0" }, "devDependencies": { "@tanstack/router-devtools": "^1.26.7", "@tanstack/router-vite-plugin": "^1.26.6", + "@types/bun": "^1.1.0", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", "@typescript-eslint/eslint-plugin": "^7.2.0", @@ -35,6 +45,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.6", "typescript": "^5.2.2", - "vite": "^5.2.0" + "vite": "^5.2.0", + "vitest": "^1.5.2" } } diff --git a/public/favico.svg b/public/favico.svg new file mode 100644 index 0000000..e299be6 --- /dev/null +++ b/public/favico.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/rrLogo.png b/public/rrLogo.png new file mode 100644 index 0000000..b02c151 Binary files /dev/null and b/public/rrLogo.png differ diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..e39945e --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,8 @@ +import { Router, RouterProvider } from '@tanstack/react-router' +import { useAppContext } from '@/utils/hooks/useAppContext' + +export const App = ({ router }: { router: Router }) => { + const auth = useAppContext() + + return +} diff --git a/src/components/RootComponent.tsx b/src/components/RootComponent.tsx new file mode 100644 index 0000000..9de4af7 --- /dev/null +++ b/src/components/RootComponent.tsx @@ -0,0 +1,34 @@ +import { ThemeToggler } from '@/components/themeToggler/ThemeToggler' +import { LanguageToggler } from '@/components/languageToggler/LanguageToggler' +import { Outlet, useRouter } from '@tanstack/react-router' +import { Button, Stack } from '@mui/material' +import { setAuthToken, tokenGlobal } from '@/utils/apiDefaults' + +export const RootComponent = () => { + const router = useRouter() + return ( +
+ + {tokenGlobal && ( + + )} + + + +
+ +
+ ) +} diff --git a/src/components/auth/LoginComponent.tsx b/src/components/auth/LoginComponent.tsx index 5d29906..2b2bc51 100644 --- a/src/components/auth/LoginComponent.tsx +++ b/src/components/auth/LoginComponent.tsx @@ -1,42 +1,65 @@ -import { Field, Form, Formik } from 'formik' +import { useFormik } from 'formik' import { loginSchema } from '@/components/auth/loginSchema' import { toFormikValidationSchema } from 'zod-formik-adapter' -import { useMutation } from '@tanstack/react-query' -import { User } from '@/utils/AuthContext' -import axios from 'axios' +import { Button, Container, Stack, TextField } from '@mui/material' +import { useTranslation } from 'react-i18next' +import { useLogin } from '@/utils/hooks/useLogin' export const LoginComponent = () => { - const mutation = useMutation({ - mutationFn: ({ username, password }: User) => { - return axios.post( - 'https://localhost:3001/login', - { username, password }, - { withCredentials: true } - ) - }, - onSuccess: () => { - console.log('success') - }, - onError: (error) => console.log('error -->', error) - }) + const { t } = useTranslation() - return ( - { - console.log('values -->', values) - mutation.mutate(values) - }} - validationSchema={toFormikValidationSchema(loginSchema)} - > -
- - - - -
+ }, + validationSchema: toFormikValidationSchema(loginSchema), + onSubmit: (values) => { + const { username, password } = values + return loginMutation.mutate({ + username, + password + }) + } + }) + + return ( + +
+ + + + + +
+
) } diff --git a/src/components/dashboard/DashboardComponent.tsx b/src/components/dashboard/DashboardComponent.tsx new file mode 100644 index 0000000..0917a2e --- /dev/null +++ b/src/components/dashboard/DashboardComponent.tsx @@ -0,0 +1,50 @@ +import { Avatar, Box, Button, Container, Stack } from '@mui/material' +import { useNavigate } from '@tanstack/react-router' +import { useAppContext } from '@/utils/hooks/useAppContext' +import { useLogout } from '@/utils/hooks/useLogout' +import { axiosBase } from '@/utils/apiDefaults' +import { useQuery } from '@tanstack/react-query' + +interface ProtectedDataProps { + id: number + secret: string + userId: number +} + +export const DashboardComponent = () => { + const { user } = useAppContext() + const navigate = useNavigate() + const { mutate: logout } = useLogout() + + const { data: protectedData, refetch } = useQuery({ + queryKey: ['protectedExample'], + queryFn: async () => { + return await axiosBase.get('/protected') + } + }) + + return ( + + Logged in User Info + {user && ( + + + {user.username} + + )} + {protectedData?.data.secret} + + + + + + + ) +} diff --git a/src/components/languageToggler/LanguageToggler.tsx b/src/components/languageToggler/LanguageToggler.tsx new file mode 100644 index 0000000..3563a17 --- /dev/null +++ b/src/components/languageToggler/LanguageToggler.tsx @@ -0,0 +1,19 @@ +import { useTranslation } from 'react-i18next' +import { useLanguageSelect } from '@/utils/hooks/useLanguageSelect' +import { MenuItem, Select } from '@mui/material' + +export const LanguageToggler = () => { + const { currentLanguage, handleLanguageSelect } = useLanguageSelect() + const { t } = useTranslation() + + return ( + + ) +} diff --git a/src/components/profile/ProfileComponent.tsx b/src/components/profile/ProfileComponent.tsx new file mode 100644 index 0000000..e59c943 --- /dev/null +++ b/src/components/profile/ProfileComponent.tsx @@ -0,0 +1,18 @@ +import { Button, Container } from '@mui/material' +import { useNavigate } from '@tanstack/react-router' + +export const ProfileComponent = () => { + const navigate = useNavigate() + return ( + +
Another protected route example
+ +
+ ) +} diff --git a/src/components/themeToggler/ThemeToggler.tsx b/src/components/themeToggler/ThemeToggler.tsx new file mode 100644 index 0000000..1c305b1 --- /dev/null +++ b/src/components/themeToggler/ThemeToggler.tsx @@ -0,0 +1,14 @@ +import { useThemeContext } from '@/utils/hooks/useThemeContext' +import { IconButton } from '@mui/material' +import Brightness4Icon from '@mui/icons-material/Brightness4' +import Brightness7Icon from '@mui/icons-material/Brightness7' + +export const ThemeToggler = () => { + const { toggleTheme, isDarkMode } = useThemeContext() + + return ( + + {isDarkMode ? : } + + ) +} diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..3ae017f --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,18 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' +import en from '@/locales/en.json' +import de from '@/locales/de.json' + +export const initI18n = () => { + i18n.use(initReactI18next).init({ + fallbackLng: 'en', + resources: { + en: { + translation: en + }, + de: { + translation: de + } + } + }) +} diff --git a/src/locales/de.json b/src/locales/de.json new file mode 100644 index 0000000..65f9ad8 --- /dev/null +++ b/src/locales/de.json @@ -0,0 +1,11 @@ +{ + "languages": { + "english": "Englisch", + "german": "Deutsch" + }, + "auth": { + "username": "Benutzername", + "password": "Passwort", + "login": "Einloggen" + } +} diff --git a/src/locales/en.json b/src/locales/en.json new file mode 100644 index 0000000..3517508 --- /dev/null +++ b/src/locales/en.json @@ -0,0 +1,11 @@ +{ + "languages": { + "english": "English", + "german": "German" + }, + "auth": { + "username": "Username", + "password": "Password", + "login": "Login" + } +} diff --git a/src/main.tsx b/src/main.tsx index 6a074f3..85ca738 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,17 +1,24 @@ import ReactDOM from 'react-dom/client' +import { StrictMode } from 'react' +import { CssBaseline } from '@mui/material' +import { App } from '@/App' +import { MultiThemeProvider } from '@/utils/ThemeContext' +import { initI18n } from '@/i18n' +import { AppProvider } from '@/utils/AppProvider' +import { ToastContainer } from 'react-toastify' +import 'react-toastify/dist/ReactToastify.min.css' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { - createRouter, - ErrorComponent, - RouterProvider -} from '@tanstack/react-router' +import { createRouter, ErrorComponent } from '@tanstack/react-router' import { routeTree } from '@/routeTree.gen' -import { useAuth } from '@/utils/hooks/useAuth' -import { AuthProvider } from '@/utils/AuthContext' -import { StrictMode } from 'react' const queryClient = new QueryClient() +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + const router = createRouter({ routeTree, context: { @@ -25,37 +32,23 @@ const router = createRouter({ defaultNotFoundComponent: () => is a 404 }) -declare module '@tanstack/react-router' { - interface Register { - router: typeof router - } -} - -function InnerApp() { - const auth = useAuth() - return ( - - - - ) -} - -function App() { - return ( - - - - ) -} - -const rootElement = document.getElementById('app')! +const rootElement = document.getElementById('appRoot')! if (!rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement) + initI18n() root.render( - + + + + + + + + + ) } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 748cd85..f92b7c0 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -12,7 +12,10 @@ import { Route as rootRoute } from './routes/__root' import { Route as LoginImport } from './routes/login' +import { Route as AuthenticatedImport } from './routes/_authenticated' import { Route as IndexImport } from './routes/index' +import { Route as AuthenticatedProfileImport } from './routes/_authenticated/profile' +import { Route as AuthenticatedDashboardImport } from './routes/_authenticated/dashboard' // Create/Update Routes @@ -21,11 +24,26 @@ const LoginRoute = LoginImport.update({ getParentRoute: () => rootRoute, } as any) +const AuthenticatedRoute = AuthenticatedImport.update({ + id: '/_authenticated', + getParentRoute: () => rootRoute, +} as any) + const IndexRoute = IndexImport.update({ path: '/', getParentRoute: () => rootRoute, } as any) +const AuthenticatedProfileRoute = AuthenticatedProfileImport.update({ + path: '/profile', + getParentRoute: () => AuthenticatedRoute, +} as any) + +const AuthenticatedDashboardRoute = AuthenticatedDashboardImport.update({ + path: '/dashboard', + getParentRoute: () => AuthenticatedRoute, +} as any) + // Populate the FileRoutesByPath interface declare module '@tanstack/react-router' { @@ -34,15 +52,34 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexImport parentRoute: typeof rootRoute } + '/_authenticated': { + preLoaderRoute: typeof AuthenticatedImport + parentRoute: typeof rootRoute + } '/login': { preLoaderRoute: typeof LoginImport parentRoute: typeof rootRoute } + '/_authenticated/dashboard': { + preLoaderRoute: typeof AuthenticatedDashboardImport + parentRoute: typeof AuthenticatedImport + } + '/_authenticated/profile': { + preLoaderRoute: typeof AuthenticatedProfileImport + parentRoute: typeof AuthenticatedImport + } } } // Create and export the route tree -export const routeTree = rootRoute.addChildren([IndexRoute, LoginRoute]) +export const routeTree = rootRoute.addChildren([ + IndexRoute, + AuthenticatedRoute.addChildren([ + AuthenticatedDashboardRoute, + AuthenticatedProfileRoute, + ]), + LoginRoute, +]) /* prettier-ignore-end */ diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 44e8618..8dcb3f4 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,18 +1,13 @@ -import { createRootRouteWithContext, Outlet } from '@tanstack/react-router' +import { createRootRouteWithContext } from '@tanstack/react-router' import { QueryClient } from '@tanstack/react-query' -import { AuthContextProps } from 'utils/AuthContext' +import { AppContextProps } from '@/utils/AppContext' +import { RootComponent } from '@/components/RootComponent' -interface RouterContextProps { - auth: AuthContextProps +export interface RouterContextProps { + auth: AppContextProps queryClient: QueryClient } export const Route = createRootRouteWithContext()({ - component: () => ( -
- root itself -
- -
- ) + component: RootComponent }) diff --git a/src/routes/_authenticated.ts b/src/routes/_authenticated.ts new file mode 100644 index 0000000..bebc3cc --- /dev/null +++ b/src/routes/_authenticated.ts @@ -0,0 +1,37 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' +import { getRefreshToken, setAuthToken, tokenGlobal } from '@/utils/apiDefaults' + +export const Route = createFileRoute('/_authenticated')({ + beforeLoad: async ({ context, location }) => { + const { setUser } = context.auth + const persistedUserInfo = localStorage.getItem('user') + + if (!persistedUserInfo) { + throw redirect({ + to: '/login', + search: { + redirect: location.href + } + }) + } + + try { + const accessToken = await getRefreshToken() + + if (tokenGlobal !== accessToken) { + setAuthToken(accessToken) + } + + setUser({ + ...(persistedUserInfo && JSON.parse(persistedUserInfo)) + }) + } catch (err) { + console.error('err', err) + redirect({ to: '/login' }) + } + + return { + username: context.auth.user + } + } +}) diff --git a/src/routes/_authenticated/dashboard.tsx b/src/routes/_authenticated/dashboard.tsx new file mode 100644 index 0000000..8eb5f97 --- /dev/null +++ b/src/routes/_authenticated/dashboard.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { DashboardComponent } from '@/components/dashboard/DashboardComponent' + +export const Route = createFileRoute('/_authenticated/dashboard')({ + component: () => +}) diff --git a/src/routes/_authenticated/profile.tsx b/src/routes/_authenticated/profile.tsx new file mode 100644 index 0000000..0bed0a5 --- /dev/null +++ b/src/routes/_authenticated/profile.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { ProfileComponent } from '@/components/profile/ProfileComponent' + +export const Route = createFileRoute('/_authenticated/profile')({ + component: () => +}) diff --git a/src/routes/index.tsx b/src/routes/index.tsx index eeaf1b5..6fcfef5 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,5 +1,17 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, redirect } from '@tanstack/react-router' +import { DashboardComponent } from '@/components/dashboard/DashboardComponent' export const Route = createFileRoute('/')({ - component: () =>
Hello /!
+ beforeLoad: ({ context }) => { + if (context.auth.user) { + throw redirect({ + to: '/login' + }) + } + + throw redirect({ + to: '/dashboard' + }) + }, + component: DashboardComponent }) diff --git a/src/routes/login.tsx b/src/routes/login.tsx index 90ef509..07dbb2c 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -1,6 +1,11 @@ import { createFileRoute } from '@tanstack/react-router' import { LoginComponent } from '@/components/auth/LoginComponent' +import { z } from 'zod' export const Route = createFileRoute('/login')({ - component: () => + validateSearch: z.object({ + redirect: z.string().optional() + }) +}).update({ + component: LoginComponent }) diff --git a/src/utils/AppContext.tsx b/src/utils/AppContext.tsx new file mode 100644 index 0000000..c86a0e4 --- /dev/null +++ b/src/utils/AppContext.tsx @@ -0,0 +1,27 @@ +import { createContext } from 'react' + +export interface LoginResponseProps { + username: string + userId: number + accessToken: string | undefined + avatarURL: string +} + +export interface User extends Omit {} + +export interface LoginRequestProps { + username: string + password: string +} + +export interface AppContextProps { + setUser: (user: User | null) => void + user: User | null +} + +const appInitials: AppContextProps = { + setUser: () => {}, + user: null +} + +export const AppContext = createContext(appInitials) diff --git a/src/utils/AppProvider.tsx b/src/utils/AppProvider.tsx new file mode 100644 index 0000000..dc5c215 --- /dev/null +++ b/src/utils/AppProvider.tsx @@ -0,0 +1,18 @@ +import { PropsWithChildren, useMemo, useState } from 'react' +import { AppContext, User } from '@/utils/AppContext' + +export const AppProvider = (props: PropsWithChildren) => { + const [user, setUser] = useState(null) + + const value = useMemo( + () => ({ + user, + setUser + }), + [user] + ) + + return ( + {props.children} + ) +} diff --git a/src/utils/AuthContext.tsx b/src/utils/AuthContext.tsx deleted file mode 100644 index 63e9129..0000000 --- a/src/utils/AuthContext.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { createContext, PropsWithChildren, useMemo, useState } from 'react' - -export interface User { - username: string - password: string -} - -export interface AuthContextProps { - isAuthenticated: boolean - setUser: (user: User | null) => void - user: User | null -} - -const authInitials: AuthContextProps = { - isAuthenticated: false, - setUser: () => {}, - user: null -} - -export const AuthContext = createContext(authInitials) - -export const AuthProvider = (props: PropsWithChildren) => { - const [user, setUser] = useState(null) - const isAuthenticated = !!user - - const value = useMemo( - () => ({ isAuthenticated, user, setUser }), - [isAuthenticated, user] - ) - - return ( - {props.children} - ) -} diff --git a/src/utils/ThemeContext.tsx b/src/utils/ThemeContext.tsx new file mode 100644 index 0000000..2f92374 --- /dev/null +++ b/src/utils/ThemeContext.tsx @@ -0,0 +1,55 @@ +import { createContext, PropsWithChildren, useEffect, useState } from 'react' +import { createTheme } from '@mui/material/styles' +import { ThemeProvider, useMediaQuery } from '@mui/material' + +interface ThemeContextProps { + toggleTheme: () => void + isDarkMode: boolean +} + +const themeInitials: ThemeContextProps = { + toggleTheme: () => {}, + isDarkMode: false +} + +export const ThemeContext = createContext(themeInitials) + +export const MultiThemeProvider = (props: PropsWithChildren) => { + const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)') + const [isDarkMode, setIsDarkMode] = useState(() => { + const persistedPreference = localStorage.getItem('theme') + + if (persistedPreference) { + return persistedPreference === 'dark' + } + + return prefersDarkMode + }) + + useEffect(() => { + localStorage.setItem('theme', isDarkMode ? 'dark' : 'light') + }, [isDarkMode]) + + const toggleTheme = () => setIsDarkMode(!isDarkMode) + + const theme = createTheme({ + palette: { + mode: isDarkMode ? 'dark' : 'light', + primary: { + main: '#556cd6' + }, + secondary: { + main: '#19857b' + }, + error: { + main: '#ff0000' + } + } + }) + + return ( + + {props.children} + + ) +} diff --git a/src/utils/apiDefaults.ts b/src/utils/apiDefaults.ts index 751ab89..bd8e20f 100644 --- a/src/utils/apiDefaults.ts +++ b/src/utils/apiDefaults.ts @@ -1,6 +1,60 @@ -import axios from 'axios' +import axios, { AxiosError, AxiosResponse } from 'axios' +import createAuthRefreshInterceptor from 'axios-auth-refresh' + +export const backendURL = import.meta.env.VITE_BACKEND_ENDPOINT + +if (!backendURL) { + console.error('BACKEND_ENDPOINT must be set. Please copy .env.dist to .env') +} export const axiosBase = axios.create({ withCredentials: true, - baseURL: 'https://localhost:3001' + baseURL: backendURL }) + +export interface TokenResponse { + accessToken: string +} + +export let tokenGlobal: string | undefined = undefined + +export const getRefreshToken = async () => { + if (tokenGlobal) { + return tokenGlobal + } + + try { + const refreshedToken = + await axiosBase.post('/public/refresh') + + return refreshedToken.data.accessToken + } catch (err) { + console.error('error getRefreshToken', err) + } +} + +export const setAuthToken = (token: string | undefined) => { + tokenGlobal = token + if (token) { + axiosBase.defaults.headers.common['Authorization'] = `Bearer ${token}` + } else { + delete axiosBase.defaults.headers.common['Authorization'] + } +} + +export const refreshAuth = async (failedRequest: AxiosError) => { + return axiosBase + .post('/public/refresh') + .then((tokenRefreshResponse: AxiosResponse) => { + const newAccessToken = tokenRefreshResponse.data.accessToken + setAuthToken(newAccessToken) + + if (failedRequest.response && failedRequest.response.config) { + failedRequest.response.config.headers['Authorization'] = + `Bearer ${newAccessToken}` + } + return Promise.resolve() + }) +} + +createAuthRefreshInterceptor(axiosBase, refreshAuth) diff --git a/src/utils/hooks/useAuth.ts b/src/utils/hooks/useAppContext.ts similarity index 53% rename from src/utils/hooks/useAuth.ts rename to src/utils/hooks/useAppContext.ts index 0f1b5c1..651020e 100644 --- a/src/utils/hooks/useAuth.ts +++ b/src/utils/hooks/useAppContext.ts @@ -1,8 +1,8 @@ import { useContext } from 'react' -import { AuthContext } from '@/utils/AuthContext' +import { AppContext } from '@/utils/AppContext' -export const useAuth = () => { - const context = useContext(AuthContext) +export const useAppContext = () => { + const context = useContext(AppContext) if (!context) { throw new Error('useAuth must be used inside AuthProvider') diff --git a/src/utils/hooks/useLanguageSelect.ts b/src/utils/hooks/useLanguageSelect.ts new file mode 100644 index 0000000..ca5ef16 --- /dev/null +++ b/src/utils/hooks/useLanguageSelect.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react' +import i18n from 'i18next' + +export const useLanguageSelect = () => { + const [currentLanguage, setCurrentLanguage] = useState(i18n.language) + + useEffect(() => { + const handleLanguageChange = () => { + setCurrentLanguage(i18n.language) + } + + i18n.on('languageChanged', handleLanguageChange) + + return () => { + i18n.off('languageChanged', handleLanguageChange) + } + }, []) + + const handleLanguageSelect = (newLanguage: string) => { + i18n.changeLanguage(newLanguage) + } + + return { currentLanguage, handleLanguageSelect } +} diff --git a/src/utils/hooks/useLogin.ts b/src/utils/hooks/useLogin.ts new file mode 100644 index 0000000..717577e --- /dev/null +++ b/src/utils/hooks/useLogin.ts @@ -0,0 +1,56 @@ +import { useMutation } from '@tanstack/react-query' +import { LoginRequestProps, LoginResponseProps } from '@/utils/AppContext' +import { axiosBase, setAuthToken } from '@/utils/apiDefaults' +import { useAppContext } from '@/utils/hooks/useAppContext' +import { AxiosError } from 'axios' +import { toast } from 'react-toastify' +import type { CustomAxiosRequestConfig } from 'axios-auth-refresh/dist/utils' +import { useRouter } from '@tanstack/react-router' + +interface ErrorResponse { + error: string +} + +export const useLogin = () => { + const { setUser } = useAppContext() + const router = useRouter() + + const customAxiosRequestConfig: CustomAxiosRequestConfig = { + skipAuthRefresh: true + } + + return useMutation({ + mutationFn: ({ username, password }: LoginRequestProps) => { + return axiosBase.post( + '/login', + { username, password }, + customAxiosRequestConfig + ) + }, + onSuccess: (res) => { + const { username, userId, accessToken, avatarURL } = res.data + + const userData = { + username, + userId, + avatarURL + } + + localStorage.setItem('user', JSON.stringify(userData)) + + setUser(userData) + + setAuthToken(accessToken) + router.history.push('/dashboard') + }, + onError: (err: AxiosError) => { + let errMsg = 'Network Error' + + if (err.response && err.response.status === 401) { + errMsg = err.response.data.error + } + + toast.error(errMsg) + } + }) +} diff --git a/src/utils/hooks/useLogout.ts b/src/utils/hooks/useLogout.ts new file mode 100644 index 0000000..6e247bc --- /dev/null +++ b/src/utils/hooks/useLogout.ts @@ -0,0 +1,24 @@ +import { useMutation } from '@tanstack/react-query' +import { axiosBase, setAuthToken } from '@/utils/apiDefaults' +import type { CustomAxiosRequestConfig } from 'axios-auth-refresh/dist/utils' +import { useAppContext } from '@/utils/hooks/useAppContext' +import { useRouter } from '@tanstack/react-router' + +export const useLogout = () => { + const { setUser } = useAppContext() + const router = useRouter() + + const customAxiosRequestConfig: CustomAxiosRequestConfig = { + skipAuthRefresh: true + } + + return useMutation({ + mutationFn: () => axiosBase.post('/logout', {}, customAxiosRequestConfig), + onSuccess: () => { + setUser(null) + localStorage.removeItem('user') + setAuthToken(undefined) + router.history.push('/login') + } + }) +} diff --git a/src/utils/hooks/useThemeContext.ts b/src/utils/hooks/useThemeContext.ts new file mode 100644 index 0000000..6ba5c4f --- /dev/null +++ b/src/utils/hooks/useThemeContext.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react' +import { ThemeContext } from '@/utils/ThemeContext' + +export const useThemeContext = () => { + const context = useContext(ThemeContext) + + if (!context) + throw new Error('useThemeContext must be used within a ThemeProvider') + return context +}