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:
masaaki 2023-10-16 06:52:08 +00:00
parent 45350d0ab8
commit 897bad289b
11 changed files with 177 additions and 51 deletions

View File

@ -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 />} />

View File

@ -32,6 +32,7 @@ export const errorCodes = [
"E010206", // DBのTierが想定外の値エラー
"E010207", // ユーザーのRole変更不可エラー
"E010208", // ユーザーの暗号化パスワード不足エラー
"E010209", // ユーザーの同意済み利用規約バージョンが最新でないエラー
"E010301", // メールアドレス登録済みエラー
"E010302", // authorId重複エラー
"E010401", // PONumber重複エラー

View File

@ -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;
};

View File

@ -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: {

View File

@ -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;

View File

@ -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 });
}
});

View File

@ -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;

View File

@ -4,4 +4,5 @@ export interface LoginState {
export interface Apps {
LoginApiCallStatus: "fulfilled" | "rejected" | "none" | "pending";
localStorageKeyforIdToken: string | null;
}

View 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;

View 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;

View File

@ -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 (
<>