diff --git a/dictation_client/src/AppRouter.tsx b/dictation_client/src/AppRouter.tsx index cf6e1d3..8ac2ccc 100644 --- a/dictation_client/src/AppRouter.tsx +++ b/dictation_client/src/AppRouter.tsx @@ -1,5 +1,6 @@ import { Route, Routes } from "react-router-dom"; import TopPage from "pages/TopPage"; +import AuthPage from "pages/AuthPage"; import LoginPage from "pages/LoginPage"; import SamplePage from "pages/SamplePage"; import { AuthErrorPage } from "pages/ErrorPage"; @@ -20,18 +21,21 @@ import WorkflowPage from "pages/WorkflowPage"; import TypistGroupSettingPage from "pages/TypistGroupSettingPage"; import WorktypeIdSettingPage from "pages/WorkTypeIdSettingPage"; import AccountPage from "pages/AccountPage"; +import AcceptToUsePage from "pages/AcceptToUsePage"; import { TemplateFilePage } from "pages/TemplateFilePage"; import { AccountDeleteSuccess } from "pages/AccountPage/accountDeleteSuccess"; const AppRouter: React.FC = () => ( } /> + } /> } /> } /> } /> + } /> } /> } /> } /> diff --git a/dictation_client/src/common/errors/code.ts b/dictation_client/src/common/errors/code.ts index 8c3236f..1867e14 100644 --- a/dictation_client/src/common/errors/code.ts +++ b/dictation_client/src/common/errors/code.ts @@ -32,6 +32,7 @@ export const errorCodes = [ "E010206", // DBのTierが想定外の値エラー "E010207", // ユーザーのRole変更不可エラー "E010208", // ユーザーの暗号化パスワード不足エラー + "E010209", // ユーザーの同意済み利用規約バージョンが最新でないエラー "E010301", // メールアドレス登録済みエラー "E010302", // authorId重複エラー "E010401", // PONumber重複エラー diff --git a/dictation_client/src/common/errors/utils.ts b/dictation_client/src/common/errors/utils.ts index 8f756ca..3dd2410 100644 --- a/dictation_client/src/common/errors/utils.ts +++ b/dictation_client/src/common/errors/utils.ts @@ -81,3 +81,21 @@ const isErrorResponse = (error: unknown): error is ErrorResponse => { const isErrorCode = (errorCode: string): errorCode is ErrorCodeType => errorCodes.includes(errorCode as ErrorCodeType); + +export const isErrorObject = ( + data: unknown +): data is { error: ErrorObject } => { + if ( + data && + typeof data === "object" && + "error" in data && + typeof (data as { error: ErrorObject }).error === "object" && + typeof (data as { error: ErrorObject }).error.message === "string" && + typeof (data as { error: ErrorObject }).error.code === "string" && + (typeof (data as { error: ErrorObject }).error.statusCode === "number" || + (data as { error: ErrorObject }).error.statusCode === undefined) + ) { + return true; + } + return false; +}; diff --git a/dictation_client/src/common/msalConfig.ts b/dictation_client/src/common/msalConfig.ts index e90e744..6a28910 100644 --- a/dictation_client/src/common/msalConfig.ts +++ b/dictation_client/src/common/msalConfig.ts @@ -5,7 +5,7 @@ export const msalConfig: Configuration = { clientId: import.meta.env.VITE_B2C_CLIENTID, authority: import.meta.env.VITE_B2C_AUTHORITY, knownAuthorities: [import.meta.env.VITE_B2C_KNOWNAUTHORITIES], - redirectUri: `${globalThis.location.origin}/login`, + redirectUri: `${globalThis.location.origin}/auth`, navigateToLoginRequestUrl: false, }, cache: { diff --git a/dictation_client/src/features/login/loginSlice.ts b/dictation_client/src/features/login/loginSlice.ts index 322a3ce..312bae1 100644 --- a/dictation_client/src/features/login/loginSlice.ts +++ b/dictation_client/src/features/login/loginSlice.ts @@ -1,17 +1,26 @@ -import { createSlice } from "@reduxjs/toolkit"; +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; import { LoginState } from "./state"; import { loginAsync } from "./operations"; const initialState: LoginState = { apps: { LoginApiCallStatus: "none", + localStorageKeyforIdToken: null, }, }; export const loginSlice = createSlice({ name: "login", initialState, - reducers: {}, + reducers: { + changeLocalStorageKeyforIdToken: ( + state, + action: PayloadAction<{ localStorageKeyforIdToken: string }> + ) => { + const { localStorageKeyforIdToken } = action.payload; + state.apps.localStorageKeyforIdToken = localStorageKeyforIdToken; + }, + }, extraReducers: (builder) => { builder.addCase(loginAsync.pending, (state) => { state.apps.LoginApiCallStatus = "pending"; @@ -25,4 +34,5 @@ export const loginSlice = createSlice({ }, }); +export const { changeLocalStorageKeyforIdToken } = loginSlice.actions; export default loginSlice.reducer; diff --git a/dictation_client/src/features/login/operations.ts b/dictation_client/src/features/login/operations.ts index 61ddece..0ac9edd 100644 --- a/dictation_client/src/features/login/operations.ts +++ b/dictation_client/src/features/login/operations.ts @@ -3,6 +3,7 @@ import type { RootState } from "app/store"; import { setToken } from "features/auth/authSlice"; import { AuthApi } from "../../api/api"; import { Configuration } from "../../api/configuration"; +import { ErrorObject, createErrorObject } from "../../common/errors"; export const loginAsync = createAsyncThunk< { @@ -14,7 +15,7 @@ export const loginAsync = createAsyncThunk< { // rejectした時の返却値の型 rejectValue: { - /* Empty Object */ + error: ErrorObject; }; } >("login/loginAsync", async (args, thunkApi) => { @@ -41,6 +42,8 @@ export const loginAsync = createAsyncThunk< return {}; } catch (e) { - return thunkApi.rejectWithValue({}); + // e ⇒ errorObjectに変換" + const error = createErrorObject(e); + return thunkApi.rejectWithValue({ error }); } }); diff --git a/dictation_client/src/features/login/selectors.ts b/dictation_client/src/features/login/selectors.ts index 9615b35..d0ded8e 100644 --- a/dictation_client/src/features/login/selectors.ts +++ b/dictation_client/src/features/login/selectors.ts @@ -4,3 +4,7 @@ export const selectLoginApiCallStatus = ( state: RootState ): "fulfilled" | "rejected" | "none" | "pending" => state.login.apps.LoginApiCallStatus; + +export const selectLocalStorageKeyforIdToken = ( + state: RootState +): string | null => state.login.apps.localStorageKeyforIdToken; diff --git a/dictation_client/src/features/login/state.ts b/dictation_client/src/features/login/state.ts index 98a61ad..98fa599 100644 --- a/dictation_client/src/features/login/state.ts +++ b/dictation_client/src/features/login/state.ts @@ -4,4 +4,5 @@ export interface LoginState { export interface Apps { LoginApiCallStatus: "fulfilled" | "rejected" | "none" | "pending"; + localStorageKeyforIdToken: string | null; } diff --git a/dictation_client/src/pages/AcceptToUsePage/index.tsx b/dictation_client/src/pages/AcceptToUsePage/index.tsx new file mode 100644 index 0000000..a6e5d0e --- /dev/null +++ b/dictation_client/src/pages/AcceptToUsePage/index.tsx @@ -0,0 +1,25 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import styles from "styles/app.module.scss"; +import { useNavigate } from "react-router-dom"; + +const AcceptToUsePage: React.FC = (): JSX.Element => { + // 遷移確認用のダミーページ + + const navigate = useNavigate(); + + const navigateToLoginPage = () => { + navigate("/login"); + }; + + return ( +
+ 利用規約同意画面のダミー画面 +
+ {/* eslint-disable-next-line */} + +
+
+ ); +}; + +export default AcceptToUsePage; diff --git a/dictation_client/src/pages/AuthPage/index.tsx b/dictation_client/src/pages/AuthPage/index.tsx new file mode 100644 index 0000000..a1559bd --- /dev/null +++ b/dictation_client/src/pages/AuthPage/index.tsx @@ -0,0 +1,78 @@ +import { useMsal } from "@azure/msal-react"; +import { AuthError } from "@azure/msal-browser"; +import { AppDispatch } from "app/store"; +import Footer from "components/footer"; +import Header from "components/header"; +import { + selectLoginApiCallStatus, + changeLocalStorageKeyforIdToken, +} from "features/login"; +import React, { useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useNavigate } from "react-router-dom"; + +const AuthPage: React.FC = (): JSX.Element => { + const { instance } = useMsal(); + const dispatch: AppDispatch = useDispatch(); + const navigate = useNavigate(); + const status = useSelector(selectLoginApiCallStatus); + + // TODO 将来的にトークンの取得処理をoperations.ts側に移動させたい。useEffect内で非同期処理を行いたくない。 + useEffect(() => { + if (status !== "none") { + // ログイン処理で、何回か本画面が描画される契機があるが、認証処理は一度だけ実施すればよいため認証処理実行済みであれば何もしない + return; + } + + (async () => { + try { + const loginResult = await instance.handleRedirectPromise(); + + // eslint-disable-next-line + console.log({ loginResult }); // TODO:loading画面から遷移できない事象の調査用ログ。事象解消後削除(eslint-disable含めて)する。 + + if (loginResult && loginResult.account) { + const { homeAccountId, idTokenClaims } = loginResult.account; + if (idTokenClaims && idTokenClaims.aud) { + const localStorageKeyforIdToken = `${homeAccountId}-${ + import.meta.env.VITE_B2C_KNOWNAUTHORITIES + }-idtoken-${idTokenClaims.aud}----`; + + // AADB2Cログイン画面以外から本画面に遷移した場合用にIDトークン取得用キーをstateに保存 + dispatch( + changeLocalStorageKeyforIdToken({ + localStorageKeyforIdToken, + }) + ); + + // トークン取得と設定を行う + navigate("/login"); + } + } + } catch (e) { + // eslint-disable-next-line + console.log({ e }); // TODO:loading画面から遷移できない事象の調査用ログ。事象解消後削除(eslint-disable含めて)する。 + + // AAD B2Cの多要素認証画面やパスワードリセット画面で「cancel」をクリックすると、handleRedirectPromise()にてエラーが発生するため、 + // それをハンドリングして適切な画面遷移処理を行う。 + if (e instanceof AuthError) { + // エラーコードはerrorMessageの中の一部として埋め込まれており完全一致で取得するのは筋が悪いため、部分一致で取得する。 + // TODO 他にもAADB2Cのエラーコードを使用する箇所が出てきた場合、定数化すること + if (e.errorMessage.startsWith("AADB2C90091")) { + navigate("/"); + } + } + } + })(); + }, [instance, navigate, status, dispatch]); + + return ( + <> +
+

loading ...

+