From 16b7416de0efad85519d1be798ed2f89bc255bec Mon Sep 17 00:00:00 2001 From: "saito.k" Date: Tue, 25 Apr 2023 10:18:00 +0000 Subject: [PATCH] =?UTF-8?q?Merged=20PR=2080:=20=E7=94=BB=E9=9D=A2=E5=AE=9F?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task1618: 画面実装](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/1618) - login処理が成功した時にデスクトップアプリを起動するように実装 - デスクトップアプリを起動するURLは確認済み ## レビューポイント - デスクトップアプリを起動するタイミングは問題ないか - 実装を追加した場所は問題ないか ## UIの変更 - Before/Afterのスクショなど - スクショ置き場 ## 動作確認状況 - ローカルで確認 ## 補足 - useEffectの依存関係からloginを削除 - これでAPI呼び出しが複数回行われることは無くなったが再度調査が必要そう --- dictation_client/src/common/token.ts | 34 ++++++ dictation_client/src/features/login/index.ts | 1 + .../src/features/login/loginSlice.ts | 17 ++- .../src/features/login/operations.ts | 12 +-- .../src/features/login/selectors.ts | 6 ++ dictation_client/src/features/login/state.ts | 5 +- .../src/pages/LoginPage/index.tsx | 102 +++++++++++------- 7 files changed, 126 insertions(+), 51 deletions(-) create mode 100644 dictation_client/src/features/login/selectors.ts diff --git a/dictation_client/src/common/token.ts b/dictation_client/src/common/token.ts index 4d1e163..de1e048 100644 --- a/dictation_client/src/common/token.ts +++ b/dictation_client/src/common/token.ts @@ -25,3 +25,37 @@ export const isToken = (arg: any): arg is Token => { return true; }; + +interface IdToken { + credentialType: string; + homeAccountId: string; + environment: string; + clientId: string; + secret: string; + realm: string; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isIdToken = (arg: any): arg is IdToken => { + const idToken = arg as IdToken; + if (idToken.credentialType === undefined) { + return false; + } + if (idToken.homeAccountId === undefined) { + return false; + } + if (idToken.environment === undefined) { + return false; + } + if (idToken.clientId === undefined) { + return false; + } + if (idToken.secret === undefined) { + return false; + } + if (idToken.realm === undefined) { + return false; + } + + return true; +}; diff --git a/dictation_client/src/features/login/index.ts b/dictation_client/src/features/login/index.ts index d3e1e79..2fc4fac 100644 --- a/dictation_client/src/features/login/index.ts +++ b/dictation_client/src/features/login/index.ts @@ -1,3 +1,4 @@ export * from "./loginSlice"; export * from "./state"; export * from "./operations"; +export * from "./selectors"; diff --git a/dictation_client/src/features/login/loginSlice.ts b/dictation_client/src/features/login/loginSlice.ts index a15a1ce..322a3ce 100644 --- a/dictation_client/src/features/login/loginSlice.ts +++ b/dictation_client/src/features/login/loginSlice.ts @@ -1,16 +1,27 @@ import { createSlice } from "@reduxjs/toolkit"; import { LoginState } from "./state"; +import { loginAsync } from "./operations"; const initialState: LoginState = { - apps: {}, + apps: { + LoginApiCallStatus: "none", + }, }; export const loginSlice = createSlice({ name: "login", initialState, reducers: {}, - extraReducers: () => { - // + extraReducers: (builder) => { + builder.addCase(loginAsync.pending, (state) => { + state.apps.LoginApiCallStatus = "pending"; + }); + builder.addCase(loginAsync.fulfilled, (state) => { + state.apps.LoginApiCallStatus = "fulfilled"; + }); + builder.addCase(loginAsync.rejected, (state) => { + state.apps.LoginApiCallStatus = "rejected"; + }); }, }); diff --git a/dictation_client/src/features/login/operations.ts b/dictation_client/src/features/login/operations.ts index f5c2060..61ddece 100644 --- a/dictation_client/src/features/login/operations.ts +++ b/dictation_client/src/features/login/operations.ts @@ -1,4 +1,3 @@ -import { IPublicClientApplication, SilentRequest } from "@azure/msal-browser"; import { createAsyncThunk } from "@reduxjs/toolkit"; import type { RootState } from "app/store"; import { setToken } from "features/auth/authSlice"; @@ -7,11 +6,10 @@ import { Configuration } from "../../api/configuration"; export const loginAsync = createAsyncThunk< { - /* Empty Object */ + // }, { - instance: IPublicClientApplication; - request: SilentRequest; + idToken: string; }, { // rejectした時の返却値の型 @@ -20,7 +18,7 @@ export const loginAsync = createAsyncThunk< }; } >("login/loginAsync", async (args, thunkApi) => { - const { instance, request } = args; + const { idToken } = args; // apiのConfigurationを取得する const { getState } = thunkApi; const state = getState() as RootState; @@ -29,10 +27,8 @@ export const loginAsync = createAsyncThunk< const authApi = new AuthApi(config); try { - // B2CからIDトークンを取得 - const b2cToken = await instance.acquireTokenSilent(request); const { data } = await authApi.token({ - idToken: b2cToken.idToken, + idToken, type: "web", }); // アクセストークン・リフレッシュトークンをlocalStorageに保存 diff --git a/dictation_client/src/features/login/selectors.ts b/dictation_client/src/features/login/selectors.ts new file mode 100644 index 0000000..9615b35 --- /dev/null +++ b/dictation_client/src/features/login/selectors.ts @@ -0,0 +1,6 @@ +import { RootState } from "app/store"; + +export const selectLoginApiCallStatus = ( + state: RootState +): "fulfilled" | "rejected" | "none" | "pending" => + state.login.apps.LoginApiCallStatus; diff --git a/dictation_client/src/features/login/state.ts b/dictation_client/src/features/login/state.ts index 00862e6..98a61ad 100644 --- a/dictation_client/src/features/login/state.ts +++ b/dictation_client/src/features/login/state.ts @@ -2,5 +2,6 @@ export interface LoginState { apps: Apps; } -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface Apps {} +export interface Apps { + LoginApiCallStatus: "fulfilled" | "rejected" | "none" | "pending"; +} diff --git a/dictation_client/src/pages/LoginPage/index.tsx b/dictation_client/src/pages/LoginPage/index.tsx index a95fe75..58d6796 100644 --- a/dictation_client/src/pages/LoginPage/index.tsx +++ b/dictation_client/src/pages/LoginPage/index.tsx @@ -1,52 +1,78 @@ -import { InteractionStatus, SilentRequest } from "@azure/msal-browser"; -import { useIsAuthenticated, useMsal } from "@azure/msal-react"; +import { useMsal } from "@azure/msal-react"; import { AppDispatch } from "app/store"; +import { isIdToken } from "common/token"; import Footer from "components/footer"; import Header from "components/header"; -import { loginAsync } from "features/login"; -import React, { useCallback, useLayoutEffect } from "react"; -import { useDispatch } from "react-redux"; +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"; const LoginPage: React.FC = (): JSX.Element => { - const { accounts, instance, inProgress } = useMsal(); + /* XXX B2CのログインからIDトークンの取得までの挙動を整理する必要がある。 + 「プロダクト バックログ項目 1655: ログイン周りの挙動について調査・整理する」で調査・整理する。 + */ + const { accounts, instance } = useMsal(); const dispatch: AppDispatch = useDispatch(); const navigate = useNavigate(); - const isAuthenticated = useIsAuthenticated(); - const login = useCallback(async () => { - const request: SilentRequest = { - scopes: ["openid"], - account: accounts[0], - }; - // ログイン処理呼び出し - const { meta } = await dispatch(loginAsync({ instance, request })); + const [, i18n] = useTranslation(); + const status = useSelector(selectLoginApiCallStatus); - // ログイン失敗した場合、B2Cをログアウトしてからエラーページに遷移する - if (meta.requestStatus === "rejected") { - instance.logout({ - postLogoutRedirectUri: "/AuthError", - }); - } - if (meta.requestStatus === "fulfilled") { - navigate("/xxx"); - } - }, [accounts, dispatch, instance, navigate]); + const login = useCallback( + async (idToken: string) => { + // ログイン処理呼び出し + const { meta } = await dispatch(loginAsync({ idToken })); - useLayoutEffect(() => { - // B2CからリダイレクトされてB2Cへのログインが完了してからAPIを呼ぶ - if (isAuthenticated && inProgress === InteractionStatus.None) { - login(); - } - }, [ - accounts, - dispatch, - inProgress, - instance, - isAuthenticated, - login, - navigate, - ]); + // ログイン失敗した場合、B2Cをログアウトしてからエラーページに遷移する + if (meta.requestStatus === "rejected") { + instance.logout({ + postLogoutRedirectUri: "/AuthError", + }); + } + if (meta.requestStatus === "fulfilled") { + const accessToken = loadAccessToken(); + const refreshToken = loadRefreshToken(); + /* TODO デスクトップアプリが無いためメモ帳を開くようにしている + デスクトップアプリチームでスキーム名が決まり次第修正する + */ + const url = `note:login?accessToken=${accessToken}&refreshToken=${refreshToken}&language=${i18n.language}`; // カスタムURLスキーム + const a = document.createElement("a"); + a.href = url; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + navigate("/xxx"); + } + }, + [dispatch, i18n.language, instance, navigate] + ); + // TODO 将来的にトークンの取得処理をoperations.ts側に移動させたい。useEffect内で非同期処理を行いたくない。 + useEffect(() => { + (async () => { + if (accounts.length >= 1) { + const { homeAccountId, idTokenClaims } = accounts[0]; + if (idTokenClaims) { + if (idTokenClaims.aud) { + // IDトークンの取得 + const idTokenString = localStorage.getItem( + `${homeAccountId}-${ + import.meta.env.VITE_B2C_KNOWNAUTHORITIES + }-idtoken-${idTokenClaims.aud}----` + ); + if (idTokenString && status === "none") { + const idTokenObject = JSON.parse(idTokenString); + if (isIdToken(idTokenObject)) { + await login(idTokenObject.secret); + } + } + } + } + } + })(); + }, [accounts, login, status]); return ( <>