From 8c5badb43f2089afe1e49579e44ef3c9db090659 Mon Sep 17 00:00:00 2001 From: Artem Ganev Date: Mon, 20 Nov 2023 17:27:03 -0500 Subject: [PATCH] Add login and authentication token --- .env.example | 1 - package.json | 3 +- pnpm-lock.yaml | 23 +++++++ src/assets/styles.scss | 8 ++- src/client/features/auth/AuthForm.module.scss | 14 +++++ src/client/features/auth/AuthForm.tsx | 61 +++++++++++++++++++ src/client/features/auth/AuthWrapper.tsx | 30 +++++++++ src/client/features/auth/LoginButton.tsx | 23 +++++++ src/client/features/auth/auth-api.ts | 30 +++++---- src/client/features/auth/auth-constants.ts | 3 + src/client/features/auth/auth-types.ts | 4 ++ .../features/auth/hooks/use-auth-token.ts | 19 ++++++ .../features/auth/hooks/use-authorized.ts | 6 ++ src/common/common-constants.ts | 2 + src/common/components/Layout.module.scss | 2 + src/common/components/Navigation.tsx | 24 ++++---- src/pages/_app.tsx | 37 +++++------ src/pages/login.tsx | 9 +++ src/server/app/app-module.ts | 2 +- .../features/auth/auth-api-controller.ts | 27 ++++++++ src/server/features/auth/auth-controller.ts | 13 ++++ .../auth/{auth.module.ts => auth-module.ts} | 11 ++-- .../auth/{auth.service.ts => auth-service.ts} | 6 +- src/server/features/auth/auth.controller.ts | 22 ------- .../{jwt-auth.guard.ts => jwt-auth-guard.ts} | 0 ...ocal-auth.guard.ts => local-auth-guard.ts} | 0 .../{jwt.strategy.ts => jwt-strategy.ts} | 0 .../{local.strategy.ts => local-strategy.ts} | 2 +- .../features/blog/blog-api-controller.ts | 7 ++- src/server/features/blog/blog-module.ts | 2 + .../features/career/career-api-controller.ts | 6 +- src/server/features/career/career-module.ts | 4 ++ 32 files changed, 321 insertions(+), 80 deletions(-) create mode 100644 src/client/features/auth/AuthForm.module.scss create mode 100644 src/client/features/auth/AuthForm.tsx create mode 100644 src/client/features/auth/AuthWrapper.tsx create mode 100644 src/client/features/auth/LoginButton.tsx create mode 100644 src/client/features/auth/auth-constants.ts create mode 100644 src/client/features/auth/auth-types.ts create mode 100644 src/client/features/auth/hooks/use-auth-token.ts create mode 100644 src/client/features/auth/hooks/use-authorized.ts create mode 100644 src/pages/login.tsx create mode 100644 src/server/features/auth/auth-api-controller.ts create mode 100644 src/server/features/auth/auth-controller.ts rename src/server/features/auth/{auth.module.ts => auth-module.ts} (65%) rename src/server/features/auth/{auth.service.ts => auth-service.ts} (84%) delete mode 100644 src/server/features/auth/auth.controller.ts rename src/server/features/auth/guards/{jwt-auth.guard.ts => jwt-auth-guard.ts} (100%) rename src/server/features/auth/guards/{local-auth.guard.ts => local-auth-guard.ts} (100%) rename src/server/features/auth/strategies/{jwt.strategy.ts => jwt-strategy.ts} (100%) rename src/server/features/auth/strategies/{local.strategy.ts => local-strategy.ts} (92%) diff --git a/.env.example b/.env.example index cc24dd42..141ba0ab 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,4 @@ BROWSER=none DB_URI=mongodb://localhost:27017/artyom-ganev-node JWT_SECRET= -NEW_RELIC_KEY= PORT=3000 diff --git a/package.json b/package.json index 8eddffd5..c7f8013b 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,8 @@ "ts-jest": "^29.1.1", "ts-loader": "^9.5.1", "ts-node": "^10.9.1", - "tsconfig-paths": "^4.2.0" + "tsconfig-paths": "^4.2.0", + "zustand": "4.4.6" }, "devDependencies": { "@babel/core": "^7.18.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dde3d784..e2e9ed79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,6 +135,9 @@ dependencies: tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 + zustand: + specifier: 4.4.6 + version: 4.4.6(@types/react@18.2.37)(react@18.2.0) devDependencies: '@babel/core': @@ -8227,3 +8230,23 @@ packages: /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + + /zustand@4.4.6(@types/react@18.2.37)(react@18.2.0): + resolution: {integrity: sha512-Rb16eW55gqL4W2XZpJh0fnrATxYEG3Apl2gfHTyDSE965x/zxslTikpNch0JgNjJA9zK6gEFW8Fl6d1rTZaqgg==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0' + react: ^18.2.0 + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + dependencies: + '@types/react': 18.2.37 + react: 18.2.0 + use-sync-external-store: 1.2.0(react@18.2.0) + dev: false diff --git a/src/assets/styles.scss b/src/assets/styles.scss index 5d96e10d..0c36eaab 100644 --- a/src/assets/styles.scss +++ b/src/assets/styles.scss @@ -69,6 +69,10 @@ h3 { width: 100vw; } +.ag-textAlign-center { + text-align: center; +} + ::-webkit-scrollbar { position: relative; width: 15px; @@ -77,12 +81,12 @@ h3 { ::-webkit-scrollbar-thumb { background-clip: content-box; - background-color: #c7c7c7; + background-color: $purpleColor; border-radius: 8px; border: 4px solid transparent; &:hover, &:active { - background-color: #767676; + background-color: $darkBlueColor; } } diff --git a/src/client/features/auth/AuthForm.module.scss b/src/client/features/auth/AuthForm.module.scss new file mode 100644 index 00000000..a0866174 --- /dev/null +++ b/src/client/features/auth/AuthForm.module.scss @@ -0,0 +1,14 @@ +@import 'src/assets/variables'; + +.form { + padding: $padding-md; + + &Item { + padding: $padding-sm 0; + text-align: center; + } +} + +.error { + color: red; +} diff --git a/src/client/features/auth/AuthForm.tsx b/src/client/features/auth/AuthForm.tsx new file mode 100644 index 00000000..cd936dc1 --- /dev/null +++ b/src/client/features/auth/AuthForm.tsx @@ -0,0 +1,61 @@ +import { ChangeEvent, JSX, useCallback, useState } from 'react'; + +import { Button } from '@mui/base/Button'; +import { Input } from '@mui/base/Input'; +import { useMutation } from '@tanstack/react-query'; +import { useRouter } from 'next/router'; +import { loginApi } from 'src/client/features/auth/auth-api'; +import { useAuthToken } from 'src/client/features/auth/hooks/use-auth-token'; + +import styles from './AuthForm.module.scss'; + +const AuthForm = (): JSX.Element => { + const router = useRouter(); + const [login, setLogin] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const { setToken } = useAuthToken(); + const { mutate } = useMutation({ + mutationFn: async () => { + if (!login) { + setError('Invalid login'); + return ''; + } + if (!password) { + setError('Invalid password'); + return ''; + } + const { authToken } = await loginApi(login, password); + return authToken; + }, + onSuccess: (token: string) => { + setToken(token); + void router.push('/'); + }, + onError: (error) => { + setError(error.message); + }, + }); + const handleLoginChange = useCallback(({ target }: ChangeEvent) => setLogin(target.value), []); + const handlePasswordChange = useCallback( + ({ target }: ChangeEvent) => setPassword(target.value), + [] + ); + const handleLogin = useCallback(() => mutate(), [mutate]); + return ( +
+
+ +
+
+ +
+
+ +
+ {error ?
{error}
: null} +
+ ); +}; + +export default AuthForm; diff --git a/src/client/features/auth/AuthWrapper.tsx b/src/client/features/auth/AuthWrapper.tsx new file mode 100644 index 00000000..ddfaefec --- /dev/null +++ b/src/client/features/auth/AuthWrapper.tsx @@ -0,0 +1,30 @@ +import { JSX, PropsWithChildren } from 'react'; + +import { useQuery } from '@tanstack/react-query'; +import { useRouter } from 'next/router'; +import { LOGIN_PAGE_URL } from 'src/common/common-constants'; + +import { checkAuthTokenApi } from './auth-api'; +import { CHECK_AUTH_TOKEN_QUERY_KEY } from './auth-constants'; +import { AuthStore } from './auth-types'; +import { useAuthToken } from './hooks/use-auth-token'; + +const AuthWrapper = ({ children }: PropsWithChildren): JSX.Element => { + const router = useRouter(); + const { isLoading } = useQuery>({ + queryFn: async () => { + const { token, setToken }: AuthStore = useAuthToken.getState(); + try { + return await checkAuthTokenApi(token); + } catch (e) { + setToken(''); + void router.replace(LOGIN_PAGE_URL); + } + }, + queryKey: [CHECK_AUTH_TOKEN_QUERY_KEY], + refetchOnMount: false, + }); + return <>{isLoading ? 'Loading... ' : children}; +}; + +export default AuthWrapper; diff --git a/src/client/features/auth/LoginButton.tsx b/src/client/features/auth/LoginButton.tsx new file mode 100644 index 00000000..17d6e583 --- /dev/null +++ b/src/client/features/auth/LoginButton.tsx @@ -0,0 +1,23 @@ +'use client'; +import { JSX, useCallback } from 'react'; + +import { Button } from '@mui/base/Button'; +import { useRouter } from 'next/router'; +import { useAuthToken } from 'src/client/features/auth/hooks/use-auth-token'; +import { useAuthorized } from 'src/client/features/auth/hooks/use-authorized'; +import { LOGIN_PAGE_URL } from 'src/common/common-constants'; + +const LoginButton = (): JSX.Element => { + const router = useRouter(); + const { setToken } = useAuthToken(); + const isAuthorized = useAuthorized(); + const handleLoginClick = useCallback(() => router.push(LOGIN_PAGE_URL), []); + const handleLogoutClick = useCallback(() => setToken(''), [setToken]); + return isAuthorized ? ( + + ) : ( + + ); +}; + +export default LoginButton; diff --git a/src/client/features/auth/auth-api.ts b/src/client/features/auth/auth-api.ts index 39a71115..7324efff 100644 --- a/src/client/features/auth/auth-api.ts +++ b/src/client/features/auth/auth-api.ts @@ -1,16 +1,20 @@ import httpClient from 'src/client/app/http-client'; -export const auth = async (username, password) => - httpClient.post(`auth`, { - json: { - username, - password, - }, - }); +export const loginApi = (username: string, password: string): Promise<{ authToken: string }> => + httpClient + .post('auth/login', { + json: { + username, + password, + }, + }) + .json(); -export const user = async (accessToken) => - httpClient.get('user', { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); +export const checkAuthTokenApi = (token: string): Promise>> => + httpClient + .get('auth/check-token', { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + .json(); diff --git a/src/client/features/auth/auth-constants.ts b/src/client/features/auth/auth-constants.ts new file mode 100644 index 00000000..3c3f0a01 --- /dev/null +++ b/src/client/features/auth/auth-constants.ts @@ -0,0 +1,3 @@ +export const AUTH_TOKEN_KEY = 'AUTH_TOKEN'; + +export const CHECK_AUTH_TOKEN_QUERY_KEY = 'CHECK_AUTH_TOKEN_QUERY_KEY'; diff --git a/src/client/features/auth/auth-types.ts b/src/client/features/auth/auth-types.ts new file mode 100644 index 00000000..3ea36fe8 --- /dev/null +++ b/src/client/features/auth/auth-types.ts @@ -0,0 +1,4 @@ +export interface AuthStore { + setToken: (token: string) => void; + token: string; +} diff --git a/src/client/features/auth/hooks/use-auth-token.ts b/src/client/features/auth/hooks/use-auth-token.ts new file mode 100644 index 00000000..deacd6d2 --- /dev/null +++ b/src/client/features/auth/hooks/use-auth-token.ts @@ -0,0 +1,19 @@ +import { create } from 'zustand'; +import { createJSONStorage, devtools, persist } from 'zustand/middleware'; + +import { AuthStore } from '../auth-types'; + +export const useAuthToken = create()( + devtools( + persist( + (set) => ({ + token: '', + setToken: (token: string) => set(() => ({ token: token })), + }), + { + name: 'auth-store', + storage: createJSONStorage(() => sessionStorage), + } + ) + ) +); diff --git a/src/client/features/auth/hooks/use-authorized.ts b/src/client/features/auth/hooks/use-authorized.ts new file mode 100644 index 00000000..e33f3e8d --- /dev/null +++ b/src/client/features/auth/hooks/use-authorized.ts @@ -0,0 +1,6 @@ +import { useAuthToken } from './use-auth-token'; + +export const useAuthorized = (): boolean => { + const { token } = useAuthToken(); + return token.length > 0; +}; diff --git a/src/common/common-constants.ts b/src/common/common-constants.ts index 858292fc..df201474 100644 --- a/src/common/common-constants.ts +++ b/src/common/common-constants.ts @@ -10,6 +10,8 @@ export const BLOG_PAGE_ID = 'blog'; export const BLOG_PAGE_URL = `/${BLOG_PAGE_ID}`; export const CAREER_PAGE_ID = 'career'; export const CAREER_PAGE_URL = `/${CAREER_PAGE_ID}`; +export const LOGIN_PAGE_ID = 'login'; +export const LOGIN_PAGE_URL = `/${LOGIN_PAGE_ID}`; if (isDev) { console.log('isServer', isServer); diff --git a/src/common/components/Layout.module.scss b/src/common/components/Layout.module.scss index 60e38c1b..58434b35 100644 --- a/src/common/components/Layout.module.scss +++ b/src/common/components/Layout.module.scss @@ -2,7 +2,9 @@ .header { height: $headerHeight; + left: $padding-lg; position: fixed; + right: $padding-lg; } .main { diff --git a/src/common/components/Navigation.tsx b/src/common/components/Navigation.tsx index fba2b651..3990f032 100644 --- a/src/common/components/Navigation.tsx +++ b/src/common/components/Navigation.tsx @@ -7,17 +7,19 @@ import ApiLink from './ApiLink'; import styles from './Navigation.module.scss'; const Navigation = (): ReactElement => ( -