diff --git a/src/boot/apollo.ts b/src/boot/apollo.ts index 98c6883..3cbac90 100644 --- a/src/boot/apollo.ts +++ b/src/boot/apollo.ts @@ -1,29 +1,39 @@ -// import { currentOrg } from '@/utils'; -import { AUTH_TOKEN } from '@/shared/constants/storage'; -import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client'; +import { AUTH_TOKEN, AUTH_STATUS } from '@/shared/constants/storage'; +import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client'; import { setContext } from '@apollo/client/link/context'; -const httpLink = createHttpLink({ - // uri: import.meta.env.VITE_DEV_SERVER_URL, +const httpLink = new HttpLink({ uri: process.env.NEXT_PUBLIC_DEV_SERVER_URL, + credentials: 'include', }); const authLink = setContext((_, { headers }) => { - const token = - typeof window !== 'undefined' - ? sessionStorage.getItem(AUTH_TOKEN) || localStorage.getItem(AUTH_TOKEN) - : null; - // const token = sessionStorage.getItem(AUTH_TOKEN) || localStorage.getItem(AUTH_TOKEN); + const accessToken = typeof window !== 'undefined' ? localStorage.getItem(AUTH_TOKEN) : null; return { headers: { ...headers, - Authorization: token ? `Bearer ${token}` : '', + Authorization: accessToken ? `Bearer ${accessToken}` : '', }, }; }); +const responseLink = new ApolloLink((operation, forward) => { + return forward(operation).map((response) => { + if (typeof window !== 'undefined') { + const context = operation.getContext(); + const newAccessToken = context.response.headers.get('x-new-access-token'); + const isRefreshTokenExpired = context.response.headers.get('x-auth-status'); + console.log('newAccessToken from backend', newAccessToken); + console.log('isRefreshTokenExpired from backend', isRefreshTokenExpired); + newAccessToken && localStorage.setItem(AUTH_TOKEN, newAccessToken); + isRefreshTokenExpired && localStorage.setItem(AUTH_STATUS, isRefreshTokenExpired); + } + return response; + }); +}); + export const client = new ApolloClient({ - link: authLink.concat(httpLink), + link: ApolloLink.from([authLink, responseLink, httpLink]), defaultOptions: { watchQuery: { fetchPolicy: 'no-cache', diff --git a/src/containers/Layout/sidebar/SidebarContent.tsx b/src/containers/Layout/sidebar/SidebarContent.tsx index 410e1fb..b3312f3 100644 --- a/src/containers/Layout/sidebar/SidebarContent.tsx +++ b/src/containers/Layout/sidebar/SidebarContent.tsx @@ -2,11 +2,13 @@ import { colorBorder, colorBackground, colorHover } from '@/styles/palette'; import { left } from '@/styles/directions'; import styled from 'styled-components'; -import { AUTH_TOKEN, THEME } from '@/shared/constants/storage'; +import { AUTH_TOKEN, THEME, AUTH_STATUS } from '@/shared/constants/storage'; import { useUserContext } from '@/hooks/userHooks'; import { ROUTE_KEY, getPublicRouteByKey, getRouteByKey } from '@/routes/routeConfig'; import SidebarCategory from './SidebarCategory'; import SidebarLink, { SidebarLinkTitle, SidebarNavLink } from './SidebarLink'; +import { REVOKETOKENS } from '@/graphql/auth'; +import { useMutation } from '@apollo/client'; type SidebarContentProps = { onClick: () => void; @@ -16,9 +18,12 @@ type SidebarContentProps = { const SidebarContent = ({ onClick, $collapse }: SidebarContentProps) => { const { store, setStore } = useUserContext(); - const logout = () => { + const [revokeTokens] = useMutation(REVOKETOKENS); + const logout = async () => { + await revokeTokens(); sessionStorage.setItem(AUTH_TOKEN, ''); localStorage.setItem(AUTH_TOKEN, ''); + localStorage.removeItem(AUTH_STATUS); }; const changeTheme = (color: string) => { diff --git a/src/containers/Layout/topbar/TopbarProfile.tsx b/src/containers/Layout/topbar/TopbarProfile.tsx index a5038ee..9ff4343 100644 --- a/src/containers/Layout/topbar/TopbarProfile.tsx +++ b/src/containers/Layout/topbar/TopbarProfile.tsx @@ -8,22 +8,27 @@ import styled from 'styled-components'; import { marginLeft, right, left } from '@/styles/directions'; import { colorBackground, colorHover, colorText, colorBorder } from '@/styles/palette'; import { useUserContext } from '@/hooks/userHooks'; -import { AUTH_TOKEN } from '@/shared/constants/storage'; +import { AUTH_TOKEN, AUTH_STATUS } from '@/shared/constants/storage'; import Image from 'next/image'; import { TopbarBack, TopbarDownIcon } from './BasicTopbarComponents'; import TopbarMenuLink, { TopbarLink } from './TopbarMenuLink'; +import { REVOKETOKENS } from '@/graphql/auth'; +import { useMutation } from '@apollo/client'; const TopbarProfile = () => { const { store } = useUserContext(); const [isCollapsed, setIsCollapsed] = useState(false); + const [revokeTokens] = useMutation(REVOKETOKENS); const toggleCollapse = () => { setIsCollapsed(!isCollapsed); }; - const logout = () => { + const logout = async () => { + await revokeTokens(); sessionStorage.setItem(AUTH_TOKEN, ''); localStorage.setItem(AUTH_TOKEN, ''); + localStorage.removeItem(AUTH_STATUS); }; return ( diff --git a/src/graphql/auth.ts b/src/graphql/auth.ts index 5a1cb33..834679a 100644 --- a/src/graphql/auth.ts +++ b/src/graphql/auth.ts @@ -1,7 +1,7 @@ import { gql } from './codegen/'; export const USER_LOGIN = gql(` - mutation Login($email: String!, $password: String!) { - login(email: $email, password: $password) { + mutation Login($email: String!, $password: String!, $isStaySignedIn: Boolean!) { + login(email: $email, password: $password, isStaySignedIn: $isStaySignedIn) { code message data @@ -18,3 +18,9 @@ export const USER_REGISTER = gql(` } } `); + +export const REVOKETOKENS = gql(` + mutation RevokeTokens { + revokeTokens + } +`); diff --git a/src/graphql/codegen/gql.ts b/src/graphql/codegen/gql.ts index 38cac59..f620e1c 100644 --- a/src/graphql/codegen/gql.ts +++ b/src/graphql/codegen/gql.ts @@ -13,10 +13,11 @@ import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/ * Therefore it is highly recommended to use the babel or swc plugin for production. */ const documents = { - '\n mutation Login($email: String!, $password: String!) {\n login(email: $email, password: $password) {\n code\n message\n data\n }\n }\n': + '\n mutation Login($email: String!, $password: String!, $isStaySignedIn: Boolean!) {\n login(email: $email, password: $password, isStaySignedIn: $isStaySignedIn) {\n code\n message\n data\n }\n }\n': types.LoginDocument, '\n mutation Register($input: CreateUserInput!) {\n register(input: $input) {\n code\n message\n data\n }\n }\n': types.RegisterDocument, + '\n mutation RevokeTokens {\n revokeTokens\n }\n': types.RevokeTokensDocument, '\n query getUserInfo {\n getUserInfo {\n id\n displayName\n }\n }\n': types.GetUserInfoDocument, '\n query getUserById($id: String!) {\n getUserById(id: $id) {\n id\n email\n realName\n displayName\n mobile\n }\n }\n': @@ -43,14 +44,20 @@ export function gql(source: string): unknown; * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function gql( - source: '\n mutation Login($email: String!, $password: String!) {\n login(email: $email, password: $password) {\n code\n message\n data\n }\n }\n' -): (typeof documents)['\n mutation Login($email: String!, $password: String!) {\n login(email: $email, password: $password) {\n code\n message\n data\n }\n }\n']; + source: '\n mutation Login($email: String!, $password: String!, $isStaySignedIn: Boolean!) {\n login(email: $email, password: $password, isStaySignedIn: $isStaySignedIn) {\n code\n message\n data\n }\n }\n' +): (typeof documents)['\n mutation Login($email: String!, $password: String!, $isStaySignedIn: Boolean!) {\n login(email: $email, password: $password, isStaySignedIn: $isStaySignedIn) {\n code\n message\n data\n }\n }\n']; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function gql( source: '\n mutation Register($input: CreateUserInput!) {\n register(input: $input) {\n code\n message\n data\n }\n }\n' ): (typeof documents)['\n mutation Register($input: CreateUserInput!) {\n register(input: $input) {\n code\n message\n data\n }\n }\n']; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql( + source: '\n mutation RevokeTokens {\n revokeTokens\n }\n' +): (typeof documents)['\n mutation RevokeTokens {\n revokeTokens\n }\n']; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/src/graphql/codegen/graphql.ts b/src/graphql/codegen/graphql.ts index d53c2b1..a1b5c26 100644 --- a/src/graphql/codegen/graphql.ts +++ b/src/graphql/codegen/graphql.ts @@ -83,6 +83,8 @@ export type Mutation = { login: Result; /** User register */ register: Result; + /** User logout */ + revokeTokens: Scalars['Boolean']['output']; /** Update exchange key info */ updateExchangeKey: Scalars['Boolean']['output']; /** Update user info */ @@ -107,6 +109,7 @@ export type MutationDeleteUserArgs = { export type MutationLoginArgs = { email: Scalars['String']['input']; + isStaySignedIn?: InputMaybe; password: Scalars['String']['input']; }; @@ -210,6 +213,7 @@ export type UserType = { export type LoginMutationVariables = Exact<{ email: Scalars['String']['input']; password: Scalars['String']['input']; + isStaySignedIn: Scalars['Boolean']['input']; }>; export type LoginMutation = { @@ -226,6 +230,10 @@ export type RegisterMutation = { register: { __typename?: 'Result'; code: number; message?: string | null; data?: string | null }; }; +export type RevokeTokensMutationVariables = Exact<{ [key: string]: never }>; + +export type RevokeTokensMutation = { __typename?: 'Mutation'; revokeTokens: boolean }; + export type GetUserInfoQueryVariables = Exact<{ [key: string]: never }>; export type GetUserInfoQuery = { @@ -280,6 +288,14 @@ export const LoginDocument = { type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, }, }, + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'isStaySignedIn' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } }, + }, + }, ], selectionSet: { kind: 'SelectionSet', @@ -298,6 +314,11 @@ export const LoginDocument = { name: { kind: 'Name', value: 'password' }, value: { kind: 'Variable', name: { kind: 'Name', value: 'password' } }, }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'isStaySignedIn' }, + value: { kind: 'Variable', name: { kind: 'Name', value: 'isStaySignedIn' } }, + }, ], selectionSet: { kind: 'SelectionSet', @@ -357,6 +378,20 @@ export const RegisterDocument = { }, ], } as unknown as DocumentNode; +export const RevokeTokensDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'RevokeTokens' }, + selectionSet: { + kind: 'SelectionSet', + selections: [{ kind: 'Field', name: { kind: 'Name', value: 'revokeTokens' } }], + }, + }, + ], +} as unknown as DocumentNode; export const GetUserInfoDocument = { kind: 'Document', definitions: [ diff --git a/src/hooks/userHooks.ts b/src/hooks/userHooks.ts index 89a4580..74c4ddc 100644 --- a/src/hooks/userHooks.ts +++ b/src/hooks/userHooks.ts @@ -3,6 +3,7 @@ import { useRouter, usePathname } from 'next/navigation'; import { useAppContext, connectFactory } from '@/shared/utils/contextFactory'; import { GET_USER } from '@/graphql/user'; import { IUser } from '@/shared/utils/types'; +import { AUTH_STATUS } from '@/shared/constants/storage'; // import { useLocation, useHistory } from 'react-router-dom'; @@ -21,8 +22,10 @@ export const useLoadUser = () => { const router = useRouter(); const pathName = usePathname(); + const isLoginPage = pathName === '/login'; const { loading, refetch } = useQuery<{ getUserInfo: IUser }>(GET_USER, { + skip: isLoginPage, onCompleted: (data) => { if (data.getUserInfo) { const { id, displayName } = data.getUserInfo; @@ -43,6 +46,7 @@ export const useLoadUser = () => { console.error('failed retrieving user info, backing to login'); if (!pathName.match('/login') && typeof window !== 'undefined') { router.push(`/login?orgUrl=${pathName}`); + localStorage.removeItem(AUTH_STATUS); } }, }); diff --git a/src/module/account/settings/SettingForm.test.tsx b/src/module/account/settings/SettingForm.test.tsx index 232f502..c3714f9 100644 --- a/src/module/account/settings/SettingForm.test.tsx +++ b/src/module/account/settings/SettingForm.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { DisplayErrorMsgs, EmailErrorMsgs, PasswordErrorMsgs } from '@/shared/utils/helpers'; +import { DisplayErrorMsgs, EmailErrorMsgs } from '@/shared/utils/helpers'; import SettingForm from './SettingForm'; function renderSettingPage() { diff --git a/src/module/auth/login-form/FormLayout.tsx b/src/module/auth/login-form/FormLayout.tsx index d7d9b48..4b122d4 100644 --- a/src/module/auth/login-form/FormLayout.tsx +++ b/src/module/auth/login-form/FormLayout.tsx @@ -3,7 +3,7 @@ import { USER_LOGIN } from '@/graphql/auth'; import { useSearchParams } from '@/hooks/useSearchParams'; import { AccountButton, LoginForm } from '@/shared/components/account/AccountElements'; -import { AUTH_TOKEN, EMAIL, REMEMBER_ME } from '@/shared/constants/storage'; +import { AUTH_TOKEN, EMAIL, STAY_SIGNED_IN } from '@/shared/constants/storage'; import { useMutation } from '@apollo/client'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; @@ -12,25 +12,25 @@ import { Alert } from 'react-bootstrap'; import { FormProvider, useForm } from 'react-hook-form'; import LoginFormGroup from './LoginFormGroup'; -type LoginData = { email: string; password: string; remember_me: boolean }; +type LoginData = { email: string; password: string; isStaySignedIn: boolean }; const FormLayout = () => { const methods = useForm({ defaultValues: { email: '', password: '', - remember_me: false, + isStaySignedIn: false, }, }); const { handleSubmit, watch } = methods; - const rememberMe = watch('remember_me'); + const isStaySignedIn = watch('isStaySignedIn'); useEffect(() => { - if (rememberMe !== undefined && typeof window !== 'undefined') { - localStorage.setItem(REMEMBER_ME, rememberMe.toString()); + if (isStaySignedIn !== undefined && typeof window !== 'undefined') { + localStorage.setItem(STAY_SIGNED_IN, isStaySignedIn.toString()); } - }, [rememberMe]); + }, [isStaySignedIn]); const router = useRouter(); const [error, setError] = useState(''); @@ -45,15 +45,12 @@ const FormLayout = () => { // refresh store after login success // store.refetchHandler(); if (typeof window !== 'undefined') { - if (data.remember_me) { - sessionStorage.setItem(AUTH_TOKEN, ''); + if (data.isStaySignedIn) { localStorage.setItem(EMAIL, data.email); - localStorage.setItem(AUTH_TOKEN, result.data.login.data ?? ''); } else { localStorage.setItem(EMAIL, ''); - localStorage.setItem(AUTH_TOKEN, ''); - sessionStorage.setItem(AUTH_TOKEN, result.data.login.data ?? ''); } + localStorage.setItem(AUTH_TOKEN, result.data.login.data ?? ''); } if (originUrl && originUrl !== '/') { // history.push(originUrl); diff --git a/src/module/auth/login-form/LoginFormGroup.tsx b/src/module/auth/login-form/LoginFormGroup.tsx index 5321662..afe53c9 100644 --- a/src/module/auth/login-form/LoginFormGroup.tsx +++ b/src/module/auth/login-form/LoginFormGroup.tsx @@ -7,7 +7,7 @@ import { import AccountOutlineIcon from 'mdi-react/AccountOutlineIcon'; import FormField from '@/shared/components/form/FormHookField'; import { emailPattern } from '@/shared/utils/helpers'; -import { EMAIL, REMEMBER_ME } from '@/shared/constants/storage'; +import { EMAIL, STAY_SIGNED_IN } from '@/shared/constants/storage'; import { Controller, useFormContext } from 'react-hook-form'; import PasswordField from '@/shared/components/form/Password'; import { AccountForgotPassword } from '@/shared/components/account/AccountElements'; @@ -73,17 +73,19 @@ export default function LoginFormGroup() { - + ( diff --git a/src/shared/Layout/Routes/WrappedRoutes.tsx b/src/shared/Layout/Routes/WrappedRoutes.tsx index 1fd9d9c..3f0a9b9 100644 --- a/src/shared/Layout/Routes/WrappedRoutes.tsx +++ b/src/shared/Layout/Routes/WrappedRoutes.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ROUTE_KEY } from '@/routes/routeConfig'; +import { ROUTE_KEY, getPublicRouteByKey } from '@/routes/routeConfig'; import { isAuthenticated } from '@/shared/utils/auth'; import { useRouter } from 'next/navigation'; import styled from 'styled-components'; @@ -8,6 +8,8 @@ import { paddingLeft } from '@/styles/directions'; import { colorBackgroundBody } from '@/styles/palette'; import { ReactNode, useEffect } from 'react'; import Layout from '@/containers/Layout/Layout'; +import { usePathname } from 'next/navigation'; +import { AUTH_STATUS } from '@/shared/constants/storage'; const ContainerWrap = styled.div` padding-top: 90px; @@ -33,11 +35,13 @@ interface Props { export const WrappedRoutes: React.FC = ({ children }) => { const router = useRouter(); + const pathname = usePathname(); useEffect(() => { if (!isAuthenticated()) { - router.push(ROUTE_KEY.LOGIN); + localStorage.removeItem(AUTH_STATUS); + router.push(getPublicRouteByKey(ROUTE_KEY.LOGIN).path); } - }, [isAuthenticated(), router]); + }, [isAuthenticated(), pathname]); return (
diff --git a/src/shared/constants/storage.ts b/src/shared/constants/storage.ts index 4ed53d7..abf21e9 100644 --- a/src/shared/constants/storage.ts +++ b/src/shared/constants/storage.ts @@ -1,5 +1,6 @@ export const AUTH_TOKEN = 'auth_token'; -export const REMEMBER_ME = 'remember_me'; export const SIDEBAR_COLLAPSED = 'sidebar_collapsed'; export const EMAIL = 'email'; export const THEME = 'theme'; +export const STAY_SIGNED_IN = 'stay_signed_in'; +export const AUTH_STATUS = 'auth_status'; diff --git a/src/shared/utils/auth.ts b/src/shared/utils/auth.ts index 456be29..cd2451b 100644 --- a/src/shared/utils/auth.ts +++ b/src/shared/utils/auth.ts @@ -1,35 +1,9 @@ -import { AUTH_TOKEN, REMEMBER_ME } from '../constants/storage'; - -const parseJwt = (token: string) => { - const base64Url = token.split('.')[1]; - const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); - const jsonPayload = decodeURIComponent( - atob(base64) - .split('') - .map((c) => `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`) - .join('') - ); - - return JSON.parse(jsonPayload); -}; - -const isJWTExpired = (token: string) => { - const { exp } = parseJwt(token); - return exp * 1000 < new Date().getTime(); -}; +import { AUTH_STATUS } from '../constants/storage'; const isAuthenticated = (): boolean => { - const rememberMe = typeof window !== 'undefined' ? localStorage.getItem(REMEMBER_ME) : null; - // const rememberMe = localStorage.getItem(REMEMBER_ME); - let token; - if (rememberMe === 'true') { - token = typeof window !== 'undefined' ? localStorage.getItem(AUTH_TOKEN) : null; - } else { - token = typeof window !== 'undefined' ? sessionStorage.getItem(AUTH_TOKEN) : null; - } - if (!token) return false; - if (isJWTExpired(token)) return false; - return true; + const isRefreshTokenExpired = + typeof window !== 'undefined' ? localStorage.getItem(AUTH_STATUS) : null; + return isRefreshTokenExpired !== 'invalid'; }; -export { isAuthenticated, isJWTExpired }; +export { isAuthenticated }; diff --git a/tsconfig.json b/tsconfig.json index 79332c5..45a37de 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,52 +2,22 @@ "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, - "lib": [ - "ES2020", - "DOM", - "DOM.Iterable" - ], + "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", - "types": [ - "vite/client", - "jest", - "react", - "react-dom", - "node" - ], + "types": ["vite/client", "jest", "react", "react-dom", "node"], "skipLibCheck": true, "baseUrl": "src", "paths": { - "@/styles/*": [ - "styles/*" - ], - "@/config/*": [ - "config/*" - ], - "@/shared/*": [ - "shared/*" - ], - "@/graphql/*": [ - "graphql/*" - ], - "@/utils/*": [ - "shared/utils/*" - ], - "@/hooks/*": [ - "hooks/*" - ], - "@/constants/*": [ - "shared/constants/*" - ], - "@/containers/*": [ - "containers/*" - ], - "@/routes/*": [ - "routes/*" - ], - "@/components/*": [ - "shared/components/*" - ] + "@/styles/*": ["styles/*"], + "@/config/*": ["config/*"], + "@/shared/*": ["shared/*"], + "@/graphql/*": ["graphql/*"], + "@/utils/*": ["shared/utils/*"], + "@/hooks/*": ["hooks/*"], + "@/constants/*": ["shared/constants/*"], + "@/containers/*": ["containers/*"], + "@/routes/*": ["routes/*"], + "@/components/*": ["shared/components/*"] }, /* Bundler mode */ "moduleResolution": "bundler", @@ -73,13 +43,6 @@ } ] }, - "include": [ - "./src", - "./dist/types/**/*.ts", - "./next-env.d.ts", - ".next/types/**/*.ts" - ], - "exclude": [ - "./node_modules" - ] + "include": ["./src", "./dist/types/**/*.ts", "./next-env.d.ts", ".next/types/**/*.ts"], + "exclude": ["./node_modules"] }