AuditLogManagement.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. import type React from 'react';
  2. import type { FC } from 'react';
  3. import { useCallback, useRef, useState } from 'react';
  4. import { LoadingSpinner } from '@growi/ui/dist/components';
  5. import { format } from 'date-fns/format';
  6. import { useAtomValue } from 'jotai';
  7. import { useTranslation } from 'react-i18next';
  8. import type { IClearable } from '~/client/interfaces/clearable';
  9. import { toastError } from '~/client/util/toastr';
  10. import type { SupportedActionType } from '~/interfaces/activity';
  11. import { useGrowiAppIdForGrowiCloud, useGrowiCloudUri } from '~/states/global';
  12. import {
  13. auditLogAvailableActionsAtom,
  14. auditLogEnabledAtom,
  15. } from '~/states/server-configurations';
  16. import { useSWRxActivity } from '~/stores/activity';
  17. import PaginationWrapper from '../PaginationWrapper';
  18. import { ActivityTable } from './AuditLog/ActivityTable';
  19. import { AuditLogDisableMode } from './AuditLog/AuditLogDisableMode';
  20. import { AuditLogExportModal } from './AuditLog/AuditLogExportModal';
  21. import { AuditLogSettings } from './AuditLog/AuditLogSettings';
  22. import { DateRangePicker } from './AuditLog/DateRangePicker';
  23. import { SearchUsernameTypeahead } from './AuditLog/SearchUsernameTypeahead';
  24. import { SelectActionDropdown } from './AuditLog/SelectActionDropdown';
  25. const formatDate = (date: Date | null) => {
  26. if (date == null) {
  27. return '';
  28. }
  29. return format(new Date(date), 'yyyy-MM-dd');
  30. };
  31. const PAGING_LIMIT = 10;
  32. export const AuditLogManagement: FC = () => {
  33. const { t } = useTranslation('admin');
  34. const growiCloudUri = useGrowiCloudUri();
  35. const growiAppIdForGrowiCloud = useGrowiAppIdForGrowiCloud();
  36. const isCloud = growiCloudUri != null && growiAppIdForGrowiCloud != null;
  37. const typeaheadRef = useRef<IClearable>(null);
  38. const auditLogAvailableActionsData = useAtomValue(
  39. auditLogAvailableActionsAtom,
  40. );
  41. /*
  42. * State
  43. */
  44. const [isSettingPage, setIsSettingPage] = useState<boolean>(false);
  45. const [activePageNumber, setActivePageNumber] = useState<number>(1);
  46. const [jumpPageNumber, setJumpPageNumber] = useState<number>(1);
  47. const offset = (activePageNumber - 1) * PAGING_LIMIT;
  48. const [startDate, setStartDate] = useState<Date | null>(null);
  49. const [endDate, setEndDate] = useState<Date | null>(null);
  50. const [selectedUsernames, setSelectedUsernames] = useState<string[]>([]);
  51. const [actionMap, setActionMap] = useState(
  52. new Map<SupportedActionType, boolean>(
  53. auditLogAvailableActionsData != null
  54. ? auditLogAvailableActionsData.map((action) => [action, true])
  55. : [],
  56. ),
  57. );
  58. /*
  59. * Fetch
  60. */
  61. const selectedDate = {
  62. startDate: formatDate(startDate),
  63. endDate: formatDate(endDate),
  64. };
  65. const selectedActionList = Array.from(actionMap.entries())
  66. .filter((v) => v[1])
  67. .map((v) => v[0]);
  68. const searchFilter = {
  69. actions: selectedActionList,
  70. dates: selectedDate,
  71. usernames: selectedUsernames,
  72. };
  73. const {
  74. data: activityData,
  75. mutate: mutateActivity,
  76. error,
  77. } = useSWRxActivity(PAGING_LIMIT, offset, searchFilter);
  78. const activityList = activityData?.docs != null ? activityData.docs : [];
  79. const totalActivityNum =
  80. activityData?.totalDocs != null ? activityData.totalDocs : 0;
  81. const totalPagingPages =
  82. activityData?.totalPages != null ? activityData.totalPages : 0;
  83. const isLoading = activityData === undefined && error == null;
  84. if (error != null) {
  85. toastError('Failed to get Audit Log');
  86. }
  87. const auditLogEnabled = useAtomValue(auditLogEnabledAtom);
  88. /*
  89. * Functions
  90. */
  91. const setActivePageHandler = useCallback((selectedPageNum: number) => {
  92. setActivePageNumber(selectedPageNum);
  93. }, []);
  94. const datePickerChangedHandler = useCallback((dateList: Date[] | null[]) => {
  95. setActivePageNumber(1);
  96. setStartDate(dateList[0]);
  97. setEndDate(dateList[1]);
  98. }, []);
  99. const actionCheckboxChangedHandler = useCallback(
  100. (action: SupportedActionType) => {
  101. setActivePageNumber(1);
  102. actionMap.set(action, !actionMap.get(action));
  103. setActionMap(new Map(actionMap.entries()));
  104. },
  105. [actionMap],
  106. );
  107. const multipleActionCheckboxChangedHandler = useCallback(
  108. (actions: SupportedActionType[], isChecked) => {
  109. setActivePageNumber(1);
  110. actions.forEach((action) => {
  111. actionMap.set(action, isChecked);
  112. });
  113. setActionMap(new Map(actionMap.entries()));
  114. },
  115. [actionMap],
  116. );
  117. const setUsernamesHandler = useCallback((usernames: string[]) => {
  118. setActivePageNumber(1);
  119. setSelectedUsernames(usernames);
  120. }, []);
  121. const clearButtonPushedHandler = useCallback(() => {
  122. setActivePageNumber(1);
  123. setStartDate(null);
  124. setEndDate(null);
  125. setSelectedUsernames([]);
  126. typeaheadRef.current?.clear();
  127. if (auditLogAvailableActionsData != null) {
  128. setActionMap(
  129. new Map<SupportedActionType, boolean>(
  130. auditLogAvailableActionsData.map((action) => [action, true]),
  131. ),
  132. );
  133. }
  134. }, [auditLogAvailableActionsData]);
  135. const reloadButtonPushedHandler = useCallback(() => {
  136. setActivePageNumber(1);
  137. mutateActivity();
  138. }, [mutateActivity]);
  139. const jumpPageInputChangeHandler = useCallback(
  140. (e: React.ChangeEvent<HTMLInputElement>) => {
  141. const inputNumber = Number(e.target.value);
  142. const isNan = Number.isNaN(inputNumber);
  143. if (!isNan) {
  144. const jumpPageNumber =
  145. inputNumber > totalPagingPages
  146. ? totalPagingPages
  147. : inputNumber <= 0
  148. ? activePageNumber
  149. : inputNumber;
  150. setJumpPageNumber(jumpPageNumber);
  151. } else {
  152. setJumpPageNumber(activePageNumber);
  153. }
  154. },
  155. [totalPagingPages, activePageNumber],
  156. );
  157. const jumpPageInputKeyDownHandler = useCallback(
  158. (e) => {
  159. if (e.key === 'Enter') {
  160. setActivePageNumber(jumpPageNumber);
  161. }
  162. },
  163. [jumpPageNumber],
  164. );
  165. const jumpPageButtonPushedHandler = useCallback(() => {
  166. setActivePageNumber(jumpPageNumber);
  167. }, [jumpPageNumber]);
  168. const [isExportModalOpen, setIsExportModalOpen] = useState<boolean>(false);
  169. const startIndex = activityList.length === 0 ? 0 : offset + 1;
  170. const endIndex = activityList.length === 0 ? 0 : offset + activityList.length;
  171. if (!auditLogEnabled) {
  172. return <AuditLogDisableMode />;
  173. }
  174. return (
  175. <div data-testid="admin-auditlog">
  176. <button
  177. type="button"
  178. className="btn btn-outline-secondary mb-4"
  179. onClick={() => setIsSettingPage(!isSettingPage)}
  180. >
  181. {isSettingPage ? (
  182. <>
  183. <span className="material-symbols-outlined">arrow_left_alt</span>
  184. {t('admin:audit_log_management.return')}
  185. </>
  186. ) : (
  187. <>
  188. <span className="material-symbols-outlined">settings</span>
  189. {t('admin:audit_log_management.settings')}
  190. </>
  191. )}
  192. </button>
  193. {isCloud && (
  194. <a
  195. href={`${growiCloudUri}/my/apps/${growiAppIdForGrowiCloud}`}
  196. className="btn btn-outline-secondary mb-4 ms-2"
  197. >
  198. <span className="material-symbols-outlined me-1">share</span>
  199. {t('cloud_setting_management.to_cloud_settings')}
  200. </a>
  201. )}
  202. <h2 className="admin-setting-header mb-3">
  203. <span>
  204. {isSettingPage
  205. ? t('audit_log_management.audit_log_settings')
  206. : t('audit_log_management.audit_log')}
  207. </span>
  208. {!isSettingPage && (
  209. <button
  210. type="button"
  211. className="btn btn-sm ms-auto grw-btn-reload"
  212. onClick={reloadButtonPushedHandler}
  213. >
  214. <span className="material-symbols-outlined">refresh</span>
  215. </button>
  216. )}
  217. </h2>
  218. {isSettingPage ? (
  219. <AuditLogSettings />
  220. ) : (
  221. <>
  222. <div className="row row-cols-lg-auto mb-3 g-3">
  223. <div className="col-12">
  224. <SearchUsernameTypeahead
  225. ref={typeaheadRef}
  226. onChange={setUsernamesHandler}
  227. />
  228. </div>
  229. <div className="col-12">
  230. <DateRangePicker
  231. startDate={startDate}
  232. endDate={endDate}
  233. onChange={datePickerChangedHandler}
  234. />
  235. </div>
  236. <div className="col-12">
  237. <SelectActionDropdown
  238. actionMap={actionMap}
  239. availableActions={auditLogAvailableActionsData || []}
  240. onChangeAction={actionCheckboxChangedHandler}
  241. onChangeMultipleAction={multipleActionCheckboxChangedHandler}
  242. />
  243. </div>
  244. <div className="col-12">
  245. <button
  246. type="button"
  247. className="btn btn-link"
  248. onClick={clearButtonPushedHandler}
  249. >
  250. {t('admin:audit_log_management.clear')}
  251. </button>
  252. </div>
  253. <div className="col-12">
  254. <button
  255. type="button"
  256. className="btn btn-outline-secondary"
  257. onClick={() => setIsExportModalOpen(true)}
  258. >
  259. <span className="material-symbols-outlined me-1">download</span>
  260. {t('admin:audit_log_management.export')}
  261. </button>
  262. </div>
  263. </div>
  264. <p className="ms-2">
  265. <strong>{startIndex}</strong> - <strong>{endIndex}</strong> of{' '}
  266. <strong>{totalActivityNum}</strong>
  267. </p>
  268. {isLoading ? (
  269. <div className="text-muted text-center mb-5">
  270. <LoadingSpinner className="me-1 fs-3" />
  271. </div>
  272. ) : (
  273. <ActivityTable activityList={activityList} />
  274. )}
  275. <div className="d-flex flex-row justify-content-center">
  276. <PaginationWrapper
  277. activePage={activePageNumber}
  278. changePage={setActivePageHandler}
  279. totalItemsCount={totalActivityNum}
  280. pagingLimit={PAGING_LIMIT}
  281. align="center"
  282. size="sm"
  283. />
  284. <div className="admin-audit-log ms-3">
  285. <label
  286. htmlFor="jumpPageInput"
  287. className="form-label me-1 text-secondary"
  288. >
  289. Jump To Page
  290. </label>
  291. <input
  292. id="jumpPageInput"
  293. type="text"
  294. className="jump-page-input"
  295. onChange={jumpPageInputChangeHandler}
  296. onKeyDown={jumpPageInputKeyDownHandler}
  297. />
  298. <button
  299. className="btn btn-sm"
  300. type="button"
  301. onClick={jumpPageButtonPushedHandler}
  302. >
  303. <b>Go</b>
  304. </button>
  305. </div>
  306. </div>
  307. <AuditLogExportModal
  308. isOpen={isExportModalOpen}
  309. onClose={() => setIsExportModalOpen(false)}
  310. initialStartDate={startDate}
  311. initialEndDate={endDate}
  312. initialSelectedUsernames={selectedUsernames}
  313. initialActionMap={actionMap}
  314. />
  315. </>
  316. )}
  317. </div>
  318. );
  319. };