diff --git a/package.json b/package.json index 152f3e1e..63da6d6f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "iSunFA", - "version": "0.8.2+25", + "version": "0.8.2+26", "private": false, "scripts": { "dev": "next dev", diff --git a/src/components/cta_section/cta_section.tsx b/src/components/cta_section/cta_section.tsx index 0dd76ff7..beba7b17 100644 --- a/src/components/cta_section/cta_section.tsx +++ b/src/components/cta_section/cta_section.tsx @@ -13,7 +13,7 @@ const CTASection = () => { const animeRef1 = useRef(null); const [isAnimeRef1Visible, setIsAnimeRef1Visible] = useState(false); - const { signedIn } = useContext(UserContext); + const { isSignIn } = useContext(UserContext); useEffect(() => { const waitForCTA = setTimeout(() => { @@ -49,7 +49,7 @@ const CTASection = () => { - + - + { +const LoginAnimation = ({ + setIsAnimationShowing, +}: { + setIsAnimationShowing: React.Dispatch>; +}) => { const [switchTitle, setSwitchTitle] = useState(false); useEffect(() => { @@ -11,8 +15,14 @@ const LoginAnimation = () => { setSwitchTitle(true); }, 3000); + // Info: (20241001 - Liz) 6 秒後關閉動畫 + const closeAnimation = setTimeout(() => { + setIsAnimationShowing(false); + }, 6000); + return () => { clearTimeout(titleTimer); + clearTimeout(closeAnimation); }; }, []); diff --git a/src/components/login_confirm_modal/login_confirm_modal.tsx b/src/components/login_confirm_modal/login_confirm_modal.tsx index ff27f8ff..fafa61ff 100644 --- a/src/components/login_confirm_modal/login_confirm_modal.tsx +++ b/src/components/login_confirm_modal/login_confirm_modal.tsx @@ -6,6 +6,8 @@ import PrivacyPolicy from '@/components/login_confirm_modal/privacy_policy'; import { useUserCtx } from '@/contexts/user_context'; import { Hash } from '@/constants/hash'; import { Button } from '@/components/button/button'; +import { ISUNFA_ROUTE } from '@/constants/url'; +import { useRouter } from 'next/router'; interface ILoginConfirmProps { id: string; @@ -28,6 +30,7 @@ const LoginConfirmModal: React.FC = ({ }) => { const { t } = useTranslation('common'); const { handleUserAgree, signOut } = useUserCtx(); + const router = useRouter(); const onAgree = async () => { if (id === 'agree-to-our-terms-of-service') { @@ -38,6 +41,7 @@ const LoginConfirmModal: React.FC = ({ if (id === 'agree-to-our-privacy-policy') { tosModalVisibilityHandler(false); await handleUserAgree(Hash.HASH_FOR_PRIVACY_POLICY); + router.push(ISUNFA_ROUTE.SELECT_COMPANY); } }; const onCancel = () => { diff --git a/src/components/login_page_body/login_page_body.tsx b/src/components/login_page_body/login_page_body.tsx index ad5ff4d4..82faee51 100644 --- a/src/components/login_page_body/login_page_body.tsx +++ b/src/components/login_page_body/login_page_body.tsx @@ -7,53 +7,17 @@ import { Provider } from '@/constants/provider'; import { useUserCtx } from '@/contexts/user_context'; import { ToastType } from '@/interfaces/toastify'; import { useTranslation } from 'next-i18next'; +import { FiHome } from 'react-icons/fi'; +import I18n from '@/components/i18n/i18n'; +import { signIn } from 'next-auth/react'; -const getProviderDetails = (provider: Provider) => { - return { - logo: provider === Provider.GOOGLE ? '/icons/google_logo.svg' : '/icons/apple_logo.svg', - bgColor: provider === Provider.GOOGLE ? 'bg-white' : 'bg-black', - textColor: provider === Provider.GOOGLE ? 'text-black' : 'text-white', - }; -}; - -const AuthButton = React.memo( - ({ - onClick, - provider, - disabled = false, - }: { - onClick: () => void; - provider: Provider; - disabled?: boolean; - }) => { - const { t } = useTranslation('common'); - const { logo, bgColor, textColor } = getProviderDetails(provider); - - return ( - - - - {t('common:LOGIN_PAGE_BODY.LOGIN_WITH_PROVIDER', { - provider: provider.replace(provider[0], provider[0].toUpperCase()), - })} - - - ); - } -); - -const Loader = React.memo(() => { +const Loader = () => { return ( ); -}); +}; const LoginPageBody = ({ invitation, action }: ILoginPageProps) => { const { t } = useTranslation('common'); @@ -83,25 +47,74 @@ const LoginPageBody = ({ invitation, action }: ILoginPageProps) => { return ( + + + + + + + {isAuthLoading ? ( ) : ( - - - {t('common:LOGIN_PAGE_BODY.LOG_IN')} - - + + + iSunFA + {t('common:LOGIN_PAGE_BODY.LOG_IN')} + + + - - - {/* Info: (20240819-Tzuhan) [Beta] Apple login is not supported in the beta version - */} + + + + + Log In with Google + + + {/* // Info: (20241001 - Liz) 登入 Apple 功能待實作 */} + signIn('apple')} + className="flex items-center justify-center gap-15px rounded-sm bg-black p-15px" + disabled + > + + Log In with Apple + )} ); + + // return ( + // + // + // {isAuthLoading ? ( + // + // ) : ( + // + // + // {t('common:LOGIN_PAGE_BODY.LOG_IN')} + // + // + // + // + // + // + // {/* Info: (20240819-Tzuhan) [Beta] Apple login is not supported in the beta version + // */} + // + // + // )} + // + // ); }; export default LoginPageBody; diff --git a/src/components/nav_bar/nav_bar.tsx b/src/components/nav_bar/nav_bar.tsx index 6f9fd3a2..bb375cc9 100644 --- a/src/components/nav_bar/nav_bar.tsx +++ b/src/components/nav_bar/nav_bar.tsx @@ -29,7 +29,7 @@ import { UploadType } from '@/constants/file'; const NavBar = () => { const { t }: { t: TranslateFunction } = useTranslation('common'); - const { signedIn, signOut, username, selectedCompany, userAuth, isAuthLoading, selectCompany } = + const { isSignIn, signOut, username, selectedCompany, userAuth, isAuthLoading, selectCompany } = useUserCtx(); const { profileUploadModalDataHandler, profileUploadModalVisibilityHandler } = useGlobalCtx(); const router = useRouter(); @@ -119,7 +119,7 @@ const NavBar = () => { className="mx-auto flex w-full items-center gap-16px px-24px py-10px text-button-text-secondary disabled:text-button-text-disable" > {/* */} @@ -127,7 +127,7 @@ const NavBar = () => { {/* */} @@ -149,7 +149,7 @@ const NavBar = () => { className="mx-auto flex w-full items-center gap-16px px-24px py-10px text-button-text-secondary disabled:text-button-text-disable" > {/* */} @@ -157,7 +157,7 @@ const NavBar = () => { {/* */} @@ -223,7 +223,7 @@ const NavBar = () => { className="mx-auto flex flex-col items-center gap-8px hover:text-button-text-primary-hover disabled:text-button-text-disable" > {/* */} @@ -234,7 +234,7 @@ const NavBar = () => { {/* Info: (20240416 - Julian) Account button */} @@ -260,7 +260,7 @@ const NavBar = () => { className="mx-auto flex flex-col items-center gap-8px disabled:text-button-text-disable" > {/* */} @@ -270,7 +270,7 @@ const NavBar = () => { {/* Info: (20240416 - Julian) Report button */} @@ -301,7 +301,7 @@ const NavBar = () => { - {signedIn ? username ?? DEFAULT_DISPLAYED_USER_NAME : ''} + {isSignIn ? (username ?? DEFAULT_DISPLAYED_USER_NAME) : ''} {/* Info: (20240809 - Shirley) edit name button */} { const displayedAvatar = isAuthLoading ? ( - ) : signedIn ? ( + ) : isSignIn ? ( { {/* Info: (20240802 - Shirley) hide the login button for now */} diff --git a/src/components/select_company_page_body/select_company_page_body.tsx b/src/components/select_company_page_body/select_company_page_body.tsx index 20415487..5a0b349b 100644 --- a/src/components/select_company_page_body/select_company_page_body.tsx +++ b/src/components/select_company_page_body/select_company_page_body.tsx @@ -19,7 +19,7 @@ import { useTranslation } from 'next-i18next'; const SelectCompanyPageBody = () => { const { t } = useTranslation(['common', 'kyc']); - const { signedIn, username, selectCompany, successSelectCompany, errorCode, userAuth } = + const { isSignIn, username, selectCompany, successSelectCompany, errorCode, userAuth } = useUserCtx(); const { companyInvitationModalVisibilityHandler, createCompanyModalVisibilityHandler } = useGlobalCtx(); @@ -45,7 +45,7 @@ const SelectCompanyPageBody = () => { Array<{ company: ICompany; role: IRole }> >([]); - const userName = signedIn ? username || DEFAULT_DISPLAYED_USER_NAME : ''; + const userName = isSignIn ? username || DEFAULT_DISPLAYED_USER_NAME : ''; const selectedCompanyName = selectedCompany?.name ?? t('kyc:SELECT_COMPANY.SELECT_AN_COMPANY'); const menuOpenHandler = () => { diff --git a/src/contexts/accounting_context.tsx b/src/contexts/accounting_context.tsx index 9c7d13f5..712d0a65 100644 --- a/src/contexts/accounting_context.tsx +++ b/src/contexts/accounting_context.tsx @@ -192,7 +192,7 @@ const initialAccountingContext: IAccountingContext = { export const AccountingContext = createContext(initialAccountingContext); export const AccountingProvider = ({ children }: IAccountingProvider) => { - const { userAuth, selectedCompany, signedIn } = useUserCtx(); + const { userAuth, selectedCompany, isSignIn } = useUserCtx(); const { trigger: getAccountList, data: accountTitleList, @@ -480,7 +480,7 @@ export const AccountingProvider = ({ children }: IAccountingProvider) => { useEffect(() => { clearOCRs(); - }, [signedIn, selectedCompany]); + }, [isSignIn, selectedCompany]); useEffect(() => { if (accountSuccess && accountTitleList) { diff --git a/src/contexts/global_context.tsx b/src/contexts/global_context.tsx index 241b9b94..24d271af 100644 --- a/src/contexts/global_context.tsx +++ b/src/contexts/global_context.tsx @@ -133,8 +133,7 @@ export const GlobalProvider = ({ children }: IGlobalProvider) => { const router = useRouter(); const { pathname } = router; - const { signedIn, selectedCompany, isAgreeInfoCollection, isAgreeTosNPrivacyPolicy } = - useUserCtx(); + const { isSignIn, selectedCompany, isAgreeTermsOfService, isAgreePrivacyPolicy } = useUserCtx(); const { reportGeneratedStatus, reportPendingStatus, reportGeneratedStatusHandler } = useNotificationCtx(); @@ -361,7 +360,7 @@ export const GlobalProvider = ({ children }: IGlobalProvider) => { }; useEffect(() => { - if (!signedIn) return; + if (!isSignIn) return; if (reportGeneratedStatus) { toastHandler({ @@ -403,14 +402,14 @@ export const GlobalProvider = ({ children }: IGlobalProvider) => { // autoClose: false, // }); // } - }, [reportPendingStatus, reportGeneratedStatus, signedIn, pathname]); + }, [reportPendingStatus, reportGeneratedStatus, isSignIn, pathname]); useEffect(() => { - if (signedIn) { - if (!isAgreeInfoCollection || !isAgreeTosNPrivacyPolicy) { + if (isSignIn) { + if (!isAgreeTermsOfService || !isAgreePrivacyPolicy) { if (router.pathname !== ISUNFA_ROUTE.LOGIN) router.push(ISUNFA_ROUTE.LOGIN); - if (!isAgreeInfoCollection) termsOfServiceConfirmModalVisibilityHandler(true); - if (isAgreeInfoCollection && !isAgreeTosNPrivacyPolicy) { + if (!isAgreeTermsOfService) termsOfServiceConfirmModalVisibilityHandler(true); + if (isAgreeTermsOfService && !isAgreePrivacyPolicy) { privacyPolicyConfirmModalVisibilityHandler(true); } } else { @@ -418,10 +417,10 @@ export const GlobalProvider = ({ children }: IGlobalProvider) => { privacyPolicyConfirmModalVisibilityHandler(false); } } - }, [pathname, signedIn, isAgreeInfoCollection, isAgreeTosNPrivacyPolicy]); + }, [pathname, isSignIn, isAgreeTermsOfService, isAgreePrivacyPolicy]); useEffect(() => { - if (signedIn) { + if (isSignIn) { if (router.pathname.startsWith('/users') && !router.pathname.includes(ISUNFA_ROUTE.LOGIN)) { eliminateToast(ToastId.ALPHA_TEST_REMINDER); if (!router.pathname.includes(ISUNFA_ROUTE.SELECT_COMPANY)) { @@ -468,7 +467,7 @@ export const GlobalProvider = ({ children }: IGlobalProvider) => { // eliminateToast(ToastId.ALPHA_TEST_REMINDER); // } } - }, [pathname, signedIn]); + }, [pathname, isSignIn]); // Info: (20240830 - Anna) 為了拿掉react/jsx-no-constructed-context-values註解,所以使用useMemo hook diff --git a/src/contexts/user_context.tsx b/src/contexts/user_context.tsx index dd5decfe..22e50ee6 100644 --- a/src/contexts/user_context.tsx +++ b/src/contexts/user_context.tsx @@ -21,9 +21,9 @@ interface UserContextType { signOut: () => void; userAuth: IUser | null; username: string | null; - signedIn: boolean; - isAgreeInfoCollection: boolean; - isAgreeTosNPrivacyPolicy: boolean; + isSignIn: boolean; + isAgreeTermsOfService: boolean; + isAgreePrivacyPolicy: boolean; isSignInError: boolean; selectedCompany: ICompany | null; selectCompany: (company: ICompany | null, isPublic?: boolean) => Promise; @@ -52,9 +52,9 @@ export const UserContext = createContext({ signOut: () => {}, userAuth: null, username: null, - signedIn: false, - isAgreeInfoCollection: false, - isAgreeTosNPrivacyPolicy: false, + isSignIn: false, + isAgreeTermsOfService: false, + isAgreePrivacyPolicy: false, isSignInError: false, selectedCompany: null, selectCompany: async () => {}, @@ -76,7 +76,7 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { const router = useRouter(); const EXPIRATION_TIME = 1000 * 60 * 60 * 1; // Info: (20240822) 1 hours - const [, setSignedIn, signedInRef] = useStateRef(false); + const [, setIsSignIn, isSignInRef] = useStateRef(false); const [, setCredential, credentialRef] = useStateRef(null); const [userAuth, setUserAuth, userAuthRef] = useStateRef(null); const [, setUsername, usernameRef] = useStateRef(null); @@ -112,11 +112,11 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { setIsSignInError(!isSignInErrorRef.current); }; - const clearState = () => { + const clearStates = () => { setUserAuth(null); setUsername(null); setCredential(null); - setSignedIn(false); + setIsSignIn(false); setIsSignInError(false); setSelectedCompany(null); setSuccessSelectCompany(undefined); @@ -126,17 +126,23 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { }; // Info: (20240530 - Shirley) 在瀏覽器被重新整理後,如果沒有登入,就 redirect to login page - const handleNotSignedIn = () => { - clearState(); + const redirectToLoginPage = () => { if (router.pathname.startsWith('/users') && !router.pathname.includes(ISUNFA_ROUTE.LOGIN)) { router.push(ISUNFA_ROUTE.LOGIN); } + // Deprecated: (20241001 - Liz) + // eslint-disable-next-line no-console + console.log('呼叫 redirectToLoginPage'); }; - const handleSignInRoute = () => { + // Info: (20241001 - Liz) Alpha:重新導向到選擇公司的頁面 ; Beta:重新導向到選擇角色的頁面 + const redirectToSelectCompanyPage = () => { if (isAgreeTermsOfServiceRef.current && isAgreePrivacyPolicyRef.current) { router.push(ISUNFA_ROUTE.SELECT_COMPANY); } + // Deprecated: (20241001 - Liz) + // eslint-disable-next-line no-console + console.log('呼叫 redirectToSelectCompanyPage'); }; const checkIsRegistered = async (): Promise<{ @@ -193,7 +199,8 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { const signOut = async () => { await signOutAPI(); await authSignOut({ redirect: false }); - handleNotSignedIn(); + clearStates(); + redirectToLoginPage(); }; const isProfileFetchNeeded = () => { @@ -226,116 +233,96 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { return false; }; - /** Info: (20240903 - Shirley) - * 前端登入流程: - * 1. 當用戶點擊登入按鈕時,會調用 `authenticateUser` 函數,傳入選擇的登入方式(如 Google 或 Apple)和登入頁面的 props。 - * 2. `authenticateUser` 函數會調用 `authSignIn` 函數(來自 `next-auth/react`),傳入選擇的登入方式和額外的參數,開始 OAuth2.0 的登入流程。 - * 3. 登入流程跳轉到 `[...nextauth].ts` 中的 `/api/auth/signin` 路由,該路由由 NextAuth 處理。 - * 4. NextAuth 根據選擇的登入方式(如 Google 或 Apple),跳轉到相應的 OAuth 提供者進行用戶認證。 - * 5. 用戶在 OAuth 提供者的頁面上輸入憑證並授權應用訪問其資料。 - * 6. OAuth 提供者認證成功後,會將用戶重定向回應用的 `/api/auth/callback/:provider` 路由,該路由也由 NextAuth 處理。 - * 7. NextAuth 在 `[...nextauth].ts` 的 `signIn` 回調函數中處理登入成功後的邏輯: - * - 檢查用戶是否已存在於資料庫中,如果不存在則創建新用戶。 - * - 調用 `setSession` 函數(來自 `session.ts`)將用戶的 ID 存儲在 session 中。 - * 8. 登入成功後,NextAuth 會將用戶重定向到應用的主頁面(iSunFA login page)。 - * 9. 在主頁面(iSunFA login page)中,`UserProvider` 組件會調用 `getStatusInfo` 函數來獲取當前登入用戶和公司的資料。 - * - `getStatusInfo` 函數會調用 `getStatusInfoAPI`,該 API 會攜帶 NextAuth 管理的 session。 - * - 在 `status_info.ts` 中的 `handleGetRequest` 函數中,通過調用 `getSession` 函數(來自 `session.ts`)獲取當前請求的 session,並從中獲取用戶的 ID 和公司 ID。 - * - 根據獲取到的用戶 ID 和公司 ID,從資料庫中獲取相應的用戶和公司資料,並返回給前端。 - * - 如果回傳的資料有 user 但沒有 company,透過 `handleSignInRoute` 將用戶導向選擇公司的頁面,有 user 跟 company 則透過 `handleReturnUrl` 將用戶導向之前儀表板/嘗試訪問的頁面。 - * 10. 如果 `getStatusInfoAPI` 請求成功,並且返回的數據中包含用戶和公司的資訊: - * - 將這些資料存儲到 React 的 state 中。 - * - 將用戶 ID 存儲到 localStorage 中。 - * - 設置一個過期時間(例如 1 小時後),並將其存儲到 localStorage 中。 - * 11. React state 中的用戶和公司資料會通過 `UserContext` 提供給應用的其他組件使用。 - * 12. 檢查用戶是否已同意所有必要的條款: - * - 如果用戶尚未同意所有必要的條款(如資訊收集同意書和服務條款),系統會顯示相應的同意書頁面。 - * - 用戶需要閱讀並同意這些條款。 - * 13. 當用戶同意條款時: - * - 調用 `handleUserAgree` 函數,該函數會發送 API 請求(`agreementAPI`)來更新用戶的同意狀態。 - * - 如果 API 請求成功,更新本地 state 中的用戶同意狀態(`setIsAgreeTermsOfService` 和 `setIsAgreePrivacyPolicy`)。 - * 14. 當用戶同意所有必要的條款後: - * - 如果用戶尚未選擇公司,系統會將用戶重定向到選擇公司的頁面。 - * - 如果用戶已經選擇了公司,系統會將用戶重定向到儀表板或之前嘗試訪問的頁面(如果有的話)。 - * 15. 在每次頁面加載或路由變更時,系統會檢查 localStorage 中的用戶 ID 和過期時間: - * - 如果存在有效的用戶 ID 和未過期的時間戳,系統會嘗試重新獲取用戶狀態(調用 `getStatusInfo`)。 - * - 如果 localStorage 中的數據已過期或不存在,系統會清除 state 並將用戶重定向到登入頁面。 - * - * 總結: - * - NextAuth 負責管理 OAuth2.0 的登入流程,並在登入成功後調用 `setSession` 函數將用戶的 ID 存儲在 session 中。 - * - 在主頁面(iSunFA login page)中,`getStatusInfo` 函數會調用 `getStatusInfoAPI`,該 API 通過 `getSession` 函數從 NextAuth 管理的 session 中獲取當前登入用戶的 ID 和公司 ID,並根據這些 ID 從資料庫中獲取相應的用戶和公司資料。 - * - 獲取到的用戶和公司資料會存儲到 React 的 state 中,並通過 `UserContext` 提供給應用的其他組件使用。 - * - 用戶 ID 和登入狀態的過期時間會被存儲在 localStorage 中,用於在頁面刷新或重新訪問時快速恢復用戶狀態。 - * - `session.ts` 中提供的 `getSession` 和 `setSession` 函數封裝了 `next-session` 庫的功能,不僅 NextAuth 可以使用這些函數來操作 session,其他後端檔案(如 API 路由)也可以通過調用這些函數來讀取和修改 session。 - * - 登入流程包含了檢查和處理用戶同意條款的邏輯,確保用戶在使用系統之前已經同意了所有必要的條款。 - * - 用戶同意條款的狀態會被更新到資料庫中,並反映在本地 state 中,影響後續的導航邏輯。 - * - 系統使用 localStorage 來保存用戶的登入狀態,以提高用戶體驗並減少不必要的 API 請求。 - */ + // =============================================================================== + // Info: (20241001 - Liz) 此函式根據使用者的協議列表,更新使用者是否同意了服務條款和隱私政策。 + // 它會將結果存入狀態變數 setIsAgreeTermsOfService 和 setIsAgreePrivacyPolicy。 + const updateUserAgreements = (user: IUser) => { + const hasAgreedToTerms = user.agreementList.includes(Hash.HASH_FOR_TERMS_OF_SERVICE); + const hasAgreedToPrivacy = user.agreementList.includes(Hash.HASH_FOR_PRIVACY_POLICY); + + setIsAgreeTermsOfService(hasAgreedToTerms); + setIsAgreePrivacyPolicy(hasAgreedToPrivacy); + }; + + // Info: (20241001 - Liz) 此函式處理公司資訊: + // 如果公司資料存在且不為空,它會設定選定的公司 (setSelectedCompany),並標記成功選擇公司。 + // 若公司資料不存在,會將公司資訊設為空,並檢查路由是否位於 users 路徑中。如果符合條件且不在 SELECT_COMPANY 頁面,它會呼叫 redirectToSelectCompanyPage 函式進行重新導向。 + const processCompanyInfo = (company: ICompany) => { + if (company && Object.keys(company).length > 0) { + setSelectedCompany(company); + setSuccessSelectCompany(true); + handleReturnUrl(); + } else { + setSuccessSelectCompany(undefined); + setSelectedCompany(null); + + const isInUsersRoute = + router.pathname.includes('users') && !router.pathname.includes(ISUNFA_ROUTE.SELECT_COMPANY); + + if (isInUsersRoute) { + redirectToSelectCompanyPage(); + } + } + }; + + // Info: (20241001 - Liz) 此函式處理使用者資訊: + // 如果使用者資料存在且有效,會設定使用者認證、名稱,並標記為已登入。 + // 它還會將使用者的 userId 和過期時間儲存在 localStorage。 + // 最後,它會更新使用者的協議狀態。 + // 如果使用者資料無效,則呼叫 handleNotSignedIn 函式來處理未登入的情況。 + const processUserInfo = (user: IUser) => { + if (user && Object.keys(user).length > 0) { + setUserAuth(user); + setUsername(user.name); + setIsSignIn(true); + setIsSignInError(false); + + localStorage.setItem('userId', user.id.toString()); + localStorage.setItem('expired_at', (Date.now() + EXPIRATION_TIME).toString()); + + updateUserAgreements(user); + } else { + clearStates(); + redirectToLoginPage(); + } + }; + + // Info: (20241001 - Liz) 此函式使用 useCallback 封裝,用來非同步取得使用者和公司狀態資訊。 + // 它首先檢查是否需要取得使用者資料 (isProfileFetchNeeded),如果不需要,則直接返回。 + // 當資料獲取中,它會設定載入狀態 (setIsAuthLoading) 並清除公司選擇狀態。 + // 當 API 回傳成功且有資料時,它會呼叫 processUserInfo 和 processCompanyInfo 分別處理使用者和公司資訊。 + // 如果獲取資料失敗,它會執行未登入的處理邏輯: 清除狀態、導向登入頁面、設定登入錯誤狀態、設定錯誤代碼。 + // 最後,它會將載入狀態設為完成。 const getStatusInfo = useCallback(async () => { - const isNeed = isProfileFetchNeeded(); - if (!isNeed) return; + if (!isProfileFetchNeeded()) return; + setIsAuthLoading(true); + setSelectedCompany(null); + setSuccessSelectCompany(undefined); + const { data: StatusInfo, success: getStatusInfoSuccess, code: getStatusInfoCode, } = await getStatusInfoAPI(); - setSelectedCompany(null); - setSuccessSelectCompany(undefined); - if (getStatusInfoSuccess) { - if (StatusInfo) { - if ('user' in StatusInfo && StatusInfo.user && Object.keys(StatusInfo.user).length > 0) { - setUserAuth(StatusInfo.user); - setUsername(StatusInfo.user.name); - setSignedIn(true); - setIsSignInError(false); - localStorage.setItem('userId', StatusInfo.user.id.toString()); - localStorage.setItem('expired_at', (Date.now() + EXPIRATION_TIME).toString()); - if (StatusInfo.user.agreementList.includes(Hash.HASH_FOR_TERMS_OF_SERVICE)) { - setIsAgreeTermsOfService(true); - } else { - setIsAgreeTermsOfService(false); - } - if (StatusInfo.user.agreementList.includes(Hash.HASH_FOR_PRIVACY_POLICY)) { - setIsAgreePrivacyPolicy(true); - } else { - setIsAgreePrivacyPolicy(false); - } - if ( - 'company' in StatusInfo && - StatusInfo.company && - Object.keys(StatusInfo.company).length > 0 - ) { - setSelectedCompany(StatusInfo.company); - setSuccessSelectCompany(true); - handleReturnUrl(); - } else { - setSuccessSelectCompany(undefined); - setSelectedCompany(null); - if ( - router.pathname.includes('users') && - !router.pathname.includes(ISUNFA_ROUTE.SELECT_COMPANY) - ) { - handleSignInRoute(); - } - } - } else { - handleNotSignedIn(); - } - } - } - if (getStatusInfoSuccess === false) { - handleNotSignedIn(); + + // Deprecated: (20241001 - Liz) + // eslint-disable-next-line no-console + console.log('getStatusInfo', StatusInfo, 'getStatusInfoSuccess', getStatusInfoSuccess); + + if (getStatusInfoSuccess && StatusInfo) { + processUserInfo(StatusInfo.user); + processCompanyInfo(StatusInfo.company); + } else { + clearStates(); + redirectToLoginPage(); setIsSignInError(true); setErrorCode(getStatusInfoCode ?? ''); } + setIsAuthLoading(false); }, [router.pathname]); - - // Info: (20240903 - Shirley) 第一次登入,在用戶同意後,重新導向到選擇公司的頁面 - useEffect(() => { - handleSignInRoute(); - }, [userAgreeResponseRef.current]); + // =============================================================================== const handleUserAgree = async (hash: Hash) => { try { @@ -475,9 +462,9 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { signOut, userAuth: userAuthRef.current, username: usernameRef.current, - signedIn: signedInRef.current, - isAgreeInfoCollection: isAgreeTermsOfServiceRef.current, - isAgreeTosNPrivacyPolicy: isAgreePrivacyPolicyRef.current, + isSignIn: isSignInRef.current, + isAgreeTermsOfService: isAgreeTermsOfServiceRef.current, + isAgreePrivacyPolicy: isAgreePrivacyPolicyRef.current, isSignInError: isSignInErrorRef.current, selectedCompany: selectedCompanyRef.current, selectCompany, @@ -513,3 +500,49 @@ export const useUserCtx = () => { } return context; }; + +/** Info: (20240903 - Shirley) + * 前端登入流程: + * 1. 當用戶點擊登入按鈕時,會調用 `authenticateUser` 函數,傳入選擇的登入方式(如 Google 或 Apple)和登入頁面的 props。 + * 2. `authenticateUser` 函數會調用 `authSignIn` 函數(來自 `next-auth/react`),傳入選擇的登入方式和額外的參數,開始 OAuth2.0 的登入流程。 + * 3. 登入流程跳轉到 `[...nextauth].ts` 中的 `/api/auth/signin` 路由,該路由由 NextAuth 處理。 + * 4. NextAuth 根據選擇的登入方式(如 Google 或 Apple),跳轉到相應的 OAuth 提供者進行用戶認證。 + * 5. 用戶在 OAuth 提供者的頁面上輸入憑證並授權應用訪問其資料。 + * 6. OAuth 提供者認證成功後,會將用戶重定向回應用的 `/api/auth/callback/:provider` 路由,該路由也由 NextAuth 處理。 + * 7. NextAuth 在 `[...nextauth].ts` 的 `signIn` 回調函數中處理登入成功後的邏輯: + * - 檢查用戶是否已存在於資料庫中,如果不存在則創建新用戶。 + * - 調用 `setSession` 函數(來自 `session.ts`)將用戶的 ID 存儲在 session 中。 + * 8. 登入成功後,NextAuth 會將用戶重定向到應用的主頁面(iSunFA login page)。 + * 9. 在主頁面(iSunFA login page)中,`UserProvider` 組件會調用 `getStatusInfo` 函數來獲取當前登入用戶和公司的資料。 + * - `getStatusInfo` 函數會調用 `getStatusInfoAPI`,該 API 會攜帶 NextAuth 管理的 session。 + * - 在 `status_info.ts` 中的 `handleGetRequest` 函數中,通過調用 `getSession` 函數(來自 `session.ts`)獲取當前請求的 session,並從中獲取用戶的 ID 和公司 ID。 + * - 根據獲取到的用戶 ID 和公司 ID,從資料庫中獲取相應的用戶和公司資料,並返回給前端。 + * - 如果回傳的資料有 user 但沒有 company,透過 `redirectToSelectCompanyPage` 將用戶導向選擇公司的頁面,有 user 跟 company 則透過 `handleReturnUrl` 將用戶導向之前儀表板/嘗試訪問的頁面。 + * 10. 如果 `getStatusInfoAPI` 請求成功,並且返回的數據中包含用戶和公司的資訊: + * - 將這些資料存儲到 React 的 state 中。 + * - 將用戶 ID 存儲到 localStorage 中。 + * - 設置一個過期時間(例如 1 小時後),並將其存儲到 localStorage 中。 + * 11. React state 中的用戶和公司資料會通過 `UserContext` 提供給應用的其他組件使用。 + * 12. 檢查用戶是否已同意所有必要的條款: + * - 如果用戶尚未同意所有必要的條款(如資訊收集同意書和服務條款),系統會顯示相應的同意書頁面。 + * - 用戶需要閱讀並同意這些條款。 + * 13. 當用戶同意條款時: + * - 調用 `handleUserAgree` 函數,該函數會發送 API 請求(`agreementAPI`)來更新用戶的同意狀態。 + * - 如果 API 請求成功,更新本地 state 中的用戶同意狀態(`setIsAgreeTermsOfService` 和 `setIsAgreePrivacyPolicy`)。 + * 14. 當用戶同意所有必要的條款後: + * - 如果用戶尚未選擇公司,系統會將用戶重定向到選擇公司的頁面。 + * - 如果用戶已經選擇了公司,系統會將用戶重定向到儀表板或之前嘗試訪問的頁面(如果有的話)。 + * 15. 在每次頁面加載或路由變更時,系統會檢查 localStorage 中的用戶 ID 和過期時間: + * - 如果存在有效的用戶 ID 和未過期的時間戳,系統會嘗試重新獲取用戶狀態(調用 `getStatusInfo`)。 + * - 如果 localStorage 中的數據已過期或不存在,系統會清除 state 並將用戶重定向到登入頁面。 + * + * 總結: + * - NextAuth 負責管理 OAuth2.0 的登入流程,並在登入成功後調用 `setSession` 函數將用戶的 ID 存儲在 session 中。 + * - 在主頁面(iSunFA login page)中,`getStatusInfo` 函數會調用 `getStatusInfoAPI`,該 API 通過 `getSession` 函數從 NextAuth 管理的 session 中獲取當前登入用戶的 ID 和公司 ID,並根據這些 ID 從資料庫中獲取相應的用戶和公司資料。 + * - 獲取到的用戶和公司資料會存儲到 React 的 state 中,並通過 `UserContext` 提供給應用的其他組件使用。 + * - 用戶 ID 和登入狀態的過期時間會被存儲在 localStorage 中,用於在頁面刷新或重新訪問時快速恢復用戶狀態。 + * - `session.ts` 中提供的 `getSession` 和 `setSession` 函數封裝了 `next-session` 庫的功能,不僅 NextAuth 可以使用這些函數來操作 session,其他後端檔案(如 API 路由)也可以通過調用這些函數來讀取和修改 session。 + * - 登入流程包含了檢查和處理用戶同意條款的邏輯,確保用戶在使用系統之前已經同意了所有必要的條款。 + * - 用戶同意條款的狀態會被更新到資料庫中,並反映在本地 state 中,影響後續的導航邏輯。 + * - 系統使用 localStorage 來保存用戶的登入狀態,以提高用戶體驗並減少不必要的 API 請求。 + */ diff --git a/src/pages/api/auth/[...nextauth].ts b/src/pages/api/auth/[...nextauth].ts index 9803dc52..82a322ef 100644 --- a/src/pages/api/auth/[...nextauth].ts +++ b/src/pages/api/auth/[...nextauth].ts @@ -28,42 +28,42 @@ import { FileFolder, PUBLIC_IMAGE_ID } from '@/constants/file'; /** Info: (20240903 - Shirley) * 後端登入流程: * 1. 當用戶在前端點擊登入按鈕並選擇登入方式後,NextAuth 會處理 `/api/auth/signIn` 路由的請求。 - * 2. NextAuth 根據選擇的登入方式(如 Google 或 Apple),將用戶重定向到相應的 OAuth 提供者進行認證。 - * 3. 用戶在 OAuth 提供者的頁面上完成認證後,提供者會將用戶重定向回應用的 `/api/auth/callback/:provider` 路由。 + * 2. NextAuth 根據選擇的登入方式(如 Google 或 Apple),將用戶重新導向到相應的 OAuth 提供者進行認證。 + * 3. 用戶在 OAuth 提供者的頁面上完成認證後,提供者會將用戶重新導向回應用的 `/api/auth/callback/:provider` 路由。 * 4. NextAuth 處理這個回調路由,並觸發 `signIn` 回調函數: * a. 通過 `getUserByCredential` 函數檢查用戶是否已存在於資料庫中。 * b. 如果用戶不存在: - * - 使用 `createUserByAuth` 函數在資料庫中創建新用戶。 - * - 如果用戶有頭像,使用 `fetchImageInfo` 獲取頭像信息,否則使用 `generateIcon` 生成默認頭像。 + * - 使用 `createUserByAuth` 函數在資料庫中建立新用戶。 + * - 如果用戶有頭像,使用 `fetchImageInfo` 獲取頭像資訊,否則使用 `generateIcon` 生成預設頭像。 * - 使用 `createFileAndConnectUser` 函數將用戶頭像保存為文件並與用戶關聯。 - * c. 無論是新用戶還是已存在的用戶,都使用 `setSession` 函數將用戶的 ID 存儲在 session 中。 - * 5. 登入成功後,NextAuth 將用戶重定向回應用的主頁面(iSunFA login page)。 + * c. 無論是新用戶還是已存在的用戶,都使用 `setSession` 函數將用戶的 ID 儲存在 session 中。 + * 5. 登入成功後,NextAuth 將用戶重新導向回應用的主頁面(iSunFA login page)。 * * 後續的 API 請求處理: * 6. 當前端調用 `getStatusInfo` API 時,後端的 `status_info.ts` 處理這個請求: * a. 使用 `getSession` 函數獲取當前請求的 session,從中提取用戶 ID 和公司 ID。 - * b. 使用 `getUserById` 函數從資料庫獲取用戶詳細信息。 - * c. 如果 session 中有公司 ID,則使用 `getCompanyById` 函數獲取公司詳細信息。 - * d. 將獲取到的用戶和公司信息格式化後返回給前端。 + * b. 使用 `getUserById` 函數從資料庫獲取用戶詳細資訊。 + * c. 如果 session 中有公司 ID,則使用 `getCompanyById` 函數獲取公司詳細資訊。 + * d. 將獲取到的用戶和公司資訊格式化後回傳給前端。 * * 用戶同意條款的處理: * 7. 當用戶同意條款時,前端會調用相應的 API,後端處理這個請求: * a. 驗證用戶的 session。 * b. 更新資料庫中用戶的同意狀態。 - * c. 返回更新結果給前端。 + * c. 回傳更新結果給前端。 * * 選擇公司的處理: * 8. 當用戶選擇公司時,前端會調用相應的 API,後端處理這個請求: * a. 驗證用戶的 session。 * b. 更新資料庫中用戶的公司關聯。 * c. 使用 `setSession` 函數更新 session 中的公司 ID。 - * d. 返回更新結果給前端。 + * d. 回傳更新結果給前端。 * * 總結: * - 後端使用 NextAuth 處理 OAuth2.0 的認證流程。 * - `session.ts` 中的 `getSession` 和 `setSession` 函數被用於管理用戶的 session,這些函數可以被 NextAuth 和其他 API 路由使用。 - * - 用戶數據的創建、讀取和更新操作都通過資料庫操作函數(如 `getUserByCredential`, `createUserByAuth` 等)來完成。 - * - API 路由(如 `status_info.ts`)使用 `getSession` 函數來獲取當前用戶的 session 信息,確保只有已登入的用戶可以訪問受保護的資源。 + * - 用戶數據的建立、讀取和更新操作都通過資料庫操作函數(如 `getUserByCredential`, `createUserByAuth` 等)來完成。 + * - API 路由(如 `status_info.ts`)使用 `getSession` 函數來獲取當前用戶的 session 資訊,確保只有已登入的用戶可以訪問受保護的資源。 * - 用戶同意條款不會更新 session,而是更新資料庫中的用戶同意狀態。 * - 選擇公司的操作更新資料庫和 session,確保用戶狀態的一致性。 */ diff --git a/src/pages/beta/animation.tsx b/src/pages/beta/animation.tsx deleted file mode 100644 index 4d1bc0f8..00000000 --- a/src/pages/beta/animation.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import Head from 'next/head'; -import React from 'react'; -import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; -import { useTranslation } from 'next-i18next'; -import { ILocale } from '@/interfaces/locale'; -import LoginAnimation from '@/components/login/login_animation'; - -const AnimationPage = () => { - const { t } = useTranslation('common'); - - return ( - <> - - - - - {t('common:NAV_BAR.LOGIN')} - iSunFA - - - - - - - - - - - > - ); -}; - -export const getServerSideProps = async ({ locale }: ILocale) => { - return { - props: { - ...(await serverSideTranslations(locale as string, [ - 'common', - 'report_401', - 'journal', - 'kyc', - 'project', - 'setting', - 'terms', - 'salary', - ])), - }, - }; -}; - -export default AnimationPage; diff --git a/src/pages/users/login.tsx b/src/pages/users/login.tsx index fb463dc4..9de296b6 100644 --- a/src/pages/users/login.tsx +++ b/src/pages/users/login.tsx @@ -1,7 +1,7 @@ import Head from 'next/head'; import React from 'react'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; -import NavBar from '@/components/nav_bar/nav_bar'; +// import NavBar from '@/components/nav_bar/nav_bar'; import LoginPageBody from '@/components/login_page_body/login_page_body'; import { GetServerSideProps } from 'next'; import { useTranslation } from 'next-i18next'; @@ -10,12 +10,6 @@ import { ILoginPageProps } from '@/interfaces/page_props'; const LoginPage = ({ invitation, action }: ILoginPageProps) => { const { t } = useTranslation('common'); - const displayedBody = ( - - - - ); - return ( <> @@ -37,8 +31,8 @@ const LoginPage = ({ invitation, action }: ILoginPageProps) => { - - {displayedBody} + {/* */} + > ); diff --git a/src/pages/users/select_company.tsx b/src/pages/users/select_company.tsx index d013a3b9..26a1076e 100644 --- a/src/pages/users/select_company.tsx +++ b/src/pages/users/select_company.tsx @@ -1,5 +1,5 @@ import Head from 'next/head'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useModalContext } from '@/contexts/modal_context'; import SelectCompanyPageBody from '@/components/select_company_page_body/select_company_page_body'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; @@ -10,11 +10,14 @@ import { useUserCtx } from '@/contexts/user_context'; import { SkeletonList } from '@/components/skeleton/skeleton'; import { DEFAULT_SKELETON_COUNT_FOR_PAGE } from '@/constants/display'; import { useTranslation } from 'next-i18next'; +import LoginAnimation from '@/components/login/login_animation'; const SelectCompanyPage = () => { const { t } = useTranslation(['common', 'kyc']); const { isAuthLoading } = useUserCtx(); const { eliminateToast } = useModalContext(); + const [isAnimationShowing, setIsAnimationShowing] = useState(true); + // Info: (20241001 - Liz) 先預設都要顯示動畫,之後會依照是否有建立過「角色」來判斷是否要顯示動畫 useEffect(() => { // Info: (20240513 - Julian) 回到選擇公司頁面時,要把提醒試用版的 Toast 關掉 @@ -27,7 +30,11 @@ const SelectCompanyPage = () => { ) : ( - + {isAnimationShowing ? ( + + ) : ( + + )} ); @@ -53,9 +60,7 @@ const SelectCompanyPage = () => { - - - + {!isAnimationShowing && } {displayedBody} >
{ +const LoginAnimation = ({ + setIsAnimationShowing, +}: { + setIsAnimationShowing: React.Dispatch>; +}) => { const [switchTitle, setSwitchTitle] = useState(false); useEffect(() => { @@ -11,8 +15,14 @@ const LoginAnimation = () => { setSwitchTitle(true); }, 3000); + // Info: (20241001 - Liz) 6 秒後關閉動畫 + const closeAnimation = setTimeout(() => { + setIsAnimationShowing(false); + }, 6000); + return () => { clearTimeout(titleTimer); + clearTimeout(closeAnimation); }; }, []); diff --git a/src/components/login_confirm_modal/login_confirm_modal.tsx b/src/components/login_confirm_modal/login_confirm_modal.tsx index ff27f8ff..fafa61ff 100644 --- a/src/components/login_confirm_modal/login_confirm_modal.tsx +++ b/src/components/login_confirm_modal/login_confirm_modal.tsx @@ -6,6 +6,8 @@ import PrivacyPolicy from '@/components/login_confirm_modal/privacy_policy'; import { useUserCtx } from '@/contexts/user_context'; import { Hash } from '@/constants/hash'; import { Button } from '@/components/button/button'; +import { ISUNFA_ROUTE } from '@/constants/url'; +import { useRouter } from 'next/router'; interface ILoginConfirmProps { id: string; @@ -28,6 +30,7 @@ const LoginConfirmModal: React.FC = ({ }) => { const { t } = useTranslation('common'); const { handleUserAgree, signOut } = useUserCtx(); + const router = useRouter(); const onAgree = async () => { if (id === 'agree-to-our-terms-of-service') { @@ -38,6 +41,7 @@ const LoginConfirmModal: React.FC = ({ if (id === 'agree-to-our-privacy-policy') { tosModalVisibilityHandler(false); await handleUserAgree(Hash.HASH_FOR_PRIVACY_POLICY); + router.push(ISUNFA_ROUTE.SELECT_COMPANY); } }; const onCancel = () => { diff --git a/src/components/login_page_body/login_page_body.tsx b/src/components/login_page_body/login_page_body.tsx index ad5ff4d4..82faee51 100644 --- a/src/components/login_page_body/login_page_body.tsx +++ b/src/components/login_page_body/login_page_body.tsx @@ -7,53 +7,17 @@ import { Provider } from '@/constants/provider'; import { useUserCtx } from '@/contexts/user_context'; import { ToastType } from '@/interfaces/toastify'; import { useTranslation } from 'next-i18next'; +import { FiHome } from 'react-icons/fi'; +import I18n from '@/components/i18n/i18n'; +import { signIn } from 'next-auth/react'; -const getProviderDetails = (provider: Provider) => { - return { - logo: provider === Provider.GOOGLE ? '/icons/google_logo.svg' : '/icons/apple_logo.svg', - bgColor: provider === Provider.GOOGLE ? 'bg-white' : 'bg-black', - textColor: provider === Provider.GOOGLE ? 'text-black' : 'text-white', - }; -}; - -const AuthButton = React.memo( - ({ - onClick, - provider, - disabled = false, - }: { - onClick: () => void; - provider: Provider; - disabled?: boolean; - }) => { - const { t } = useTranslation('common'); - const { logo, bgColor, textColor } = getProviderDetails(provider); - - return ( - - - - {t('common:LOGIN_PAGE_BODY.LOGIN_WITH_PROVIDER', { - provider: provider.replace(provider[0], provider[0].toUpperCase()), - })} - - - ); - } -); - -const Loader = React.memo(() => { +const Loader = () => { return ( ); -}); +}; const LoginPageBody = ({ invitation, action }: ILoginPageProps) => { const { t } = useTranslation('common'); @@ -83,25 +47,74 @@ const LoginPageBody = ({ invitation, action }: ILoginPageProps) => { return ( + + + + + + + {isAuthLoading ? ( ) : ( - - - {t('common:LOGIN_PAGE_BODY.LOG_IN')} - - + + + iSunFA + {t('common:LOGIN_PAGE_BODY.LOG_IN')} + + + - - - {/* Info: (20240819-Tzuhan) [Beta] Apple login is not supported in the beta version - */} + + + + + Log In with Google + + + {/* // Info: (20241001 - Liz) 登入 Apple 功能待實作 */} + signIn('apple')} + className="flex items-center justify-center gap-15px rounded-sm bg-black p-15px" + disabled + > + + Log In with Apple + )} ); + + // return ( + // + // + // {isAuthLoading ? ( + // + // ) : ( + // + // + // {t('common:LOGIN_PAGE_BODY.LOG_IN')} + // + // + // + // + // + // + // {/* Info: (20240819-Tzuhan) [Beta] Apple login is not supported in the beta version + // */} + // + // + // )} + // + // ); }; export default LoginPageBody; diff --git a/src/components/nav_bar/nav_bar.tsx b/src/components/nav_bar/nav_bar.tsx index 6f9fd3a2..bb375cc9 100644 --- a/src/components/nav_bar/nav_bar.tsx +++ b/src/components/nav_bar/nav_bar.tsx @@ -29,7 +29,7 @@ import { UploadType } from '@/constants/file'; const NavBar = () => { const { t }: { t: TranslateFunction } = useTranslation('common'); - const { signedIn, signOut, username, selectedCompany, userAuth, isAuthLoading, selectCompany } = + const { isSignIn, signOut, username, selectedCompany, userAuth, isAuthLoading, selectCompany } = useUserCtx(); const { profileUploadModalDataHandler, profileUploadModalVisibilityHandler } = useGlobalCtx(); const router = useRouter(); @@ -119,7 +119,7 @@ const NavBar = () => { className="mx-auto flex w-full items-center gap-16px px-24px py-10px text-button-text-secondary disabled:text-button-text-disable" > {/* */} @@ -127,7 +127,7 @@ const NavBar = () => { {/* */} @@ -149,7 +149,7 @@ const NavBar = () => { className="mx-auto flex w-full items-center gap-16px px-24px py-10px text-button-text-secondary disabled:text-button-text-disable" > {/* */} @@ -157,7 +157,7 @@ const NavBar = () => { {/* */} @@ -223,7 +223,7 @@ const NavBar = () => { className="mx-auto flex flex-col items-center gap-8px hover:text-button-text-primary-hover disabled:text-button-text-disable" > {/* */} @@ -234,7 +234,7 @@ const NavBar = () => { {/* Info: (20240416 - Julian) Account button */} @@ -260,7 +260,7 @@ const NavBar = () => { className="mx-auto flex flex-col items-center gap-8px disabled:text-button-text-disable" > {/* */} @@ -270,7 +270,7 @@ const NavBar = () => { {/* Info: (20240416 - Julian) Report button */} @@ -301,7 +301,7 @@ const NavBar = () => { - {signedIn ? username ?? DEFAULT_DISPLAYED_USER_NAME : ''} + {isSignIn ? (username ?? DEFAULT_DISPLAYED_USER_NAME) : ''} {/* Info: (20240809 - Shirley) edit name button */} { const displayedAvatar = isAuthLoading ? ( - ) : signedIn ? ( + ) : isSignIn ? ( { {/* Info: (20240802 - Shirley) hide the login button for now */} diff --git a/src/components/select_company_page_body/select_company_page_body.tsx b/src/components/select_company_page_body/select_company_page_body.tsx index 20415487..5a0b349b 100644 --- a/src/components/select_company_page_body/select_company_page_body.tsx +++ b/src/components/select_company_page_body/select_company_page_body.tsx @@ -19,7 +19,7 @@ import { useTranslation } from 'next-i18next'; const SelectCompanyPageBody = () => { const { t } = useTranslation(['common', 'kyc']); - const { signedIn, username, selectCompany, successSelectCompany, errorCode, userAuth } = + const { isSignIn, username, selectCompany, successSelectCompany, errorCode, userAuth } = useUserCtx(); const { companyInvitationModalVisibilityHandler, createCompanyModalVisibilityHandler } = useGlobalCtx(); @@ -45,7 +45,7 @@ const SelectCompanyPageBody = () => { Array<{ company: ICompany; role: IRole }> >([]); - const userName = signedIn ? username || DEFAULT_DISPLAYED_USER_NAME : ''; + const userName = isSignIn ? username || DEFAULT_DISPLAYED_USER_NAME : ''; const selectedCompanyName = selectedCompany?.name ?? t('kyc:SELECT_COMPANY.SELECT_AN_COMPANY'); const menuOpenHandler = () => { diff --git a/src/contexts/accounting_context.tsx b/src/contexts/accounting_context.tsx index 9c7d13f5..712d0a65 100644 --- a/src/contexts/accounting_context.tsx +++ b/src/contexts/accounting_context.tsx @@ -192,7 +192,7 @@ const initialAccountingContext: IAccountingContext = { export const AccountingContext = createContext(initialAccountingContext); export const AccountingProvider = ({ children }: IAccountingProvider) => { - const { userAuth, selectedCompany, signedIn } = useUserCtx(); + const { userAuth, selectedCompany, isSignIn } = useUserCtx(); const { trigger: getAccountList, data: accountTitleList, @@ -480,7 +480,7 @@ export const AccountingProvider = ({ children }: IAccountingProvider) => { useEffect(() => { clearOCRs(); - }, [signedIn, selectedCompany]); + }, [isSignIn, selectedCompany]); useEffect(() => { if (accountSuccess && accountTitleList) { diff --git a/src/contexts/global_context.tsx b/src/contexts/global_context.tsx index 241b9b94..24d271af 100644 --- a/src/contexts/global_context.tsx +++ b/src/contexts/global_context.tsx @@ -133,8 +133,7 @@ export const GlobalProvider = ({ children }: IGlobalProvider) => { const router = useRouter(); const { pathname } = router; - const { signedIn, selectedCompany, isAgreeInfoCollection, isAgreeTosNPrivacyPolicy } = - useUserCtx(); + const { isSignIn, selectedCompany, isAgreeTermsOfService, isAgreePrivacyPolicy } = useUserCtx(); const { reportGeneratedStatus, reportPendingStatus, reportGeneratedStatusHandler } = useNotificationCtx(); @@ -361,7 +360,7 @@ export const GlobalProvider = ({ children }: IGlobalProvider) => { }; useEffect(() => { - if (!signedIn) return; + if (!isSignIn) return; if (reportGeneratedStatus) { toastHandler({ @@ -403,14 +402,14 @@ export const GlobalProvider = ({ children }: IGlobalProvider) => { // autoClose: false, // }); // } - }, [reportPendingStatus, reportGeneratedStatus, signedIn, pathname]); + }, [reportPendingStatus, reportGeneratedStatus, isSignIn, pathname]); useEffect(() => { - if (signedIn) { - if (!isAgreeInfoCollection || !isAgreeTosNPrivacyPolicy) { + if (isSignIn) { + if (!isAgreeTermsOfService || !isAgreePrivacyPolicy) { if (router.pathname !== ISUNFA_ROUTE.LOGIN) router.push(ISUNFA_ROUTE.LOGIN); - if (!isAgreeInfoCollection) termsOfServiceConfirmModalVisibilityHandler(true); - if (isAgreeInfoCollection && !isAgreeTosNPrivacyPolicy) { + if (!isAgreeTermsOfService) termsOfServiceConfirmModalVisibilityHandler(true); + if (isAgreeTermsOfService && !isAgreePrivacyPolicy) { privacyPolicyConfirmModalVisibilityHandler(true); } } else { @@ -418,10 +417,10 @@ export const GlobalProvider = ({ children }: IGlobalProvider) => { privacyPolicyConfirmModalVisibilityHandler(false); } } - }, [pathname, signedIn, isAgreeInfoCollection, isAgreeTosNPrivacyPolicy]); + }, [pathname, isSignIn, isAgreeTermsOfService, isAgreePrivacyPolicy]); useEffect(() => { - if (signedIn) { + if (isSignIn) { if (router.pathname.startsWith('/users') && !router.pathname.includes(ISUNFA_ROUTE.LOGIN)) { eliminateToast(ToastId.ALPHA_TEST_REMINDER); if (!router.pathname.includes(ISUNFA_ROUTE.SELECT_COMPANY)) { @@ -468,7 +467,7 @@ export const GlobalProvider = ({ children }: IGlobalProvider) => { // eliminateToast(ToastId.ALPHA_TEST_REMINDER); // } } - }, [pathname, signedIn]); + }, [pathname, isSignIn]); // Info: (20240830 - Anna) 為了拿掉react/jsx-no-constructed-context-values註解,所以使用useMemo hook diff --git a/src/contexts/user_context.tsx b/src/contexts/user_context.tsx index dd5decfe..22e50ee6 100644 --- a/src/contexts/user_context.tsx +++ b/src/contexts/user_context.tsx @@ -21,9 +21,9 @@ interface UserContextType { signOut: () => void; userAuth: IUser | null; username: string | null; - signedIn: boolean; - isAgreeInfoCollection: boolean; - isAgreeTosNPrivacyPolicy: boolean; + isSignIn: boolean; + isAgreeTermsOfService: boolean; + isAgreePrivacyPolicy: boolean; isSignInError: boolean; selectedCompany: ICompany | null; selectCompany: (company: ICompany | null, isPublic?: boolean) => Promise; @@ -52,9 +52,9 @@ export const UserContext = createContext({ signOut: () => {}, userAuth: null, username: null, - signedIn: false, - isAgreeInfoCollection: false, - isAgreeTosNPrivacyPolicy: false, + isSignIn: false, + isAgreeTermsOfService: false, + isAgreePrivacyPolicy: false, isSignInError: false, selectedCompany: null, selectCompany: async () => {}, @@ -76,7 +76,7 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { const router = useRouter(); const EXPIRATION_TIME = 1000 * 60 * 60 * 1; // Info: (20240822) 1 hours - const [, setSignedIn, signedInRef] = useStateRef(false); + const [, setIsSignIn, isSignInRef] = useStateRef(false); const [, setCredential, credentialRef] = useStateRef(null); const [userAuth, setUserAuth, userAuthRef] = useStateRef(null); const [, setUsername, usernameRef] = useStateRef(null); @@ -112,11 +112,11 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { setIsSignInError(!isSignInErrorRef.current); }; - const clearState = () => { + const clearStates = () => { setUserAuth(null); setUsername(null); setCredential(null); - setSignedIn(false); + setIsSignIn(false); setIsSignInError(false); setSelectedCompany(null); setSuccessSelectCompany(undefined); @@ -126,17 +126,23 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { }; // Info: (20240530 - Shirley) 在瀏覽器被重新整理後,如果沒有登入,就 redirect to login page - const handleNotSignedIn = () => { - clearState(); + const redirectToLoginPage = () => { if (router.pathname.startsWith('/users') && !router.pathname.includes(ISUNFA_ROUTE.LOGIN)) { router.push(ISUNFA_ROUTE.LOGIN); } + // Deprecated: (20241001 - Liz) + // eslint-disable-next-line no-console + console.log('呼叫 redirectToLoginPage'); }; - const handleSignInRoute = () => { + // Info: (20241001 - Liz) Alpha:重新導向到選擇公司的頁面 ; Beta:重新導向到選擇角色的頁面 + const redirectToSelectCompanyPage = () => { if (isAgreeTermsOfServiceRef.current && isAgreePrivacyPolicyRef.current) { router.push(ISUNFA_ROUTE.SELECT_COMPANY); } + // Deprecated: (20241001 - Liz) + // eslint-disable-next-line no-console + console.log('呼叫 redirectToSelectCompanyPage'); }; const checkIsRegistered = async (): Promise<{ @@ -193,7 +199,8 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { const signOut = async () => { await signOutAPI(); await authSignOut({ redirect: false }); - handleNotSignedIn(); + clearStates(); + redirectToLoginPage(); }; const isProfileFetchNeeded = () => { @@ -226,116 +233,96 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { return false; }; - /** Info: (20240903 - Shirley) - * 前端登入流程: - * 1. 當用戶點擊登入按鈕時,會調用 `authenticateUser` 函數,傳入選擇的登入方式(如 Google 或 Apple)和登入頁面的 props。 - * 2. `authenticateUser` 函數會調用 `authSignIn` 函數(來自 `next-auth/react`),傳入選擇的登入方式和額外的參數,開始 OAuth2.0 的登入流程。 - * 3. 登入流程跳轉到 `[...nextauth].ts` 中的 `/api/auth/signin` 路由,該路由由 NextAuth 處理。 - * 4. NextAuth 根據選擇的登入方式(如 Google 或 Apple),跳轉到相應的 OAuth 提供者進行用戶認證。 - * 5. 用戶在 OAuth 提供者的頁面上輸入憑證並授權應用訪問其資料。 - * 6. OAuth 提供者認證成功後,會將用戶重定向回應用的 `/api/auth/callback/:provider` 路由,該路由也由 NextAuth 處理。 - * 7. NextAuth 在 `[...nextauth].ts` 的 `signIn` 回調函數中處理登入成功後的邏輯: - * - 檢查用戶是否已存在於資料庫中,如果不存在則創建新用戶。 - * - 調用 `setSession` 函數(來自 `session.ts`)將用戶的 ID 存儲在 session 中。 - * 8. 登入成功後,NextAuth 會將用戶重定向到應用的主頁面(iSunFA login page)。 - * 9. 在主頁面(iSunFA login page)中,`UserProvider` 組件會調用 `getStatusInfo` 函數來獲取當前登入用戶和公司的資料。 - * - `getStatusInfo` 函數會調用 `getStatusInfoAPI`,該 API 會攜帶 NextAuth 管理的 session。 - * - 在 `status_info.ts` 中的 `handleGetRequest` 函數中,通過調用 `getSession` 函數(來自 `session.ts`)獲取當前請求的 session,並從中獲取用戶的 ID 和公司 ID。 - * - 根據獲取到的用戶 ID 和公司 ID,從資料庫中獲取相應的用戶和公司資料,並返回給前端。 - * - 如果回傳的資料有 user 但沒有 company,透過 `handleSignInRoute` 將用戶導向選擇公司的頁面,有 user 跟 company 則透過 `handleReturnUrl` 將用戶導向之前儀表板/嘗試訪問的頁面。 - * 10. 如果 `getStatusInfoAPI` 請求成功,並且返回的數據中包含用戶和公司的資訊: - * - 將這些資料存儲到 React 的 state 中。 - * - 將用戶 ID 存儲到 localStorage 中。 - * - 設置一個過期時間(例如 1 小時後),並將其存儲到 localStorage 中。 - * 11. React state 中的用戶和公司資料會通過 `UserContext` 提供給應用的其他組件使用。 - * 12. 檢查用戶是否已同意所有必要的條款: - * - 如果用戶尚未同意所有必要的條款(如資訊收集同意書和服務條款),系統會顯示相應的同意書頁面。 - * - 用戶需要閱讀並同意這些條款。 - * 13. 當用戶同意條款時: - * - 調用 `handleUserAgree` 函數,該函數會發送 API 請求(`agreementAPI`)來更新用戶的同意狀態。 - * - 如果 API 請求成功,更新本地 state 中的用戶同意狀態(`setIsAgreeTermsOfService` 和 `setIsAgreePrivacyPolicy`)。 - * 14. 當用戶同意所有必要的條款後: - * - 如果用戶尚未選擇公司,系統會將用戶重定向到選擇公司的頁面。 - * - 如果用戶已經選擇了公司,系統會將用戶重定向到儀表板或之前嘗試訪問的頁面(如果有的話)。 - * 15. 在每次頁面加載或路由變更時,系統會檢查 localStorage 中的用戶 ID 和過期時間: - * - 如果存在有效的用戶 ID 和未過期的時間戳,系統會嘗試重新獲取用戶狀態(調用 `getStatusInfo`)。 - * - 如果 localStorage 中的數據已過期或不存在,系統會清除 state 並將用戶重定向到登入頁面。 - * - * 總結: - * - NextAuth 負責管理 OAuth2.0 的登入流程,並在登入成功後調用 `setSession` 函數將用戶的 ID 存儲在 session 中。 - * - 在主頁面(iSunFA login page)中,`getStatusInfo` 函數會調用 `getStatusInfoAPI`,該 API 通過 `getSession` 函數從 NextAuth 管理的 session 中獲取當前登入用戶的 ID 和公司 ID,並根據這些 ID 從資料庫中獲取相應的用戶和公司資料。 - * - 獲取到的用戶和公司資料會存儲到 React 的 state 中,並通過 `UserContext` 提供給應用的其他組件使用。 - * - 用戶 ID 和登入狀態的過期時間會被存儲在 localStorage 中,用於在頁面刷新或重新訪問時快速恢復用戶狀態。 - * - `session.ts` 中提供的 `getSession` 和 `setSession` 函數封裝了 `next-session` 庫的功能,不僅 NextAuth 可以使用這些函數來操作 session,其他後端檔案(如 API 路由)也可以通過調用這些函數來讀取和修改 session。 - * - 登入流程包含了檢查和處理用戶同意條款的邏輯,確保用戶在使用系統之前已經同意了所有必要的條款。 - * - 用戶同意條款的狀態會被更新到資料庫中,並反映在本地 state 中,影響後續的導航邏輯。 - * - 系統使用 localStorage 來保存用戶的登入狀態,以提高用戶體驗並減少不必要的 API 請求。 - */ + // =============================================================================== + // Info: (20241001 - Liz) 此函式根據使用者的協議列表,更新使用者是否同意了服務條款和隱私政策。 + // 它會將結果存入狀態變數 setIsAgreeTermsOfService 和 setIsAgreePrivacyPolicy。 + const updateUserAgreements = (user: IUser) => { + const hasAgreedToTerms = user.agreementList.includes(Hash.HASH_FOR_TERMS_OF_SERVICE); + const hasAgreedToPrivacy = user.agreementList.includes(Hash.HASH_FOR_PRIVACY_POLICY); + + setIsAgreeTermsOfService(hasAgreedToTerms); + setIsAgreePrivacyPolicy(hasAgreedToPrivacy); + }; + + // Info: (20241001 - Liz) 此函式處理公司資訊: + // 如果公司資料存在且不為空,它會設定選定的公司 (setSelectedCompany),並標記成功選擇公司。 + // 若公司資料不存在,會將公司資訊設為空,並檢查路由是否位於 users 路徑中。如果符合條件且不在 SELECT_COMPANY 頁面,它會呼叫 redirectToSelectCompanyPage 函式進行重新導向。 + const processCompanyInfo = (company: ICompany) => { + if (company && Object.keys(company).length > 0) { + setSelectedCompany(company); + setSuccessSelectCompany(true); + handleReturnUrl(); + } else { + setSuccessSelectCompany(undefined); + setSelectedCompany(null); + + const isInUsersRoute = + router.pathname.includes('users') && !router.pathname.includes(ISUNFA_ROUTE.SELECT_COMPANY); + + if (isInUsersRoute) { + redirectToSelectCompanyPage(); + } + } + }; + + // Info: (20241001 - Liz) 此函式處理使用者資訊: + // 如果使用者資料存在且有效,會設定使用者認證、名稱,並標記為已登入。 + // 它還會將使用者的 userId 和過期時間儲存在 localStorage。 + // 最後,它會更新使用者的協議狀態。 + // 如果使用者資料無效,則呼叫 handleNotSignedIn 函式來處理未登入的情況。 + const processUserInfo = (user: IUser) => { + if (user && Object.keys(user).length > 0) { + setUserAuth(user); + setUsername(user.name); + setIsSignIn(true); + setIsSignInError(false); + + localStorage.setItem('userId', user.id.toString()); + localStorage.setItem('expired_at', (Date.now() + EXPIRATION_TIME).toString()); + + updateUserAgreements(user); + } else { + clearStates(); + redirectToLoginPage(); + } + }; + + // Info: (20241001 - Liz) 此函式使用 useCallback 封裝,用來非同步取得使用者和公司狀態資訊。 + // 它首先檢查是否需要取得使用者資料 (isProfileFetchNeeded),如果不需要,則直接返回。 + // 當資料獲取中,它會設定載入狀態 (setIsAuthLoading) 並清除公司選擇狀態。 + // 當 API 回傳成功且有資料時,它會呼叫 processUserInfo 和 processCompanyInfo 分別處理使用者和公司資訊。 + // 如果獲取資料失敗,它會執行未登入的處理邏輯: 清除狀態、導向登入頁面、設定登入錯誤狀態、設定錯誤代碼。 + // 最後,它會將載入狀態設為完成。 const getStatusInfo = useCallback(async () => { - const isNeed = isProfileFetchNeeded(); - if (!isNeed) return; + if (!isProfileFetchNeeded()) return; + setIsAuthLoading(true); + setSelectedCompany(null); + setSuccessSelectCompany(undefined); + const { data: StatusInfo, success: getStatusInfoSuccess, code: getStatusInfoCode, } = await getStatusInfoAPI(); - setSelectedCompany(null); - setSuccessSelectCompany(undefined); - if (getStatusInfoSuccess) { - if (StatusInfo) { - if ('user' in StatusInfo && StatusInfo.user && Object.keys(StatusInfo.user).length > 0) { - setUserAuth(StatusInfo.user); - setUsername(StatusInfo.user.name); - setSignedIn(true); - setIsSignInError(false); - localStorage.setItem('userId', StatusInfo.user.id.toString()); - localStorage.setItem('expired_at', (Date.now() + EXPIRATION_TIME).toString()); - if (StatusInfo.user.agreementList.includes(Hash.HASH_FOR_TERMS_OF_SERVICE)) { - setIsAgreeTermsOfService(true); - } else { - setIsAgreeTermsOfService(false); - } - if (StatusInfo.user.agreementList.includes(Hash.HASH_FOR_PRIVACY_POLICY)) { - setIsAgreePrivacyPolicy(true); - } else { - setIsAgreePrivacyPolicy(false); - } - if ( - 'company' in StatusInfo && - StatusInfo.company && - Object.keys(StatusInfo.company).length > 0 - ) { - setSelectedCompany(StatusInfo.company); - setSuccessSelectCompany(true); - handleReturnUrl(); - } else { - setSuccessSelectCompany(undefined); - setSelectedCompany(null); - if ( - router.pathname.includes('users') && - !router.pathname.includes(ISUNFA_ROUTE.SELECT_COMPANY) - ) { - handleSignInRoute(); - } - } - } else { - handleNotSignedIn(); - } - } - } - if (getStatusInfoSuccess === false) { - handleNotSignedIn(); + + // Deprecated: (20241001 - Liz) + // eslint-disable-next-line no-console + console.log('getStatusInfo', StatusInfo, 'getStatusInfoSuccess', getStatusInfoSuccess); + + if (getStatusInfoSuccess && StatusInfo) { + processUserInfo(StatusInfo.user); + processCompanyInfo(StatusInfo.company); + } else { + clearStates(); + redirectToLoginPage(); setIsSignInError(true); setErrorCode(getStatusInfoCode ?? ''); } + setIsAuthLoading(false); }, [router.pathname]); - - // Info: (20240903 - Shirley) 第一次登入,在用戶同意後,重新導向到選擇公司的頁面 - useEffect(() => { - handleSignInRoute(); - }, [userAgreeResponseRef.current]); + // =============================================================================== const handleUserAgree = async (hash: Hash) => { try { @@ -475,9 +462,9 @@ export const UserProvider = ({ children }: { children: React.ReactNode }) => { signOut, userAuth: userAuthRef.current, username: usernameRef.current, - signedIn: signedInRef.current, - isAgreeInfoCollection: isAgreeTermsOfServiceRef.current, - isAgreeTosNPrivacyPolicy: isAgreePrivacyPolicyRef.current, + isSignIn: isSignInRef.current, + isAgreeTermsOfService: isAgreeTermsOfServiceRef.current, + isAgreePrivacyPolicy: isAgreePrivacyPolicyRef.current, isSignInError: isSignInErrorRef.current, selectedCompany: selectedCompanyRef.current, selectCompany, @@ -513,3 +500,49 @@ export const useUserCtx = () => { } return context; }; + +/** Info: (20240903 - Shirley) + * 前端登入流程: + * 1. 當用戶點擊登入按鈕時,會調用 `authenticateUser` 函數,傳入選擇的登入方式(如 Google 或 Apple)和登入頁面的 props。 + * 2. `authenticateUser` 函數會調用 `authSignIn` 函數(來自 `next-auth/react`),傳入選擇的登入方式和額外的參數,開始 OAuth2.0 的登入流程。 + * 3. 登入流程跳轉到 `[...nextauth].ts` 中的 `/api/auth/signin` 路由,該路由由 NextAuth 處理。 + * 4. NextAuth 根據選擇的登入方式(如 Google 或 Apple),跳轉到相應的 OAuth 提供者進行用戶認證。 + * 5. 用戶在 OAuth 提供者的頁面上輸入憑證並授權應用訪問其資料。 + * 6. OAuth 提供者認證成功後,會將用戶重定向回應用的 `/api/auth/callback/:provider` 路由,該路由也由 NextAuth 處理。 + * 7. NextAuth 在 `[...nextauth].ts` 的 `signIn` 回調函數中處理登入成功後的邏輯: + * - 檢查用戶是否已存在於資料庫中,如果不存在則創建新用戶。 + * - 調用 `setSession` 函數(來自 `session.ts`)將用戶的 ID 存儲在 session 中。 + * 8. 登入成功後,NextAuth 會將用戶重定向到應用的主頁面(iSunFA login page)。 + * 9. 在主頁面(iSunFA login page)中,`UserProvider` 組件會調用 `getStatusInfo` 函數來獲取當前登入用戶和公司的資料。 + * - `getStatusInfo` 函數會調用 `getStatusInfoAPI`,該 API 會攜帶 NextAuth 管理的 session。 + * - 在 `status_info.ts` 中的 `handleGetRequest` 函數中,通過調用 `getSession` 函數(來自 `session.ts`)獲取當前請求的 session,並從中獲取用戶的 ID 和公司 ID。 + * - 根據獲取到的用戶 ID 和公司 ID,從資料庫中獲取相應的用戶和公司資料,並返回給前端。 + * - 如果回傳的資料有 user 但沒有 company,透過 `redirectToSelectCompanyPage` 將用戶導向選擇公司的頁面,有 user 跟 company 則透過 `handleReturnUrl` 將用戶導向之前儀表板/嘗試訪問的頁面。 + * 10. 如果 `getStatusInfoAPI` 請求成功,並且返回的數據中包含用戶和公司的資訊: + * - 將這些資料存儲到 React 的 state 中。 + * - 將用戶 ID 存儲到 localStorage 中。 + * - 設置一個過期時間(例如 1 小時後),並將其存儲到 localStorage 中。 + * 11. React state 中的用戶和公司資料會通過 `UserContext` 提供給應用的其他組件使用。 + * 12. 檢查用戶是否已同意所有必要的條款: + * - 如果用戶尚未同意所有必要的條款(如資訊收集同意書和服務條款),系統會顯示相應的同意書頁面。 + * - 用戶需要閱讀並同意這些條款。 + * 13. 當用戶同意條款時: + * - 調用 `handleUserAgree` 函數,該函數會發送 API 請求(`agreementAPI`)來更新用戶的同意狀態。 + * - 如果 API 請求成功,更新本地 state 中的用戶同意狀態(`setIsAgreeTermsOfService` 和 `setIsAgreePrivacyPolicy`)。 + * 14. 當用戶同意所有必要的條款後: + * - 如果用戶尚未選擇公司,系統會將用戶重定向到選擇公司的頁面。 + * - 如果用戶已經選擇了公司,系統會將用戶重定向到儀表板或之前嘗試訪問的頁面(如果有的話)。 + * 15. 在每次頁面加載或路由變更時,系統會檢查 localStorage 中的用戶 ID 和過期時間: + * - 如果存在有效的用戶 ID 和未過期的時間戳,系統會嘗試重新獲取用戶狀態(調用 `getStatusInfo`)。 + * - 如果 localStorage 中的數據已過期或不存在,系統會清除 state 並將用戶重定向到登入頁面。 + * + * 總結: + * - NextAuth 負責管理 OAuth2.0 的登入流程,並在登入成功後調用 `setSession` 函數將用戶的 ID 存儲在 session 中。 + * - 在主頁面(iSunFA login page)中,`getStatusInfo` 函數會調用 `getStatusInfoAPI`,該 API 通過 `getSession` 函數從 NextAuth 管理的 session 中獲取當前登入用戶的 ID 和公司 ID,並根據這些 ID 從資料庫中獲取相應的用戶和公司資料。 + * - 獲取到的用戶和公司資料會存儲到 React 的 state 中,並通過 `UserContext` 提供給應用的其他組件使用。 + * - 用戶 ID 和登入狀態的過期時間會被存儲在 localStorage 中,用於在頁面刷新或重新訪問時快速恢復用戶狀態。 + * - `session.ts` 中提供的 `getSession` 和 `setSession` 函數封裝了 `next-session` 庫的功能,不僅 NextAuth 可以使用這些函數來操作 session,其他後端檔案(如 API 路由)也可以通過調用這些函數來讀取和修改 session。 + * - 登入流程包含了檢查和處理用戶同意條款的邏輯,確保用戶在使用系統之前已經同意了所有必要的條款。 + * - 用戶同意條款的狀態會被更新到資料庫中,並反映在本地 state 中,影響後續的導航邏輯。 + * - 系統使用 localStorage 來保存用戶的登入狀態,以提高用戶體驗並減少不必要的 API 請求。 + */ diff --git a/src/pages/api/auth/[...nextauth].ts b/src/pages/api/auth/[...nextauth].ts index 9803dc52..82a322ef 100644 --- a/src/pages/api/auth/[...nextauth].ts +++ b/src/pages/api/auth/[...nextauth].ts @@ -28,42 +28,42 @@ import { FileFolder, PUBLIC_IMAGE_ID } from '@/constants/file'; /** Info: (20240903 - Shirley) * 後端登入流程: * 1. 當用戶在前端點擊登入按鈕並選擇登入方式後,NextAuth 會處理 `/api/auth/signIn` 路由的請求。 - * 2. NextAuth 根據選擇的登入方式(如 Google 或 Apple),將用戶重定向到相應的 OAuth 提供者進行認證。 - * 3. 用戶在 OAuth 提供者的頁面上完成認證後,提供者會將用戶重定向回應用的 `/api/auth/callback/:provider` 路由。 + * 2. NextAuth 根據選擇的登入方式(如 Google 或 Apple),將用戶重新導向到相應的 OAuth 提供者進行認證。 + * 3. 用戶在 OAuth 提供者的頁面上完成認證後,提供者會將用戶重新導向回應用的 `/api/auth/callback/:provider` 路由。 * 4. NextAuth 處理這個回調路由,並觸發 `signIn` 回調函數: * a. 通過 `getUserByCredential` 函數檢查用戶是否已存在於資料庫中。 * b. 如果用戶不存在: - * - 使用 `createUserByAuth` 函數在資料庫中創建新用戶。 - * - 如果用戶有頭像,使用 `fetchImageInfo` 獲取頭像信息,否則使用 `generateIcon` 生成默認頭像。 + * - 使用 `createUserByAuth` 函數在資料庫中建立新用戶。 + * - 如果用戶有頭像,使用 `fetchImageInfo` 獲取頭像資訊,否則使用 `generateIcon` 生成預設頭像。 * - 使用 `createFileAndConnectUser` 函數將用戶頭像保存為文件並與用戶關聯。 - * c. 無論是新用戶還是已存在的用戶,都使用 `setSession` 函數將用戶的 ID 存儲在 session 中。 - * 5. 登入成功後,NextAuth 將用戶重定向回應用的主頁面(iSunFA login page)。 + * c. 無論是新用戶還是已存在的用戶,都使用 `setSession` 函數將用戶的 ID 儲存在 session 中。 + * 5. 登入成功後,NextAuth 將用戶重新導向回應用的主頁面(iSunFA login page)。 * * 後續的 API 請求處理: * 6. 當前端調用 `getStatusInfo` API 時,後端的 `status_info.ts` 處理這個請求: * a. 使用 `getSession` 函數獲取當前請求的 session,從中提取用戶 ID 和公司 ID。 - * b. 使用 `getUserById` 函數從資料庫獲取用戶詳細信息。 - * c. 如果 session 中有公司 ID,則使用 `getCompanyById` 函數獲取公司詳細信息。 - * d. 將獲取到的用戶和公司信息格式化後返回給前端。 + * b. 使用 `getUserById` 函數從資料庫獲取用戶詳細資訊。 + * c. 如果 session 中有公司 ID,則使用 `getCompanyById` 函數獲取公司詳細資訊。 + * d. 將獲取到的用戶和公司資訊格式化後回傳給前端。 * * 用戶同意條款的處理: * 7. 當用戶同意條款時,前端會調用相應的 API,後端處理這個請求: * a. 驗證用戶的 session。 * b. 更新資料庫中用戶的同意狀態。 - * c. 返回更新結果給前端。 + * c. 回傳更新結果給前端。 * * 選擇公司的處理: * 8. 當用戶選擇公司時,前端會調用相應的 API,後端處理這個請求: * a. 驗證用戶的 session。 * b. 更新資料庫中用戶的公司關聯。 * c. 使用 `setSession` 函數更新 session 中的公司 ID。 - * d. 返回更新結果給前端。 + * d. 回傳更新結果給前端。 * * 總結: * - 後端使用 NextAuth 處理 OAuth2.0 的認證流程。 * - `session.ts` 中的 `getSession` 和 `setSession` 函數被用於管理用戶的 session,這些函數可以被 NextAuth 和其他 API 路由使用。 - * - 用戶數據的創建、讀取和更新操作都通過資料庫操作函數(如 `getUserByCredential`, `createUserByAuth` 等)來完成。 - * - API 路由(如 `status_info.ts`)使用 `getSession` 函數來獲取當前用戶的 session 信息,確保只有已登入的用戶可以訪問受保護的資源。 + * - 用戶數據的建立、讀取和更新操作都通過資料庫操作函數(如 `getUserByCredential`, `createUserByAuth` 等)來完成。 + * - API 路由(如 `status_info.ts`)使用 `getSession` 函數來獲取當前用戶的 session 資訊,確保只有已登入的用戶可以訪問受保護的資源。 * - 用戶同意條款不會更新 session,而是更新資料庫中的用戶同意狀態。 * - 選擇公司的操作更新資料庫和 session,確保用戶狀態的一致性。 */ diff --git a/src/pages/beta/animation.tsx b/src/pages/beta/animation.tsx deleted file mode 100644 index 4d1bc0f8..00000000 --- a/src/pages/beta/animation.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import Head from 'next/head'; -import React from 'react'; -import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; -import { useTranslation } from 'next-i18next'; -import { ILocale } from '@/interfaces/locale'; -import LoginAnimation from '@/components/login/login_animation'; - -const AnimationPage = () => { - const { t } = useTranslation('common'); - - return ( - <> - - - - - {t('common:NAV_BAR.LOGIN')} - iSunFA - - - - - - - - - - - > - ); -}; - -export const getServerSideProps = async ({ locale }: ILocale) => { - return { - props: { - ...(await serverSideTranslations(locale as string, [ - 'common', - 'report_401', - 'journal', - 'kyc', - 'project', - 'setting', - 'terms', - 'salary', - ])), - }, - }; -}; - -export default AnimationPage; diff --git a/src/pages/users/login.tsx b/src/pages/users/login.tsx index fb463dc4..9de296b6 100644 --- a/src/pages/users/login.tsx +++ b/src/pages/users/login.tsx @@ -1,7 +1,7 @@ import Head from 'next/head'; import React from 'react'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; -import NavBar from '@/components/nav_bar/nav_bar'; +// import NavBar from '@/components/nav_bar/nav_bar'; import LoginPageBody from '@/components/login_page_body/login_page_body'; import { GetServerSideProps } from 'next'; import { useTranslation } from 'next-i18next'; @@ -10,12 +10,6 @@ import { ILoginPageProps } from '@/interfaces/page_props'; const LoginPage = ({ invitation, action }: ILoginPageProps) => { const { t } = useTranslation('common'); - const displayedBody = ( - - - - ); - return ( <> @@ -37,8 +31,8 @@ const LoginPage = ({ invitation, action }: ILoginPageProps) => { - - {displayedBody} + {/* */} + > ); diff --git a/src/pages/users/select_company.tsx b/src/pages/users/select_company.tsx index d013a3b9..26a1076e 100644 --- a/src/pages/users/select_company.tsx +++ b/src/pages/users/select_company.tsx @@ -1,5 +1,5 @@ import Head from 'next/head'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useModalContext } from '@/contexts/modal_context'; import SelectCompanyPageBody from '@/components/select_company_page_body/select_company_page_body'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; @@ -10,11 +10,14 @@ import { useUserCtx } from '@/contexts/user_context'; import { SkeletonList } from '@/components/skeleton/skeleton'; import { DEFAULT_SKELETON_COUNT_FOR_PAGE } from '@/constants/display'; import { useTranslation } from 'next-i18next'; +import LoginAnimation from '@/components/login/login_animation'; const SelectCompanyPage = () => { const { t } = useTranslation(['common', 'kyc']); const { isAuthLoading } = useUserCtx(); const { eliminateToast } = useModalContext(); + const [isAnimationShowing, setIsAnimationShowing] = useState(true); + // Info: (20241001 - Liz) 先預設都要顯示動畫,之後會依照是否有建立過「角色」來判斷是否要顯示動畫 useEffect(() => { // Info: (20240513 - Julian) 回到選擇公司頁面時,要把提醒試用版的 Toast 關掉 @@ -27,7 +30,11 @@ const SelectCompanyPage = () => { ) : ( - + {isAnimationShowing ? ( + + ) : ( + + )} ); @@ -53,9 +60,7 @@ const SelectCompanyPage = () => { - - - + {!isAnimationShowing && } {displayedBody} >
iSunFA
{t('common:LOGIN_PAGE_BODY.LOG_IN')}
Log In with Google
Log In with Apple