Merged PR 480: 画面修正(ログイン画面)
## 概要 [Task2801: 画面修正(ログイン画面)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2801) - 以下の修正を実施しました - ログイン画面について、未同意バージョンがある場合、利用規約同意画面に遷移する処理を実装 - 利用規約同意画面(ADB2C以外の画面)からログイン画面に遷移した際も処理継続できるよう対応を実施 - このPull Requestでの対象/対象外 - AcceptToUsePageについては、遷移確認用のダミーページなので対象外でお願いします。 - 影響範囲(他の機能にも影響があるか) - ありません。 ## レビューポイント - 特にレビューしてほしい箇所 1. 既存のLoginPageを以下のように分割しています。 実装内容のイメージあっているか確認お願いします。 - LoginPage→AADB2Cからのリダイレクトを元にLocalStorageアクセス用のキーを生成 - TokenSettingPage→LocalStorageアクセス用のキーを使用してidTokenを取得し各種token生成を実施 1. TokenSettingPage/index.tsxにて、型ガード(isErrorObject)を作成し使用しています。 使い方やガードの実装が妥当か確認お願いします。 ## UIの変更 - 無し ## 動作確認状況 - ローカルで確認を実施 ## 補足 - 相談、参考資料などがあれば
This commit is contained in:
parent
45350d0ab8
commit
897bad289b
@ -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 = () => (
|
||||
<Routes>
|
||||
<Route path="/" element={<TopPage />} />
|
||||
<Route path="/auth" element={<AuthPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/authError" element={<AuthErrorPage />} />
|
||||
<Route
|
||||
path="/signup"
|
||||
element={<SignupPage completeTo="/signup/complete" />}
|
||||
/>
|
||||
<Route path="/accept-to-use" element={<AcceptToUsePage />} />
|
||||
<Route path="/signup/complete" element={<SignupCompletePage />} />
|
||||
<Route path="/mail-confirm/" element={<VerifyPage />} />
|
||||
<Route path="/mail-confirm/user" element={<UserVerifyPage />} />
|
||||
|
||||
@ -32,6 +32,7 @@ export const errorCodes = [
|
||||
"E010206", // DBのTierが想定外の値エラー
|
||||
"E010207", // ユーザーのRole変更不可エラー
|
||||
"E010208", // ユーザーの暗号化パスワード不足エラー
|
||||
"E010209", // ユーザーの同意済み利用規約バージョンが最新でないエラー
|
||||
"E010301", // メールアドレス登録済みエラー
|
||||
"E010302", // authorId重複エラー
|
||||
"E010401", // PONumber重複エラー
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -4,4 +4,5 @@ export interface LoginState {
|
||||
|
||||
export interface Apps {
|
||||
LoginApiCallStatus: "fulfilled" | "rejected" | "none" | "pending";
|
||||
localStorageKeyforIdToken: string | null;
|
||||
}
|
||||
|
||||
25
dictation_client/src/pages/AcceptToUsePage/index.tsx
Normal file
25
dictation_client/src/pages/AcceptToUsePage/index.tsx
Normal file
@ -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 (
|
||||
<div className={styles.wrap}>
|
||||
利用規約同意画面のダミー画面
|
||||
<div>
|
||||
{/* eslint-disable-next-line */}
|
||||
<button onClick={navigateToLoginPage}>OK</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AcceptToUsePage;
|
||||
78
dictation_client/src/pages/AuthPage/index.tsx
Normal file
78
dictation_client/src/pages/AuthPage/index.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<Header />
|
||||
<h3>loading ...</h3>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthPage;
|
||||
@ -1,30 +1,39 @@
|
||||
import { useMsal } from "@azure/msal-react";
|
||||
import { AuthError } from "@azure/msal-browser";
|
||||
import { AppDispatch } from "app/store";
|
||||
import { isIdToken } from "common/token";
|
||||
import { loadAccessToken, loadRefreshToken } from "features/auth/utils";
|
||||
import { loginAsync, selectLocalStorageKeyforIdToken } from "features/login";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import Footer from "components/footer";
|
||||
import Header from "components/header";
|
||||
import { loadAccessToken, loadRefreshToken } from "features/auth/utils";
|
||||
import { loginAsync, selectLoginApiCallStatus } from "features/login";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isErrorObject } from "common/errors";
|
||||
|
||||
const LoginPage: React.FC = (): JSX.Element => {
|
||||
const { instance } = useMsal();
|
||||
const dispatch: AppDispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const [, i18n] = useTranslation();
|
||||
const status = useSelector(selectLoginApiCallStatus);
|
||||
const localStorageKeyforIdToken = useSelector(
|
||||
selectLocalStorageKeyforIdToken
|
||||
);
|
||||
|
||||
const login = useCallback(
|
||||
const tokenSet = useCallback(
|
||||
async (idToken: string) => {
|
||||
// ログイン処理呼び出し
|
||||
const { meta } = await dispatch(loginAsync({ idToken }));
|
||||
const { meta, payload } = await dispatch(loginAsync({ idToken }));
|
||||
|
||||
// ログイン失敗した場合、B2Cをログアウトしてからエラーページに遷移する
|
||||
if (meta.requestStatus === "rejected") {
|
||||
if (isErrorObject(payload)) {
|
||||
// 未同意の規約がある場合は利用規約同意画面に遷移する
|
||||
if (payload.error.code === "E010209") {
|
||||
navigate("/accept-to-use");
|
||||
return;
|
||||
}
|
||||
}
|
||||
instance.logoutRedirect({
|
||||
postLogoutRedirectUri: "/AuthError",
|
||||
});
|
||||
@ -48,53 +57,26 @@ const LoginPage: React.FC = (): JSX.Element => {
|
||||
[dispatch, i18n.language, instance, navigate]
|
||||
);
|
||||
|
||||
// TODO 将来的にトークンの取得処理をoperations.ts側に移動させたい。useEffect内で非同期処理を行いたくない。
|
||||
useEffect(() => {
|
||||
if (status !== "none") {
|
||||
// ログイン処理で、何回か本画面が描画される契機があるが、認証処理は一度だけ実施すればよいため認証処理実行済みであれば何もしない
|
||||
// AADB2Cのログイン画面とLoginPageを経由していない場合はトップページに遷移する
|
||||
if (!localStorageKeyforIdToken) {
|
||||
navigate("/");
|
||||
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) {
|
||||
// IDトークンの取得
|
||||
const idTokenString = localStorage.getItem(
|
||||
`${homeAccountId}-${
|
||||
import.meta.env.VITE_B2C_KNOWNAUTHORITIES
|
||||
}-idtoken-${idTokenClaims.aud}----`
|
||||
);
|
||||
if (idTokenString) {
|
||||
const idTokenObject = JSON.parse(idTokenString);
|
||||
if (isIdToken(idTokenObject)) {
|
||||
await login(idTokenObject.secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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("/");
|
||||
}
|
||||
// IDトークンの取得
|
||||
const idTokenString = localStorage.getItem(localStorageKeyforIdToken);
|
||||
if (idTokenString) {
|
||||
const idTokenObject = JSON.parse(idTokenString);
|
||||
if (isIdToken(idTokenObject)) {
|
||||
await tokenSet(idTokenObject.secret);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [instance, login, navigate, status]);
|
||||
// 画面描画後のみ実行するため引数を設定しない
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user