Merged PR 472: 画面作成(利用規約同意画面)

## 概要
[Task2802: 画面作成(利用規約同意画面)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/2802)

- 何をどう変更したか、追加したライブラリなど
  - 利用規約同意画面の実装を行いました
- このPull Requestでの対象/対象外
  - api.tsおよびstyles
- 影響範囲(他の機能にも影響があるか)
  - ありません

## レビューポイント
- 特にレビューしてほしい箇所
  - URLの妥当性(動作確認のため別タスクで追加していますが、内容は本タスクで見てほしいです)
     違和感ないか確認お願いします。
         <Route path="/accept-to-use" element={<AcceptToUsePage />} />
  - 各処理のエラーハンドリングについて

## UIの変更
- Before/Afterのスクショなど
- スクショ置き場
https://ndstokyo.sharepoint.com/:f:/r/sites/Piranha/Shared%20Documents/General/OMDS/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88/Task2802?csf=1&web=1&e=otF5YX

## 動作確認状況
- ローカルで確認済

## 補足
- 相談、参考資料などがあれば
This commit is contained in:
水本 祐希 2023-10-17 07:15:49 +00:00
parent 74bf434786
commit 7196491cf0
18 changed files with 494 additions and 49 deletions

View File

@ -21,7 +21,7 @@ import WorkflowPage from "pages/WorkflowPage";
import TypistGroupSettingPage from "pages/TypistGroupSettingPage"; import TypistGroupSettingPage from "pages/TypistGroupSettingPage";
import WorktypeIdSettingPage from "pages/WorkTypeIdSettingPage"; import WorktypeIdSettingPage from "pages/WorkTypeIdSettingPage";
import AccountPage from "pages/AccountPage"; import AccountPage from "pages/AccountPage";
import AcceptToUsePage from "pages/AcceptToUsePage"; import AcceptToUsePage from "pages/TermsPage";
import { TemplateFilePage } from "pages/TemplateFilePage"; import { TemplateFilePage } from "pages/TemplateFilePage";
import { AccountDeleteSuccess } from "pages/AccountPage/accountDeleteSuccess"; import { AccountDeleteSuccess } from "pages/AccountPage/accountDeleteSuccess";
@ -35,7 +35,7 @@ const AppRouter: React.FC = () => (
path="/signup" path="/signup"
element={<SignupPage completeTo="/signup/complete" />} element={<SignupPage completeTo="/signup/complete" />}
/> />
<Route path="/accept-to-use" element={<AcceptToUsePage />} /> <Route path="/terms" element={<AcceptToUsePage />} />
<Route path="/signup/complete" element={<SignupCompletePage />} /> <Route path="/signup/complete" element={<SignupCompletePage />} />
<Route path="/mail-confirm/" element={<VerifyPage />} /> <Route path="/mail-confirm/" element={<VerifyPage />} />
<Route path="/mail-confirm/user" element={<UserVerifyPage />} /> <Route path="/mail-confirm/user" element={<UserVerifyPage />} />

View File

@ -18,6 +18,7 @@ import worktype from "features/workflow/worktype/worktypeSlice";
import account from "features/account/accountSlice"; import account from "features/account/accountSlice";
import template from "features/workflow/template/templateSlice"; import template from "features/workflow/template/templateSlice";
import workflow from "features/workflow/workflowSlice"; import workflow from "features/workflow/workflowSlice";
import terms from "features/terms/termsSlice";
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
@ -40,6 +41,7 @@ export const store = configureStore({
account, account,
template, template,
workflow, workflow,
terms,
}, },
}); });

View File

@ -62,3 +62,16 @@ export const isIdToken = (arg: any): arg is IdToken => {
return true; return true;
}; };
export const getIdTokenFromLocalStorage = (
localStorageKeyforIdToken: string
): string | null => {
const idTokenString = localStorage.getItem(localStorageKeyforIdToken);
if (idTokenString) {
const idTokenObject = JSON.parse(idTokenString);
if (isIdToken(idTokenObject)) {
return idTokenObject.secret;
}
}
return null;
};

View File

@ -0,0 +1,8 @@
/**
*
* @const {string[]}
*/
export const TERMS_DOCUMENT_TYPE = {
DPA: "DPA",
EULA: "EULA",
} as const;

View File

@ -0,0 +1,4 @@
export * from "./termsSlice";
export * from "./state";
export * from "./operations";
export * from "./selectors";

View File

