Merged PR 5: タスク 1471: 画面実装(トークン系)
## 概要 [Task: 1471](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/OMDSDictation/_sprints/taskboard/OMDSDictation%20%E3%83%81%E3%83%BC%E3%83%A0/OMDSDictation/%E3%82%B9%E3%83%97%E3%83%AA%E3%83%B3%E3%83%88%203-2?workitem=1471) - アクセストークンの自動更新処理を実装 - UpdateTokenTimerで定期実行を行う - 未ログインまたはトークンが期限切れの状態で、ログイン後の画面にアクセスした場合、Topページにリダイレクトする処理を実装 - RouteAuthGuard.tsx - APIからのレスポンスが401だった時にTopページにリダイレクトする処理を実装 - App.tsx ## レビューポイント - 今の実装だとトークンの自動更新に失敗した場合、画面上では何も起こらないようにになっている - 更新が失敗し続け、アクセストークンが切れた段階でRouteAuthGuardではじかれてTopへリダイレクトする - トークンの期限を確認する間隔を3分にしているが問題なさそうか ## UIの変更 - ## 動作確認状況 - ローカルで動作確認 ## 補足
This commit is contained in:
parent
a1ddc64d2b
commit
4ce2bbf823
41
dictation_client/package-lock.json
generated
41
dictation_client/package-lock.json
generated
@ -23,7 +23,9 @@
|
|||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"eslint-plugin-prefer-arrow": "^1.2.3",
|
"eslint-plugin-prefer-arrow": "^1.2.3",
|
||||||
"i18next": "^21.10.0",
|
"i18next": "^21.10.0",
|
||||||
|
"jwt-decode": "^3.1.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"luxon": "^3.3.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-google-recaptcha-v3": "^1.10.0",
|
"react-google-recaptcha-v3": "^1.10.0",
|
||||||
@ -42,6 +44,7 @@
|
|||||||
"@mdx-js/react": "^2.1.2",
|
"@mdx-js/react": "^2.1.2",
|
||||||
"@openapitools/openapi-generator-cli": "^2.5.1",
|
"@openapitools/openapi-generator-cli": "^2.5.1",
|
||||||
"@types/lodash": "^4.14.191",
|
"@types/lodash": "^4.14.191",
|
||||||
|
"@types/luxon": "^3.2.0",
|
||||||
"@types/react": "^18.0.0",
|
"@types/react": "^18.0.0",
|
||||||
"@types/react-dom": "^18.0.0",
|
"@types/react-dom": "^18.0.0",
|
||||||
"@types/redux-mock-store": "^1.0.3",
|
"@types/redux-mock-store": "^1.0.3",
|
||||||
@ -65,6 +68,9 @@
|
|||||||
"vite": "^2.9.9",
|
"vite": "^2.9.9",
|
||||||
"vite-plugin-env-compatible": "^1.1.1",
|
"vite-plugin-env-compatible": "^1.1.1",
|
||||||
"vite-tsconfig-paths": "^3.5.0"
|
"vite-tsconfig-paths": "^3.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ampproject/remapping": {
|
"node_modules/@ampproject/remapping": {
|
||||||
@ -1098,6 +1104,12 @@
|
|||||||
"integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==",
|
"integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/luxon": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-lGmaGFoaXHuOLXFvuju2bfvZRqxAqkHPx9Y9IQdQABrinJJshJwfNCKV+u7rR3kJbiqfTF/NhOkcxxAFrObyaA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@types/mdx": {
|
"node_modules/@types/mdx": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@ -4016,6 +4028,11 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwt-decode": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
|
||||||
|
},
|
||||||
"node_modules/language-subtag-registry": {
|
"node_modules/language-subtag-registry": {
|
||||||
"version": "0.3.22",
|
"version": "0.3.22",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@ -4218,6 +4235,14 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"license": "WTFPL",
|
"license": "WTFPL",
|
||||||
@ -6873,6 +6898,12 @@
|
|||||||
"integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==",
|
"integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@types/luxon": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-lGmaGFoaXHuOLXFvuju2bfvZRqxAqkHPx9Y9IQdQABrinJJshJwfNCKV+u7rR3kJbiqfTF/NhOkcxxAFrObyaA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"@types/mdx": {
|
"@types/mdx": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"dev": true
|
"dev": true
|
||||||
@ -8732,6 +8763,11 @@
|
|||||||
"object.assign": "^4.1.2"
|
"object.assign": "^4.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"jwt-decode": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
|
||||||
|
},
|
||||||
"language-subtag-registry": {
|
"language-subtag-registry": {
|
||||||
"version": "0.3.22",
|
"version": "0.3.22",
|
||||||
"dev": true
|
"dev": true
|
||||||
@ -8876,6 +8912,11 @@
|
|||||||
"yallist": "^4.0.0"
|
"yallist": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"luxon": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg=="
|
||||||
|
},
|
||||||
"lz-string": {
|
"lz-string": {
|
||||||
"version": "1.4.4"
|
"version": "1.4.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -32,7 +32,9 @@
|
|||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"eslint-plugin-prefer-arrow": "^1.2.3",
|
"eslint-plugin-prefer-arrow": "^1.2.3",
|
||||||
"i18next": "^21.10.0",
|
"i18next": "^21.10.0",
|
||||||
|
"jwt-decode": "^3.1.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"luxon": "^3.3.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-google-recaptcha-v3": "^1.10.0",
|
"react-google-recaptcha-v3": "^1.10.0",
|
||||||
@ -51,6 +53,7 @@
|
|||||||
"@mdx-js/react": "^2.1.2",
|
"@mdx-js/react": "^2.1.2",
|
||||||
"@openapitools/openapi-generator-cli": "^2.5.1",
|
"@openapitools/openapi-generator-cli": "^2.5.1",
|
||||||
"@types/lodash": "^4.14.191",
|
"@types/lodash": "^4.14.191",
|
||||||
|
"@types/luxon": "^3.2.0",
|
||||||
"@types/react": "^18.0.0",
|
"@types/react": "^18.0.0",
|
||||||
"@types/react-dom": "^18.0.0",
|
"@types/react-dom": "^18.0.0",
|
||||||
"@types/redux-mock-store": "^1.0.3",
|
"@types/redux-mock-store": "^1.0.3",
|
||||||
|
|||||||
@ -2,11 +2,35 @@ import AppRouter from "AppRouter";
|
|||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { GlobalStyle } from "styles/GlobalStyle";
|
import { GlobalStyle } from "styles/GlobalStyle";
|
||||||
import { PublicClientApplication } from "@azure/msal-browser";
|
import { PublicClientApplication } from "@azure/msal-browser";
|
||||||
import { MsalProvider } from "@azure/msal-react";
|
import { MsalProvider, useMsal } from "@azure/msal-react";
|
||||||
import { msalConfig } from "common/auth/msalConfig";
|
import { msalConfig } from "common/msalConfig";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useDispatch } from "react-redux";
|
||||||
|
import globalAxios, { AxiosError, AxiosResponse } from "axios";
|
||||||
|
import { clearToken } from "features/auth";
|
||||||
|
|
||||||
const App = (): JSX.Element => {
|
const App = (): JSX.Element => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const { instance } = useMsal();
|
||||||
const pca = new PublicClientApplication(msalConfig);
|
const pca = new PublicClientApplication(msalConfig);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = globalAxios.interceptors.response.use(
|
||||||
|
(response: AxiosResponse) => response,
|
||||||
|
(e: AxiosError) => {
|
||||||
|
if (e?.response?.status === 401) {
|
||||||
|
dispatch(clearToken());
|
||||||
|
instance.logout({
|
||||||
|
postLogoutRedirectUri: "/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.reject(e);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const cleanup = () => {
|
||||||
|
globalAxios.interceptors.response.eject(id);
|
||||||
|
};
|
||||||
|
return cleanup;
|
||||||
|
}, [dispatch, instance]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -4,14 +4,18 @@ import TopPage from "pages/TopPage";
|
|||||||
import LoginPage from "pages/LoginPage";
|
import LoginPage from "pages/LoginPage";
|
||||||
import SamplePage from "pages/SamplePage";
|
import SamplePage from "pages/SamplePage";
|
||||||
import { AuthErrorPage } from "pages/ErrorPage";
|
import { AuthErrorPage } from "pages/ErrorPage";
|
||||||
|
import { RouteAuthGuard } from "components/auth/routeAuthGuard";
|
||||||
|
|
||||||
const AppRouter: React.FC = () => (
|
const AppRouter: React.FC = () => (
|
||||||
<BaseDiv>
|
<BaseDiv>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<TopPage />} />
|
<Route path="/" element={<TopPage />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/XXX" element={<SamplePage />} />
|
|
||||||
<Route path="/AuthError" element={<AuthErrorPage />} />
|
<Route path="/AuthError" element={<AuthErrorPage />} />
|
||||||
|
<Route
|
||||||
|
path="/XXX"
|
||||||
|
element={<RouteAuthGuard component={<SamplePage />} />}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BaseDiv>
|
</BaseDiv>
|
||||||
);
|
);
|
||||||
|
|||||||
19
dictation_client/src/common/decodeToken.ts
Normal file
19
dictation_client/src/common/decodeToken.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import jwt_decode from "jwt-decode";
|
||||||
|
import { isToken, Token } from "./token";
|
||||||
|
|
||||||
|
export const decodeToken = (jwt: string): Token | null => {
|
||||||
|
if (jwt === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const token = jwt_decode(jwt);
|
||||||
|
|
||||||
|
// JWTのpayloadを復号したオブジェクトが、Token interfaceを実装していなかった場合はnull
|
||||||
|
if (!isToken(token)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
27
dictation_client/src/common/token.ts
Normal file
27
dictation_client/src/common/token.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
// トークンの型やtypeGuardの関数を配置するファイル
|
||||||
|
// TODO トークンの型は仮
|
||||||
|
export interface Token {
|
||||||
|
userId: string;
|
||||||
|
scope: string;
|
||||||
|
exp: number;
|
||||||
|
iat: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type Guard
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const isToken = (arg: any): arg is Token => {
|
||||||
|
if (arg.userId === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (arg.scope === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (arg.exp === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (arg.iat === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
22
dictation_client/src/common/useInterval.ts
Normal file
22
dictation_client/src/common/useInterval.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
export const useInterval = async (
|
||||||
|
callback: () => Promise<void>,
|
||||||
|
interval: number
|
||||||
|
) => {
|
||||||
|
const callbackRef = useRef<() => Promise<void>>(callback);
|
||||||
|
useEffect(() => {
|
||||||
|
callbackRef.current = callback;
|
||||||
|
}, [callback]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateToken = async () => {
|
||||||
|
await callbackRef.current();
|
||||||
|
};
|
||||||
|
const id = setInterval(updateToken, interval);
|
||||||
|
return () => {
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
41
dictation_client/src/components/auth/routeAuthGuard.tsx
Normal file
41
dictation_client/src/components/auth/routeAuthGuard.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
isAuthenticatedSelector,
|
||||||
|
isTokenExpired,
|
||||||
|
} from "features/auth/selectors";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { AppDispatch } from "app/store";
|
||||||
|
import { clearToken } from "features/auth";
|
||||||
|
import { useMsal } from "@azure/msal-react";
|
||||||
|
|
||||||
|
type RouteAuthGuardProps = {
|
||||||
|
component: JSX.Element;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RouteAuthGuard = (props: RouteAuthGuardProps) => {
|
||||||
|
const isAuth = useSelector(isAuthenticatedSelector);
|
||||||
|
const isExpired = useSelector(isTokenExpired);
|
||||||
|
const dispatch: AppDispatch = useDispatch();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { instance } = useMsal();
|
||||||
|
const { component } = props;
|
||||||
|
// トークンの有効期限が切れていたらTopへリダイレクト
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuth) {
|
||||||
|
navigate("/");
|
||||||
|
} else if (isExpired) {
|
||||||
|
dispatch(clearToken());
|
||||||
|
// B2Cからもログアウトする
|
||||||
|
instance.logout({
|
||||||
|
postLogoutRedirectUri: "/",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isAuth, isExpired, dispatch, navigate, instance]);
|
||||||
|
|
||||||
|
if (!isAuth || isExpired) {
|
||||||
|
return <div />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return component;
|
||||||
|
};
|
||||||
38
dictation_client/src/components/auth/updateTokenTimer.tsx
Normal file
38
dictation_client/src/components/auth/updateTokenTimer.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { AppDispatch } from "app/store";
|
||||||
|
import { decodeToken } from "common/decodeToken";
|
||||||
|
import { useInterval } from "common/useInterval";
|
||||||
|
import { updateTokenAsync } from "features/auth/operations";
|
||||||
|
import { loadAccessToken } from "features/auth/utils";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { useDispatch } from "react-redux";
|
||||||
|
//アクセストークンを更新する基準の秒数
|
||||||
|
const TOKEN_UPDATE_TIME = 5 * 60;
|
||||||
|
//アクセストークンの更新チェックを行う間隔(ミリ秒)
|
||||||
|
const TOKEN_UPDATE_INTERVAL_MS = 3 * 60 * 1000;
|
||||||
|
|
||||||
|
export const UpdateTokenTimer = () => {
|
||||||
|
const dispatch: AppDispatch = useDispatch();
|
||||||
|
|
||||||
|
// 期限が5分以内であれば更新APIを呼ぶ
|
||||||
|
const updateToken = useCallback(async () => {
|
||||||
|
// localStorageからトークンを取得
|
||||||
|
const jwt = loadAccessToken();
|
||||||
|
// selectorに以下の判定処理を移したかったが、初期表示時の値でしか判定できないのでComponent内に置く
|
||||||
|
if (jwt) {
|
||||||
|
const token = decodeToken(jwt);
|
||||||
|
if (token) {
|
||||||
|
const { exp } = token;
|
||||||
|
const now = DateTime.local().toSeconds();
|
||||||
|
if (exp - now <= TOKEN_UPDATE_TIME) {
|
||||||
|
await dispatch(updateTokenAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
useInterval(updateToken, TOKEN_UPDATE_INTERVAL_MS);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||||
|
return <></>;
|
||||||
|
};
|
||||||
@ -26,7 +26,6 @@ export const authSlice = createSlice({
|
|||||||
) => {
|
) => {
|
||||||
const { accessToken, refreshToken } = action.payload;
|
const { accessToken, refreshToken } = action.payload;
|
||||||
if (accessToken && refreshToken) {
|
if (accessToken && refreshToken) {
|
||||||
state.configuration.accessToken = accessToken;
|
|
||||||
state.accessToken = accessToken;
|
state.accessToken = accessToken;
|
||||||
saveAccessToken(accessToken);
|
saveAccessToken(accessToken);
|
||||||
|
|
||||||
@ -35,7 +34,6 @@ export const authSlice = createSlice({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
clearToken: (state) => {
|
clearToken: (state) => {
|
||||||
state.configuration.accessToken = undefined;
|
|
||||||
state.accessToken = null;
|
state.accessToken = null;
|
||||||
state.refreshToken = null;
|
state.refreshToken = null;
|
||||||
removeAccessToken();
|
removeAccessToken();
|
||||||
|
|||||||
42
dictation_client/src/features/auth/operations.ts
Normal file
42
dictation_client/src/features/auth/operations.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { createAsyncThunk } from "@reduxjs/toolkit";
|
||||||
|
import { RootState } from "app/store";
|
||||||
|
import { AuthApi } from "../../api/api";
|
||||||
|
import { Configuration } from "../../api/configuration";
|
||||||
|
import { setToken } from "./authSlice";
|
||||||
|
|
||||||
|
export const updateTokenAsync = createAsyncThunk<
|
||||||
|
{
|
||||||
|
/* Empty Object */
|
||||||
|
},
|
||||||
|
void,
|
||||||
|
{
|
||||||
|
// rejectした時の返却値の型
|
||||||
|
rejectValue: {
|
||||||
|
/* Empty Object */
|
||||||
|
};
|
||||||
|
}
|
||||||
|
>("auth/updateTokenAsync", async (args, thunkApi) => {
|
||||||
|
// apiのConfigurationを取得する
|
||||||
|
const { getState } = thunkApi;
|
||||||
|
const state = getState() as RootState;
|
||||||
|
const { configuration, refreshToken } = state.auth;
|
||||||
|
const config = new Configuration(configuration);
|
||||||
|
const authApi = new AuthApi(config);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await authApi.accessToken({
|
||||||
|
headers: { authorization: `Bearer ${refreshToken}` },
|
||||||
|
});
|
||||||
|
// アクセストークン・リフレッシュトークンをlocalStorageに保存
|
||||||
|
thunkApi.dispatch(
|
||||||
|
setToken({
|
||||||
|
accessToken: data.accessToken,
|
||||||
|
refreshToken: state.auth.refreshToken,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {};
|
||||||
|
} catch (e) {
|
||||||
|
return thunkApi.rejectWithValue({});
|
||||||
|
}
|
||||||
|
});
|
||||||
24
dictation_client/src/features/auth/selectors.ts
Normal file
24
dictation_client/src/features/auth/selectors.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { RootState } from "app/store";
|
||||||
|
import { decodeToken } from "common/decodeToken";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
/**
|
||||||
|
* ログイン済みかどうかを判定する
|
||||||
|
* @param state RootState
|
||||||
|
*/
|
||||||
|
export const isAuthenticatedSelector = (state: RootState): boolean =>
|
||||||
|
state.auth.accessToken !== null;
|
||||||
|
|
||||||
|
// トークンが有効期限内かどうかを判定する
|
||||||
|
export const isTokenExpired = (state: RootState): boolean => {
|
||||||
|
if (state.auth.accessToken) {
|
||||||
|
const token = decodeToken(state.auth.accessToken);
|
||||||
|
if (token) {
|
||||||
|
const { exp } = token;
|
||||||
|
const now = DateTime.local().toSeconds();
|
||||||
|
if (now <= exp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
@ -49,11 +49,5 @@ export const initialConfig = (): ConfigurationParameters => {
|
|||||||
config.basePath = window.location.origin;
|
config.basePath = window.location.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
// localStorageからAccessTokenを復元する
|
|
||||||
const accessToken = loadAccessToken();
|
|
||||||
if (accessToken) {
|
|
||||||
config.accessToken = accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
export * from "./loginSlice";
|
export * from "./loginSlice";
|
||||||
export * from "./state";
|
export * from "./state";
|
||||||
export * from "./selectors";
|
|
||||||
export * from "./operations";
|
export * from "./operations";
|
||||||
export * from "./types";
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { createSlice } from "@reduxjs/toolkit";
|
import { createSlice } from "@reduxjs/toolkit";
|
||||||
import { loginAsync } from "./operations";
|
|
||||||
import { LoginState } from "./state";
|
import { LoginState } from "./state";
|
||||||
|
|
||||||
const initialState: LoginState = {
|
const initialState: LoginState = {
|
||||||
@ -12,16 +11,8 @@ export const loginSlice = createSlice({
|
|||||||
name: "login",
|
name: "login",
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {},
|
reducers: {},
|
||||||
extraReducers: (builder) => {
|
extraReducers: () => {
|
||||||
builder.addCase(loginAsync.pending, (state) => {
|
//
|
||||||
state.apps.loadState = "Loading";
|
|
||||||
});
|
|
||||||
builder.addCase(loginAsync.fulfilled, (state) => {
|
|
||||||
state.apps.loadState = "Loaded";
|
|
||||||
});
|
|
||||||
builder.addCase(loginAsync.rejected, (state) => {
|
|
||||||
state.apps.loadState = "LoadError";
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export const loginAsync = createAsyncThunk<
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return data;
|
return {};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return thunkApi.rejectWithValue({});
|
return thunkApi.rejectWithValue({});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
import { LoadState } from "./types";
|
|
||||||
|
|
||||||
export interface LoginState {
|
export interface LoginState {
|
||||||
apps: Apps;
|
apps: Apps;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Apps {
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||||
loadState: LoadState;
|
export interface Apps {}
|
||||||
}
|
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
|
import { UpdateTokenTimer } from "components/auth/updateTokenTimer";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export const AuthErrorPage = (): JSX.Element => (
|
export const AuthErrorPage = (): JSX.Element => (
|
||||||
<div>
|
<div>
|
||||||
|
<UpdateTokenTimer />
|
||||||
<p>ログインに失敗しました</p>
|
<p>ログインに失敗しました</p>
|
||||||
<br />
|
<br />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
import { InteractionStatus, SilentRequest } from "@azure/msal-browser";
|
import { InteractionStatus, SilentRequest } from "@azure/msal-browser";
|
||||||
import { useMsal } from "@azure/msal-react";
|
import { useIsAuthenticated, useMsal } from "@azure/msal-react";
|
||||||
import { AppDispatch } from "app/store";
|
import { AppDispatch } from "app/store";
|
||||||
import { loginAsync } from "features/login";
|
import { loginAsync } from "features/login";
|
||||||
import React, { useCallback, useEffect } from "react";
|
import React, { useCallback, useEffect } from "react";
|
||||||
import { useDispatch } from "react-redux";
|
import { useDispatch } from "react-redux";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
const LoginPage: React.FC = () => {
|
const LoginPage: React.FC = (): JSX.Element => {
|
||||||
const { accounts, instance, inProgress } = useMsal();
|
const { accounts, instance, inProgress } = useMsal();
|
||||||
const dispatch: AppDispatch = useDispatch();
|
const dispatch: AppDispatch = useDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isAuthenticated = useIsAuthenticated();
|
||||||
const login = useCallback(async () => {
|
const login = useCallback(async () => {
|
||||||
const request: SilentRequest = {
|
const request: SilentRequest = {
|
||||||
scopes: ["openid"],
|
scopes: ["openid"],
|
||||||
@ -32,10 +32,18 @@ const LoginPage: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// B2CからリダイレクトされてB2Cへのログインが完了してからAPIを呼ぶ
|
// B2CからリダイレクトされてB2Cへのログインが完了してからAPIを呼ぶ
|
||||||
if (inProgress === InteractionStatus.None) {
|
if (isAuthenticated && inProgress === InteractionStatus.None) {
|
||||||
login();
|
login();
|
||||||
}
|
}
|
||||||
}, [accounts, dispatch, inProgress, instance, login]);
|
}, [
|
||||||
|
accounts,
|
||||||
|
dispatch,
|
||||||
|
inProgress,
|
||||||
|
instance,
|
||||||
|
isAuthenticated,
|
||||||
|
login,
|
||||||
|
navigate,
|
||||||
|
]);
|
||||||
|
|
||||||
return <h3>loading ...</h3>;
|
return <h3>loading ...</h3>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,14 +1,23 @@
|
|||||||
import { useMsal } from "@azure/msal-react";
|
import { useMsal } from "@azure/msal-react";
|
||||||
|
import { AppDispatch } from "app/store";
|
||||||
|
import { UpdateTokenTimer } from "components/auth/updateTokenTimer";
|
||||||
|
import { clearToken } from "features/auth";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useDispatch } from "react-redux";
|
||||||
|
|
||||||
const SamplePage: React.FC = () => {
|
const SamplePage: React.FC = (): JSX.Element => {
|
||||||
const { instance } = useMsal();
|
const { instance } = useMsal();
|
||||||
|
const dispatch: AppDispatch = useDispatch();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<UpdateTokenTimer />
|
||||||
<h1>hello world!!</h1>
|
<h1>hello world!!</h1>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => instance.logout({ postLogoutRedirectUri: "/" })}
|
onClick={() => {
|
||||||
|
instance.logout({ postLogoutRedirectUri: "/" });
|
||||||
|
dispatch(clearToken());
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
sign out
|
sign out
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useMsal } from "@azure/msal-react";
|
import { useMsal } from "@azure/msal-react";
|
||||||
import { loginRequest } from "common/auth/msalConfig";
|
import { loginRequest } from "common/msalConfig";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
const TopPage: React.FC = () => {
|
const TopPage: React.FC = (): JSX.Element => {
|
||||||
const { instance } = useMsal();
|
const { instance } = useMsal();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user