UserGroupDetailPage.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673
  1. import React, { type JSX, useCallback, useEffect, useState } from 'react';
  2. import dynamic from 'next/dynamic';
  3. import Link from 'next/link';
  4. import { useRouter } from 'next/router';
  5. import {
  6. GroupType,
  7. getIdStringForRef,
  8. type IGrantedGroup,
  9. type IUserGroup,
  10. type IUserGroupHasId,
  11. } from '@growi/core';
  12. import { objectIdUtils } from '@growi/core/dist/utils';
  13. import { useAtomValue } from 'jotai';
  14. import { useTranslation } from 'next-i18next';
  15. import {
  16. apiv3Delete,
  17. apiv3Get,
  18. apiv3Post,
  19. apiv3Put,
  20. } from '~/client/util/apiv3-client';
  21. import { toastError, toastSuccess } from '~/client/util/toastr';
  22. import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
  23. import type {
  24. PageActionOnGroupDelete,
  25. SearchType,
  26. } from '~/interfaces/user-group';
  27. import { SearchTypes } from '~/interfaces/user-group';
  28. import { isAclEnabledAtom } from '~/states/server-configurations';
  29. import { useUpdateUserGroupConfirmModalActions } from '~/states/ui/modal/update-user-group-confirm';
  30. import {
  31. useSWRxSelectableChildUserGroups,
  32. useSWRxSelectableParentUserGroups,
  33. useSWRxUserGroupPages,
  34. } from '~/stores/user-group';
  35. import loggerFactory from '~/utils/logger';
  36. import {
  37. useAncestorUserGroups,
  38. useChildUserGroupList,
  39. useUserGroup,
  40. useUserGroupRelationList,
  41. useUserGroupRelations,
  42. } from './use-user-group-resource';
  43. import styles from './UserGroupDetailPage.module.scss';
  44. const logger = loggerFactory('growi:services:AdminCustomizeContainer');
  45. const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), {
  46. ssr: false,
  47. });
  48. const UserGroupUserTable = dynamic(
  49. () => import('./UserGroupUserTable').then((mod) => mod.UserGroupUserTable),
  50. { ssr: false },
  51. );
  52. const UserGroupUserModal = dynamic(() => import('./UserGroupUserModal'), {
  53. ssr: false,
  54. });
  55. const UserGroupDeleteModal = dynamic(
  56. () =>
  57. import('../UserGroup/UserGroupDeleteModal').then(
  58. (mod) => mod.UserGroupDeleteModal,
  59. ),
  60. { ssr: false },
  61. );
  62. const UserGroupDropdown = dynamic(
  63. () =>
  64. import('../UserGroup/UserGroupDropdown').then(
  65. (mod) => mod.UserGroupDropdown,
  66. ),
  67. { ssr: false },
  68. );
  69. const UserGroupForm = dynamic(
  70. () => import('../UserGroup/UserGroupForm').then((mod) => mod.UserGroupForm),
  71. { ssr: false },
  72. );
  73. const UserGroupModal = dynamic(
  74. () => import('../UserGroup/UserGroupModal').then((mod) => mod.UserGroupModal),
  75. { ssr: false },
  76. );
  77. const UserGroupTable = dynamic(
  78. () => import('../UserGroup/UserGroupTable').then((mod) => mod.UserGroupTable),
  79. { ssr: false },
  80. );
  81. const UpdateParentConfirmModal = dynamic(
  82. () =>
  83. import('./UpdateParentConfirmModal').then(
  84. (mod) => mod.UpdateParentConfirmModal,
  85. ),
  86. { ssr: false },
  87. );
  88. type Props = {
  89. userGroupId: string;
  90. isExternalGroup: boolean;
  91. };
  92. const UserGroupDetailPage = (props: Props): JSX.Element => {
  93. const { t } = useTranslation('admin');
  94. const router = useRouter();
  95. const { userGroupId: currentUserGroupId, isExternalGroup } = props;
  96. const { data: currentUserGroup } = useUserGroup(
  97. currentUserGroupId,
  98. isExternalGroup,
  99. );
  100. const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
  101. const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
  102. const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
  103. const [selectedUserGroup, setSelectedUserGroup] = useState<
  104. IUserGroupHasId | undefined
  105. >(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
  106. const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
  107. const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
  108. const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
  109. const [isUserGroupUserModalShown, setIsUserGroupUserModalShown] =
  110. useState<boolean>(false);
  111. const isLoading = currentUserGroup === undefined;
  112. const notExistsUerGroup = !isLoading && currentUserGroup == null;
  113. useEffect(() => {
  114. if (
  115. !objectIdUtils.isValidObjectId(currentUserGroupId) ||
  116. notExistsUerGroup
  117. ) {
  118. router.push('/admin/user-groups');
  119. }
  120. }, [currentUserGroupId, notExistsUerGroup, router]);
  121. /*
  122. * Fetch
  123. */
  124. const { data: userGroupPages } = useSWRxUserGroupPages(
  125. currentUserGroupId,
  126. 10,
  127. 0,
  128. );
  129. const { data: userGroupRelations, mutate: mutateUserGroupRelations } =
  130. useUserGroupRelations(currentUserGroupId, isExternalGroup);
  131. const {
  132. data: childUserGroupsList,
  133. mutate: mutateChildUserGroups,
  134. updateChild,
  135. } = useChildUserGroupList(currentUserGroupId, isExternalGroup);
  136. const childUserGroups =
  137. childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
  138. const childUserGroupsForDeleteModal: IGrantedGroup[] = childUserGroups.map(
  139. (group) => {
  140. const groupType = isExternalGroup
  141. ? GroupType.externalUserGroup
  142. : GroupType.userGroup;
  143. return { item: group, type: groupType };
  144. },
  145. );
  146. const grandChildUserGroups =
  147. childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
  148. const childUserGroupIds = childUserGroups.map((group) => group._id);
  149. const { data: userGroupRelationList, mutate: mutateUserGroupRelationList } =
  150. useUserGroupRelationList(childUserGroupIds, isExternalGroup);
  151. const childUserGroupRelations =
  152. userGroupRelationList != null ? userGroupRelationList : [];
  153. const {
  154. data: selectableParentUserGroups,
  155. mutate: mutateSelectableParentUserGroups,
  156. } = useSWRxSelectableParentUserGroups(
  157. isExternalGroup ? null : currentUserGroupId,
  158. );
  159. const {
  160. data: selectableChildUserGroups,
  161. mutate: mutateSelectableChildUserGroups,
  162. } = useSWRxSelectableChildUserGroups(
  163. isExternalGroup ? null : currentUserGroupId,
  164. );
  165. const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } =
  166. useAncestorUserGroups(currentUserGroupId, isExternalGroup);
  167. const isAclEnabled = useAtomValue(isAclEnabledAtom);
  168. const { open: openUpdateParentConfirmModal } =
  169. useUpdateUserGroupConfirmModalActions();
  170. const parentUserGroup = (() => {
  171. if (isExternalGroup) {
  172. return ancestorUserGroups != null && ancestorUserGroups.length > 1
  173. ? ancestorUserGroups[ancestorUserGroups.length - 2]
  174. : undefined;
  175. }
  176. return selectableParentUserGroups?.find(
  177. (selectableParentUserGroup) =>
  178. selectableParentUserGroup._id === currentUserGroup?.parent,
  179. );
  180. })();
  181. /*
  182. * Function
  183. */
  184. const toggleIsAlsoMailSearched = useCallback(() => {
  185. setAlsoMailSearched((prev) => !prev);
  186. }, []);
  187. const toggleAlsoNameSearched = useCallback(() => {
  188. setAlsoNameSearched((prev) => !prev);
  189. }, []);
  190. const switchSearchType = useCallback((searchType: SearchType) => {
  191. setSearchType(searchType);
  192. }, []);
  193. const updateUserGroup = useCallback(
  194. async (
  195. userGroup: IUserGroupHasId,
  196. update: IUserGroupHasId,
  197. forceUpdateParents: boolean,
  198. ) => {
  199. if (isExternalGroup) {
  200. await apiv3Put<{ userGroup: IExternalUserGroupHasId }>(
  201. `/external-user-groups/${userGroup._id}`,
  202. {
  203. description: update.description,
  204. },
  205. );
  206. } else {
  207. await apiv3Put<{ userGroup: IUserGroupHasId }>(
  208. `/user-groups/${userGroup._id}`,
  209. {
  210. name: update.name,
  211. description: update.description,
  212. parentId:
  213. update.parent != null ? getIdStringForRef(update.parent) : null,
  214. forceUpdateParents,
  215. },
  216. );
  217. }
  218. // mutate
  219. mutateChildUserGroups();
  220. mutateAncestorUserGroups();
  221. mutateSelectableChildUserGroups();
  222. mutateSelectableParentUserGroups();
  223. },
  224. [
  225. mutateAncestorUserGroups,
  226. mutateChildUserGroups,
  227. mutateSelectableChildUserGroups,
  228. mutateSelectableParentUserGroups,
  229. isExternalGroup,
  230. ],
  231. );
  232. const onSubmitUpdateGroup = useCallback(
  233. async (
  234. targetGroup: IUserGroupHasId,
  235. userGroupData: IUserGroupHasId,
  236. forceUpdateParents: boolean,
  237. ): Promise<void> => {
  238. try {
  239. await updateUserGroup(targetGroup, userGroupData, forceUpdateParents);
  240. toastSuccess(
  241. t('toaster.update_successed', {
  242. target: t('UserGroup'),
  243. ns: 'commons',
  244. }),
  245. );
  246. } catch {
  247. toastError(
  248. t('toaster.update_failed', { target: t('UserGroup'), ns: 'commons' }),
  249. );
  250. }
  251. },
  252. [t, updateUserGroup],
  253. );
  254. const onClickSubmitForm = useCallback(
  255. async (targetGroup: IUserGroupHasId, userGroupData: IUserGroupHasId) => {
  256. if (typeof userGroupData.parent === 'string') {
  257. toastError(t('Something went wrong. Please try again.'));
  258. logger.error('Something went wrong.');
  259. return;
  260. }
  261. const prevParentId =
  262. typeof targetGroup.parent === 'string'
  263. ? targetGroup.parent
  264. : targetGroup.parent?._id || null;
  265. const newParentId =
  266. typeof userGroupData.parent?._id === 'string'
  267. ? userGroupData.parent?._id
  268. : null;
  269. const shouldShowConfirmModal = prevParentId !== newParentId;
  270. if (shouldShowConfirmModal) {
  271. // show confirm modal before submiting
  272. await openUpdateParentConfirmModal(
  273. targetGroup,
  274. userGroupData,
  275. onSubmitUpdateGroup,
  276. );
  277. } else {
  278. // directly submit
  279. await onSubmitUpdateGroup(targetGroup, userGroupData, false);
  280. }
  281. },
  282. [t, openUpdateParentConfirmModal, onSubmitUpdateGroup],
  283. );
  284. const fetchApplicableUsers = useCallback(
  285. async (searchWord: string) => {
  286. const res = await apiv3Get(
  287. `/user-groups/${currentUserGroupId}/unrelated-users`,
  288. {
  289. searchWord,
  290. searchType,
  291. isAlsoMailSearched,
  292. isAlsoNameSearched,
  293. },
  294. );
  295. const { users } = res.data;
  296. return users;
  297. },
  298. [currentUserGroupId, searchType, isAlsoMailSearched, isAlsoNameSearched],
  299. );
  300. const addUserByUsername = useCallback(
  301. async (username: string) => {
  302. try {
  303. await apiv3Post(`/user-groups/${currentUserGroupId}/users/${username}`);
  304. setIsUserGroupUserModalShown(false);
  305. mutateUserGroupRelations();
  306. mutateUserGroupRelationList();
  307. } catch (err) {
  308. toastError(
  309. new Error(
  310. `Unable to add "${username}" from "${currentUserGroup?.name}"`,
  311. ),
  312. );
  313. }
  314. },
  315. [
  316. currentUserGroupId,
  317. mutateUserGroupRelationList,
  318. mutateUserGroupRelations,
  319. currentUserGroup?.name,
  320. ],
  321. );
  322. // Fix: invalid csrf token => https://redmine.weseek.co.jp/issues/102704
  323. const removeUserByUsername = useCallback(
  324. async (username: string) => {
  325. try {
  326. await apiv3Delete(
  327. `/user-groups/${currentUserGroupId}/users/${username}`,
  328. );
  329. toastSuccess(`Removed "${username}" from "${currentUserGroup?.name}"`);
  330. mutateUserGroupRelationList();
  331. } catch (err) {
  332. toastError(
  333. new Error(
  334. `Unable to remove "${username}" from "${currentUserGroup?.name}"`,
  335. ),
  336. );
  337. }
  338. },
  339. [currentUserGroupId, mutateUserGroupRelationList, currentUserGroup?.name],
  340. );
  341. const showUpdateModal = useCallback((group: IUserGroupHasId) => {
  342. setUpdateModalShown(true);
  343. setSelectedUserGroup(group);
  344. }, []);
  345. const hideUpdateModal = useCallback(() => {
  346. setUpdateModalShown(false);
  347. setSelectedUserGroup(undefined);
  348. }, []);
  349. const updateChildUserGroup = useCallback(
  350. async (userGroupData: IUserGroupHasId) => {
  351. try {
  352. updateChild(userGroupData);
  353. toastSuccess(
  354. t('toaster.update_successed', {
  355. target: t('UserGroup'),
  356. ns: 'commons',
  357. }),
  358. );
  359. hideUpdateModal();
  360. } catch (err) {
  361. toastError(err);
  362. }
  363. },
  364. [t, updateChild, hideUpdateModal],
  365. );
  366. const onClickAddExistingUserGroupButtonHandler = useCallback(
  367. async (selectedChild: IUserGroupHasId): Promise<void> => {
  368. // show confirm modal before submiting
  369. await openUpdateParentConfirmModal(
  370. selectedChild,
  371. {
  372. parent: currentUserGroupId,
  373. },
  374. onSubmitUpdateGroup,
  375. );
  376. },
  377. [openUpdateParentConfirmModal, currentUserGroupId, onSubmitUpdateGroup],
  378. );
  379. const showCreateModal = useCallback(() => {
  380. setCreateModalShown(true);
  381. }, []);
  382. const hideCreateModal = useCallback(() => {
  383. setCreateModalShown(false);
  384. }, []);
  385. const createChildUserGroup = useCallback(
  386. async (userGroupData: IUserGroup) => {
  387. try {
  388. await apiv3Post('/user-groups', {
  389. name: userGroupData.name,
  390. description: userGroupData.description,
  391. parentId: currentUserGroupId,
  392. });
  393. toastSuccess(
  394. t('toaster.update_successed', {
  395. target: t('UserGroup'),
  396. ns: 'commons',
  397. }),
  398. );
  399. // mutate
  400. mutateChildUserGroups();
  401. mutateSelectableChildUserGroups();
  402. mutateSelectableParentUserGroups();
  403. hideCreateModal();
  404. } catch (err) {
  405. toastError(err);
  406. }
  407. },
  408. [
  409. currentUserGroupId,
  410. t,
  411. mutateChildUserGroups,
  412. mutateSelectableChildUserGroups,
  413. mutateSelectableParentUserGroups,
  414. hideCreateModal,
  415. ],
  416. );
  417. const showDeleteModal = useCallback(async (group: IUserGroupHasId) => {
  418. setSelectedUserGroup(group);
  419. setDeleteModalShown(true);
  420. }, []);
  421. const hideDeleteModal = useCallback(() => {
  422. setSelectedUserGroup(undefined);
  423. setDeleteModalShown(false);
  424. }, []);
  425. const deleteChildUserGroupById = useCallback(
  426. async (
  427. deleteGroupId: string,
  428. actionName: PageActionOnGroupDelete,
  429. transferToUserGroup: IGrantedGroup | null,
  430. ) => {
  431. const url = isExternalGroup
  432. ? `/external-user-groups/${deleteGroupId}`
  433. : `/user-groups/${deleteGroupId}`;
  434. const transferToUserGroupId =
  435. transferToUserGroup != null
  436. ? getIdStringForRef(transferToUserGroup.item)
  437. : null;
  438. const transferToUserGroupType =
  439. transferToUserGroup != null ? transferToUserGroup.type : null;
  440. try {
  441. const res = await apiv3Delete(url, {
  442. actionName,
  443. transferToUserGroupId,
  444. transferToUserGroupType,
  445. });
  446. // sync
  447. await mutateChildUserGroups();
  448. setSelectedUserGroup(undefined);
  449. setDeleteModalShown(false);
  450. toastSuccess(`Deleted ${res.data.userGroups.length} groups.`);
  451. } catch (err) {
  452. toastError(new Error('Unable to delete the groups'));
  453. }
  454. },
  455. [mutateChildUserGroups, isExternalGroup],
  456. );
  457. const removeChildUserGroup = useCallback(
  458. async (userGroupData: IUserGroupHasId) => {
  459. try {
  460. await apiv3Put(`/user-groups/${userGroupData._id}`, {
  461. name: userGroupData.name,
  462. description: userGroupData.description,
  463. parentId: null,
  464. });
  465. toastSuccess(
  466. t('toaster.update_successed', {
  467. target: t('UserGroup'),
  468. ns: 'commons',
  469. }),
  470. );
  471. // mutate
  472. mutateChildUserGroups();
  473. mutateSelectableChildUserGroups();
  474. } catch (err) {
  475. toastError(err);
  476. throw err;
  477. }
  478. },
  479. [t, mutateChildUserGroups, mutateSelectableChildUserGroups],
  480. );
  481. /*
  482. * Dependencies
  483. */
  484. if (currentUserGroup == null || currentUserGroupId == null) {
  485. return <></>;
  486. }
  487. return (
  488. <div>
  489. <nav aria-label="breadcrumb">
  490. <ol className="breadcrumb">
  491. <li className="breadcrumb-item">
  492. <Link href="/admin/user-groups">
  493. {t('user_group_management.group_list')}
  494. </Link>
  495. </li>
  496. {ancestorUserGroups != null &&
  497. ancestorUserGroups.length > 0 &&
  498. ancestorUserGroups.map((ancestorUserGroup: IUserGroupHasId) => (
  499. <li
  500. key={ancestorUserGroup._id}
  501. className={`breadcrumb-item ${ancestorUserGroup._id === currentUserGroupId ? 'active' : ''}`}
  502. >
  503. {ancestorUserGroup._id === currentUserGroupId ? (
  504. <span>{ancestorUserGroup.name}</span>
  505. ) : (
  506. <Link
  507. href={{
  508. pathname: `/admin/user-group-detail/${ancestorUserGroup._id}`,
  509. query: { isExternalGroup: 'true' },
  510. }}
  511. >
  512. {ancestorUserGroup.name}
  513. </Link>
  514. )}
  515. </li>
  516. ))}
  517. </ol>
  518. </nav>
  519. <div className="mt-4 form-box">
  520. <UserGroupForm
  521. userGroup={currentUserGroup}
  522. parentUserGroup={parentUserGroup}
  523. selectableParentUserGroups={selectableParentUserGroups}
  524. submitButtonLabel={t('Update')}
  525. onSubmit={onClickSubmitForm}
  526. isExternalGroup={isExternalGroup}
  527. />
  528. </div>
  529. <h2 className="admin-setting-header mt-4">
  530. {t('user_group_management.user_list')}
  531. </h2>
  532. <UserGroupUserTable
  533. userGroupRelations={userGroupRelations}
  534. onClickPlusBtn={() => setIsUserGroupUserModalShown(true)}
  535. onClickRemoveUserBtn={removeUserByUsername}
  536. isExternalGroup={isExternalGroup}
  537. />
  538. <UserGroupUserModal
  539. isOpen={isUserGroupUserModalShown}
  540. userGroup={currentUserGroup}
  541. searchType={searchType}
  542. isAlsoMailSearched={isAlsoMailSearched}
  543. isAlsoNameSearched={isAlsoNameSearched}
  544. onClickAddUserBtn={addUserByUsername}
  545. onSearchApplicableUsers={fetchApplicableUsers}
  546. onSwitchSearchType={switchSearchType}
  547. onClose={() => setIsUserGroupUserModalShown(false)}
  548. onToggleIsAlsoMailSearched={toggleIsAlsoMailSearched}
  549. onToggleIsAlsoNameSearched={toggleAlsoNameSearched}
  550. />
  551. <h2 className="admin-setting-header mt-4">
  552. {t('user_group_management.child_group_list')}
  553. </h2>
  554. {!isExternalGroup && (
  555. <UserGroupDropdown
  556. selectableUserGroups={selectableChildUserGroups}
  557. onClickAddExistingUserGroupButton={
  558. onClickAddExistingUserGroupButtonHandler
  559. }
  560. onClickCreateUserGroupButton={showCreateModal}
  561. />
  562. )}
  563. <UserGroupModal
  564. userGroup={selectedUserGroup}
  565. buttonLabel={t('Update')}
  566. onClickSubmit={updateChildUserGroup}
  567. isShow={isUpdateModalShown}
  568. onHide={hideUpdateModal}
  569. isExternalGroup={isExternalGroup}
  570. />
  571. <UserGroupModal
  572. buttonLabel={t('Create')}
  573. onClickSubmit={createChildUserGroup}
  574. isShow={isCreateModalShown}
  575. onHide={hideCreateModal}
  576. />
  577. <UpdateParentConfirmModal />
  578. <UserGroupTable
  579. userGroups={childUserGroups}
  580. childUserGroups={grandChildUserGroups}
  581. isAclEnabled={isAclEnabled ?? false}
  582. onEdit={showUpdateModal}
  583. onRemove={removeChildUserGroup}
  584. onDelete={showDeleteModal}
  585. userGroupRelations={childUserGroupRelations}
  586. isExternalGroup={isExternalGroup}
  587. />
  588. <UserGroupDeleteModal
  589. userGroups={childUserGroupsForDeleteModal}
  590. deleteUserGroup={selectedUserGroup}
  591. onDelete={deleteChildUserGroupById}
  592. isShow={isDeleteModalShown}
  593. onHide={hideDeleteModal}
  594. />
  595. <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
  596. <div className={`page-list ${styles['page-list']}`}>
  597. <UserGroupPageList
  598. userGroupId={currentUserGroupId}
  599. relatedPages={userGroupPages}
  600. />
  601. </div>
  602. </div>
  603. );
  604. };
  605. export default UserGroupDetailPage;