@ -0,0 +1,158 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import type { RootState } from "app/store";
import { ErrorObject, createErrorObject } from "common/errors";
import { getTranslationID } from "translation";
import { openSnackbar } from "features/ui/uiSlice";
import { getIdTokenFromLocalStorage } from "common/token";
import { TIERS } from "components/auth/constants";
import {
UsersApi,
GetAccountInfoMinimalAccessResponse,
AccountsApi,
TermsApi,
GetTermsInfoResponse,
} from "../../api/api";
import { Configuration } from "../../api/configuration";
export const getAccountInfoMinimalAccessAsync = createAsyncThunk<
GetAccountInfoMinimalAccessResponse,
{
localStorageKeyforIdToken: string;
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("accept/getAccountInfoMinimalAccessAsync", async (args, thunkApi) => {
const { localStorageKeyforIdToken } = args;
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration, accessToken } = state.auth;
const config = new Configuration(configuration);
const accountApi = new AccountsApi(config);
try {
// IDトークンの取得
const idToken = getIdTokenFromLocalStorage(localStorageKeyforIdToken);
// IDトークンが取得できない場合エラーとする
if (!idToken) {
throw new Error("Unable to retrieve the ID token.");
}
const res = await accountApi.getAccountInfoMinimalAccess(
{ idToken },
{
headers: { authorization: `Bearer ${accessToken}` },
}
);
return res.data;
} catch (e) {
const error = createErrorObject(e);
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});
export const getTermsInfoAsync = createAsyncThunk<
GetTermsInfoResponse,
void,
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("accept/getTermsInfoAsync", async (_args, thunkApi) => {
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration, accessToken } = state.auth;
const config = new Configuration(configuration);
const termsApi = new TermsApi(config);
try {
const termsInfo = await termsApi.getTermsInfo({
headers: { authorization: `Bearer ${accessToken}` },
});
return termsInfo.data;
} catch (e) {
// e ⇒ errorObjectに変換"
const error = createErrorObject(e);
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});
export const updateAcceptedVersionAsync = createAsyncThunk<
{
/* Empty Object */
},
{
tier: number;
localStorageKeyforIdToken: string;
updateAccceptVersions: {
acceptedVerDPA: string;
acceptedVerEULA: string;
};
},
{
// rejectした時の返却値の型
rejectValue: {
error: ErrorObject;
};
}
>("accept/UpdateAcceptedVersionAsync", async (args, thunkApi) => {
const { tier, localStorageKeyforIdToken, updateAccceptVersions } = args;
// apiのConfigurationを取得する
const { getState } = thunkApi;
const state = getState() as RootState;
const { configuration, accessToken } = state.auth;
const config = new Configuration(configuration);
const userApi = new UsersApi(config);
try {
// IDトークンの取得
const idToken = getIdTokenFromLocalStorage(localStorageKeyforIdToken);
// IDトークンが取得できない場合エラーとする
if (!idToken) {
throw new Error("Unable to retrieve the ID token.");
}
await userApi.updateAcceptedVersion(
{
idToken,
acceptedEULAVersion: updateAccceptVersions.acceptedVerEULA,
acceptedDPAVersion: !(TIERS.TIER5 === tier.toString())
? updateAccceptVersions.acceptedVerDPA
: undefined,
},
{
headers: { authorization: `Bearer ${accessToken}` },
}
);
return {};
} catch (e) {
const error = createErrorObject(e);
thunkApi.dispatch(
openSnackbar({
level: "error",
message: getTranslationID("common.message.internalServerError"),
})
);
return thunkApi.rejectWithValue({ error });
}
});

View File

@ -0,0 +1,20 @@
import { RootState } from "app/store";
import { TERMS_DOCUMENT_TYPE } from "features/terms/constants";
export const selectTermVersions = (state: RootState) => {
const { termsInfo } = state.terms.domain;
const acceptedVerDPA =
termsInfo.find(
(termInfo) => termInfo.documentType === TERMS_DOCUMENT_TYPE.DPA
)?.version || "";
const acceptedVerEULA =
termsInfo.find(
(termInfo) => termInfo.documentType === TERMS_DOCUMENT_TYPE.EULA
)?.version || "";
return { acceptedVerDPA, acceptedVerEULA };
};
export const selectTier = (state: RootState) => state.terms.domain.tier;

View File

@ -0,0 +1,15 @@
import { TermInfo } from "../../api/api";
export interface AcceptState {
domain: Domain;
apps: Apps;
}
export interface Domain {
tier: number;
termsInfo: TermInfo[];
}
export interface Apps {
isLoading: boolean;
}

View File

@ -0,0 +1,64 @@
import { createSlice } from "@reduxjs/toolkit";
import { AcceptState } from "./state";
import {
getAccountInfoMinimalAccessAsync,
getTermsInfoAsync,
updateAcceptedVersionAsync,
} from "./operations";
const initialState: AcceptState = {
domain: {
tier: 0,
termsInfo: [
{
documentType: "",
version: "",
},
],
},
apps: {
isLoading: false,
},
};
export const termsSlice = createSlice({
name: "terms",
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(getAccountInfoMinimalAccessAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(
getAccountInfoMinimalAccessAsync.fulfilled,
(state, actions) => {
state.apps.isLoading = false;
state.domain.tier = actions.payload.tier;
}
);
builder.addCase(getAccountInfoMinimalAccessAsync.rejected, (state) => {
state.apps.isLoading = false;
});
builder.addCase(getTermsInfoAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(getTermsInfoAsync.fulfilled, (state, actions) => {
state.apps.isLoading = false;
state.domain.termsInfo = actions.payload.termsInfo;
});
builder.addCase(getTermsInfoAsync.rejected, (state) => {
state.apps.isLoading = false;
});
builder.addCase(updateAcceptedVersionAsync.pending, (state) => {
state.apps.isLoading = true;
});
builder.addCase(updateAcceptedVersionAsync.fulfilled, (state) => {
state.apps.isLoading = false;
});
builder.addCase(updateAcceptedVersionAsync.rejected, (state) => {
state.apps.isLoading = false;
});
},
});
export default termsSlice.reducer;

View File

@ -1,25 +0,0 @@
/* 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

@ -30,7 +30,7 @@ const LoginPage: React.FC = (): JSX.Element => {
if (isErrorObject(payload)) { if (isErrorObject(payload)) {
// 未同意の規約がある場合は利用規約同意画面に遷移する // 未同意の規約がある場合は利用規約同意画面に遷移する
if (payload.error.code === "E010209") { if (payload.error.code === "E010209") {
navigate("/accept-to-use"); navigate("/terms");
return; return;
} }
} }

View File

@ -268,10 +268,9 @@ const SignupInput: React.FC = (): JSX.Element => {
/> />
{isPushCreateButton && hasErrorEmptyAdminName && ( {isPushCreateButton && hasErrorEmptyAdminName && (
<span className={styles.formError}> <span className={styles.formError}>
{" "} {` ${t(
{t(
getTranslationID("signupPage.message.inputEmptyError") getTranslationID("signupPage.message.inputEmptyError")
)} )}`}
</span> </span>
)} )}
</dd> </dd>
@ -373,8 +372,9 @@ const SignupInput: React.FC = (): JSX.Element => {
}} }}
> >
{t(getTranslationID("signupPage.label.termsLink"))} {t(getTranslationID("signupPage.label.termsLink"))}
</a>{" "} </a>
{t(getTranslationID("signupPage.label.termsLinkFor"))} <br /> {` ${t(getTranslationID("signupPage.label.termsLinkFor"))} `}
<br />
<label htmlFor="check-box"> <label htmlFor="check-box">
<input <input
id="check-box" id="check-box"

View File

@ -0,0 +1,188 @@
/* eslint-disable jsx-a11y/label-has-associated-control */
import { useTranslation } from "react-i18next";
import { getTranslationID } from "translation";
import Header from "components/header";
import { AppDispatch } from "app/store";
import { useDispatch, useSelector } from "react-redux";
import styles from "styles/app.module.scss";
import { TIERS } from "components/auth/constants";
import Footer from "components/footer";
import { useCallback, useEffect, useState } from "react";
import {
getAccountInfoMinimalAccessAsync,
getTermsInfoAsync,
updateAcceptedVersionAsync,
selectTier,
selectTermVersions,
} from "features//terms";
import { selectLocalStorageKeyforIdToken } from "features/login";
import { useNavigate } from "react-router-dom";
const TermsPage: React.FC = (): JSX.Element => {
const [t] = useTranslation();
const dispatch: AppDispatch = useDispatch();
const navigate = useNavigate();
const updateAccceptVersions = useSelector(selectTermVersions);
const localStorageKeyforIdToken = useSelector(
selectLocalStorageKeyforIdToken
);
const tier = useSelector(selectTier);
const [isCheckedEula, setIsCheckedEula] = useState(false);
const [isCheckedDpa, setIsCheckedDpa] = useState(false);
const [isClickedEulaLink, setIsClickedEulaLink] = useState(false);
const [isClickedDpaLink, setIsClickedDpaLink] = useState(false);
// 画面起動時
useEffect(() => {
dispatch(getTermsInfoAsync());
if (localStorageKeyforIdToken) {
dispatch(getAccountInfoMinimalAccessAsync({ localStorageKeyforIdToken }));
} else {
// ログイン画面を経由していないため、トップページに遷移する
navigate("/");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ユーザーが第5階層であるかどうかを判定する(アクセストークンから自分の階層を取得できないので自前で作成)
const isTier5 = () => TIERS.TIER5.includes(tier.toString());
// ボタン押下可否判定ロジック
const canClickButton = () => {
if (isTier5()) {
return isCheckedEula;
}
return isCheckedEula && isCheckedDpa;
};
// ボタン押下時処理
const onAcceptTermsOfUse = useCallback(async () => {
if (
localStorageKeyforIdToken &&
updateAccceptVersions.acceptedVerDPA !== "" &&
updateAccceptVersions.acceptedVerEULA !== ""
) {
const { meta } = await dispatch(
updateAcceptedVersionAsync({
tier,
localStorageKeyforIdToken,
updateAccceptVersions,
})
);
// 同意済バージョンが更新できたら、再度トークン生成を行う
if (meta.requestStatus === "fulfilled") {
navigate("/login");
}
}
}, [
navigate,
localStorageKeyforIdToken,
updateAccceptVersions,
tier,
dispatch,
]);
return (
<div className={styles.wrap}>
<Header />
<main className={styles.main}>
<div className={styles.mainSmall}>
<div>
<h1 className={`${styles.marginBtm1} ${styles.alignCenter}`}>
{t(getTranslationID("termsPage.label.title"))}
</h1>
</div>
<section className={styles.form}>
<form action="" name="" method="">
<dl className={`${styles.formList} ${styles.hasbg}`}>
<dt className={styles.formTitle} />
<dd className={`${styles.full} ${styles.alignCenter}`}>
<p>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<a
href="/" /* TODO Eula用の利用規約リンクが決定したら設定を行う */
target="_blank"
className={styles.linkTx}
onClick={() => setIsClickedEulaLink(true)}
>
{t(getTranslationID("termsPage.label.linkOfEula"))}
</a>
{` ${t(getTranslationID("termsPage.label.forOdds"))}`}
</p>
<p>
<label>
<input
type="checkbox"
checked={isCheckedEula}
className={styles.formCheck}
value=""
onChange={(e) => setIsCheckedEula(e.target.checked)}
disabled={!isClickedEulaLink}
/>
{t(
getTranslationID("termsPage.label.checkBoxForConsent")
)}
</label>
</p>
</dd>
{/* 第五階層以外の場合はEulaのリンクをあわせて表示する */}
{!isTier5() && (
<dd className={`${styles.full} ${styles.alignCenter}`}>
<p>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<a
href="/" /* TODO Dpa用の利用規約リンクが決定したら設定を行う */
target="_blank"
className={styles.linkTx}
onClick={() => setIsClickedDpaLink(true)}
>
{t(getTranslationID("termsPage.label.linkOfDpa"))}
</a>
{` ${t(getTranslationID("termsPage.label.forOdds"))}`}
</p>
<p>
<label>
<input
type="checkbox"
checked={isCheckedDpa}
className={styles.formCheck}
value=""
onChange={(e) => setIsCheckedDpa(e.target.checked)}
disabled={!isClickedDpaLink}
/>
{t(
getTranslationID("termsPage.label.checkBoxForConsent")
)}
</label>
</p>
</dd>
)}
<dd className={`${styles.full} ${styles.alignCenter}`}>
<p>
<input
type="button"
name="submit"
value={t(getTranslationID("termsPage.label.button"))}
className={`${styles.formSubmit} ${styles.marginBtm1} ${
canClickButton() ? styles.isActive : ""
}`}
onClick={onAcceptTermsOfUse}
/>
</p>
</dd>
</dl>
</form>
</section>
</div>
</main>
<Footer />
</div>
);
};
export default TermsPage;

View File

@ -2266,8 +2266,7 @@ tr.isSelected .menuInTable li a.isDisable {
} }
.formChange ul.chooseMember li input + label:hover, .formChange ul.chooseMember li input + label:hover,
.formChange ul.holdMember li input + label:hover { .formChange ul.holdMember li input + label:hover {
background: #e6e6e6 url(../assets/images/arrow_circle_left.svg) no-repeat left background: #e6e6e6 url(../assets/images/arrow_circle_left.svg) no-repeat left center;
center;
background-size: 1.3rem; background-size: 1.3rem;
} }
.formChange ul.chooseMember li input:checked + label, .formChange ul.chooseMember li input:checked + label,
@ -2278,8 +2277,8 @@ tr.isSelected .menuInTable li a.isDisable {
} }
.formChange ul.chooseMember li input:checked + label:hover, .formChange ul.chooseMember li input:checked + label:hover,
.formChange ul.holdMember li input:checked + label:hover { .formChange ul.holdMember li input:checked + label:hover {
background: #e6e6e6 url(../assets/images/arrow_circle_right.svg) no-repeat background: #e6e6e6 url(../assets/images/arrow_circle_right.svg) no-repeat right
right center; center;
background-size: 1.3rem; background-size: 1.3rem;
} }
.formChange > p { .formChange > p {
@ -2432,8 +2431,7 @@ tr.isSelected .menuInTable li a.isDisable {
} }
.formChange ul.chooseMember li input + label:hover, .formChange ul.chooseMember li input + label:hover,
.formChange ul.holdMember li input + label:hover { .formChange ul.holdMember li input + label:hover {
background: #e6e6e6 url(../assets/images/arrow_circle_left.svg) no-repeat left background: #e6e6e6 url(../assets/images/arrow_circle_left.svg) no-repeat left center;
center;
background-size: 1.3rem; background-size: 1.3rem;
} }
.formChange ul.chooseMember li input:checked + label, .formChange ul.chooseMember li input:checked + label,
@ -2444,8 +2442,8 @@ tr.isSelected .menuInTable li a.isDisable {
} }
.formChange ul.chooseMember li input:checked + label:hover, .formChange ul.chooseMember li input:checked + label:hover,
.formChange ul.holdMember li input:checked + label:hover { .formChange ul.holdMember li input:checked + label:hover {
background: #e6e6e6 url(../assets/images/arrow_circle_right.svg) no-repeat background: #e6e6e6 url(../assets/images/arrow_circle_right.svg) no-repeat right
right center; center;
background-size: 1.3rem; background-size: 1.3rem;
} }
.formChange > p { .formChange > p {

View File

@ -500,7 +500,7 @@
"backToTopPageLink": "(de)Back to TOP Page" "backToTopPageLink": "(de)Back to TOP Page"
} }
}, },
"acceptToUsePage": { "termsPage": {
"label": { "label": {
"title": "(de)Terms of Use has updated. Please confirm again.", "title": "(de)Terms of Use has updated. Please confirm again.",
"linkOfEula": "(de)Click here to read the terms of use.", "linkOfEula": "(de)Click here to read the terms of use.",
@ -510,4 +510,4 @@
"button": "(de)Continue" "button": "(de)Continue"
} }
} }
} }

View File

@ -500,7 +500,7 @@
"backToTopPageLink": "Back to TOP Page" "backToTopPageLink": "Back to TOP Page"
} }
}, },
"acceptToUsePage": { "termsPage": {
"label": { "label": {
"title": "Terms of Use has updated. Please confirm again.", "title": "Terms of Use has updated. Please confirm again.",
"linkOfEula": "Click here to read the terms of use.", "linkOfEula": "Click here to read the terms of use.",
@ -510,4 +510,4 @@
"button": "Continue" "button": "Continue"
} }
} }
} }

View File

@ -500,7 +500,7 @@
"backToTopPageLink": "(es)Back to TOP Page" "backToTopPageLink": "(es)Back to TOP Page"
} }
}, },
"acceptToUsePage": { "termsPage": {
"label": { "label": {
"title": "(es)Terms of Use has updated. Please confirm again.", "title": "(es)Terms of Use has updated. Please confirm again.",
"linkOfEula": "(es)Click here to read the terms of use.", "linkOfEula": "(es)Click here to read the terms of use.",
@ -510,4 +510,4 @@
"button": "(es)Continue" "button": "(es)Continue"
} }
} }
} }

View File

@ -500,7 +500,7 @@
"backToTopPageLink": "(fr)Back to TOP Page" "backToTopPageLink": "(fr)Back to TOP Page"
} }
}, },
"acceptToUsePage": { "termsPage": {
"label": { "label": {
"title": "(fr)Terms of Use has updated. Please confirm again.", "title": "(fr)Terms of Use has updated. Please confirm again.",
"linkOfEula": "(fr)Click here to read the terms of use.", "linkOfEula": "(fr)Click here to read the terms of use.",
@ -510,4 +510,4 @@
"button": "(fr)Continue" "button": "(fr)Continue"
} }
} }
} }