Просмотр исходного кода

Merge branch 'feat/auditlog' of https://github.com/weseek/growi into imprv/94090-put-snapshot-in-activity-document

Shun Miyazawa 3 лет назад
Родитель
Сommit
89c8ae2068

+ 1 - 0
packages/app/package.json

@@ -139,6 +139,7 @@
     "passport-twitter": "^1.0.4",
     "prom-client": "^13.0.0",
     "react-card-flip": "^1.0.10",
+    "react-datepicker": "^4.7.0",
     "react-dnd": "^14.0.5",
     "react-dnd-html5-backend": "^14.1.0",
     "react-image-crop": "^8.3.0",

+ 1 - 0
packages/app/resource/locales/en_US/translation.json

@@ -127,6 +127,7 @@
   "UserGroup": "UserGroup",
   "ChildUserGroup": "ChildUserGroup",
   "UserGroup Management": "UserGroup Management",
+  "AuditLog": "AuditLog",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",
   "Export Archive Data": "Export Archive Data",

+ 1 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -127,6 +127,7 @@
   "UserGroup": "グループ",
   "ChildUserGroup": "子グループ",
   "UserGroup Management": "グループ管理",
+  "AuditLog": "監査ログ",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
   "Export Archive Data": "データアーカイブ",

+ 1 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -135,6 +135,7 @@
   "UserGroup": "用户组",
   "ChildUserGroup": "儿童用户组",
 	"UserGroup Management": "用户组管理",
+  "AuditLog": "审计日志",
 	"Full Text Search Management": "全文搜索管理",
 	"Import Data": "导入数据",
 	"Export Archive Data": "导出主题数据",

+ 38 - 39
packages/app/src/client/admin.jsx

@@ -1,55 +1,53 @@
 import React from 'react';
+
 import ReactDOM from 'react-dom';
-import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
-
 import { SWRConfig } from 'swr';
+import { Provider } from 'unstated';
 
-import loggerFactory from '~/utils/logger';
-import { swrGlobalConfiguration } from '~/utils/swr-utils';
-
-import ErrorBoundary from '../components/ErrorBoudary';
-
-import AdminHome from '../components/Admin/AdminHome/AdminHome';
-import UserGroupDetailPage from '../components/Admin/UserGroupDetail/UserGroupDetailPage';
-import NotificationSetting from '../components/Admin/Notification/NotificationSetting';
-import LegacySlackIntegration from '../components/Admin/LegacySlackIntegration/LegacySlackIntegration';
-import SlackIntegration from '../components/Admin/SlackIntegration/SlackIntegration';
-import ManageGlobalNotification from '../components/Admin/Notification/ManageGlobalNotification';
-import MarkdownSetting from '../components/Admin/MarkdownSetting/MarkDownSetting';
-import UserManagement from '../components/Admin/UserManagement';
-import AppSettingsPage from '../components/Admin/App/AppSettingsPage';
-import SecurityManagement from '../components/Admin/Security/SecurityManagement';
-import ManageExternalAccount from '../components/Admin/ManageExternalAccount';
-import UserGroupPage from '../components/Admin/UserGroup/UserGroupPage';
-import Customize from '../components/Admin/Customize/Customize';
-import ImportDataPage from '../components/Admin/ImportDataPage';
-import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
-import FullTextSearchManagement from '../components/Admin/FullTextSearchManagement';
-import AdminNavigation from '../components/Admin/Common/AdminNavigation';
-
-import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
-import AdminHomeContainer from '~/client/services/AdminHomeContainer';
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
-import AdminImportContainer from '~/client/services/AdminImportContainer';
-import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
+import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
+import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
+import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
+import AdminHomeContainer from '~/client/services/AdminHomeContainer';
+import AdminImportContainer from '~/client/services/AdminImportContainer';
 import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
 import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
-import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
-import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
-import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
-import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
-import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
-import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
+import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
+import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
+import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
-
+import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
+import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
+import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import ContextExtractor from '~/client/services/ContextExtractor';
+import loggerFactory from '~/utils/logger';
+import { swrGlobalConfiguration } from '~/utils/swr-utils';
+
+import AdminHome from '../components/Admin/AdminHome/AdminHome';
+import AppSettingsPage from '../components/Admin/App/AppSettingsPage';
+import { AuditLogManagement } from '../components/Admin/AuditLogManagement';
+import AdminNavigation from '../components/Admin/Common/AdminNavigation';
+import Customize from '../components/Admin/Customize/Customize';
+import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
+import FullTextSearchManagement from '../components/Admin/FullTextSearchManagement';
+import ImportDataPage from '../components/Admin/ImportDataPage';
+import LegacySlackIntegration from '../components/Admin/LegacySlackIntegration/LegacySlackIntegration';
+import ManageExternalAccount from '../components/Admin/ManageExternalAccount';
+import MarkdownSetting from '../components/Admin/MarkdownSetting/MarkDownSetting';
+import ManageGlobalNotification from '../components/Admin/Notification/ManageGlobalNotification';
+import NotificationSetting from '../components/Admin/Notification/NotificationSetting';
+import SecurityManagement from '../components/Admin/Security/SecurityManagement';
+import SlackIntegration from '../components/Admin/SlackIntegration/SlackIntegration';
+import UserGroupPage from '../components/Admin/UserGroup/UserGroupPage';
+import UserGroupDetailPage from '../components/Admin/UserGroupDetail/UserGroupDetailPage';
+import UserManagement from '../components/Admin/UserManagement';
+import ErrorBoundary from '../components/ErrorBoudary';
 
 import { appContainer, componentMappings } from './base';
 
@@ -111,6 +109,7 @@ Object.assign(componentMappings, {
   'admin-user-group-detail': <UserGroupDetailPage />,
   'admin-full-text-search-management': <FullTextSearchManagement />,
   'admin-user-group-page': <UserGroupPage />,
+  'admin-audit-log': <AuditLogManagement />,
   'admin-navigation': <AdminNavigation />,
 });
 

+ 42 - 0
packages/app/src/components/Admin/AuditLog/ActivityTable.tsx

@@ -0,0 +1,42 @@
+import React, { FC } from 'react';
+
+import { format } from 'date-fns';
+
+import { IActivityHasId } from '~/interfaces/activity';
+
+type Props = {
+  activityList: IActivityHasId[]
+}
+
+const formatDate = (date) => {
+  return format(new Date(date), 'yyyy/MM/dd HH:mm:ss');
+};
+
+export const ActivityTable : FC<Props> = (props: Props) => {
+  return (
+    <div className="table-responsive text-nowrap h-100">
+      <table className="table table-default table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th scope="col">username</th>
+            <th scope="col">targetModel</th>
+            <th scope="col">action</th>
+            <th scope="col">createdAt</th>
+          </tr>
+        </thead>
+        <tbody>
+          {props.activityList.map((activity) => {
+            return (
+              <tr data-testid="activity-table" key={activity._id}>
+                <td>{activity.user?.username}</td>
+                <td>{activity.targetModel}</td>
+                <td>{activity.action}</td>
+                <td>{formatDate(activity.createdAt)}</td>
+              </tr>
+            );
+          })}
+        </tbody>
+      </table>
+    </div>
+  );
+};

+ 74 - 0
packages/app/src/components/Admin/AuditLog/SelectActionDropdown.tsx

@@ -0,0 +1,74 @@
+import React, { FC, useCallback } from 'react';
+
+import { SupportedActionType } from '~/interfaces/activity';
+
+type Props = {
+  dropdownItems: Array<{actionCategory: string, actionNames: SupportedActionType[]}>
+  actionMap: Map<SupportedActionType, boolean>
+  onSelectAction: (action: SupportedActionType) => void
+  onSelectMultipleAction: (actions: SupportedActionType[], isChecked: boolean) => void
+}
+
+export const SelectActionDropdown: FC<Props> = (props: Props) => {
+  const {
+    dropdownItems, actionMap, onSelectAction, onSelectMultipleAction,
+  } = props;
+
+  const selectActionCheckboxChangedHandler = useCallback((action) => {
+    if (onSelectAction != null) {
+      onSelectAction(action);
+    }
+  }, [onSelectAction]);
+
+  const selectMultipleActionCheckboxChangedHandler = useCallback((actions, isChecked) => {
+    if (onSelectMultipleAction != null) {
+      onSelectMultipleAction(actions, isChecked);
+    }
+  }, [onSelectMultipleAction]);
+
+  return (
+    <div className="btn-group mr-2 mb-3">
+      <button className="btn btn-outline-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown">
+        <i className="fa fa-fw fa-bolt" />Action
+      </button>
+      <ul className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+        {dropdownItems.map(item => (
+          <div key={item.actionCategory}>
+            <div className="dropdown-item">
+              <div className="form-group px-2 m-0">
+                <input
+                  type="checkbox"
+                  className="form-check-input"
+                  defaultChecked
+                  onChange={(e) => { selectMultipleActionCheckboxChangedHandler(item.actionNames, e.target.checked) }}
+                />
+                <label className="form-check-label">{item.actionCategory}</label>
+              </div>
+            </div>
+            {
+              item.actionNames.map(action => (
+                <div className="dropdown-item" key={action}>
+                  <div className="form-group px-4 m-0">
+                    <input
+                      type="checkbox"
+                      className="form-check-input"
+                      id={`checkbox${action}`}
+                      onChange={() => { selectActionCheckboxChangedHandler(action) }}
+                      checked={actionMap.get(action)}
+                    />
+                    <label
+                      className="form-check-label"
+                      htmlFor={`checkbox${action}`}
+                    >
+                      {action}
+                    </label>
+                  </div>
+                </div>
+              ))
+            }
+          </div>
+        ))}
+      </ul>
+    </div>
+  );
+};

+ 102 - 0
packages/app/src/components/Admin/AuditLogManagement.tsx

@@ -0,0 +1,102 @@
+import React, { FC, useState, useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import {
+  SupportedActionType, AllSupportedActionType, PageActions, CommentActions,
+} from '~/interfaces/activity';
+import { useSWRxActivityList } from '~/stores/activity';
+
+import PaginationWrapper from '../PaginationWrapper';
+
+import { ActivityTable } from './AuditLog/ActivityTable';
+import { SelectActionDropdown } from './AuditLog/SelectActionDropdown';
+
+const PAGING_LIMIT = 10;
+
+export const AuditLogManagement: FC = () => {
+  const { t } = useTranslation();
+
+  /*
+   * State
+   */
+  const [activePage, setActivePage] = useState<number>(1);
+  const offset = (activePage - 1) * PAGING_LIMIT;
+  const [actionMap, setActionMap] = useState(
+    new Map<SupportedActionType, boolean>(AllSupportedActionType.map(action => [action, true])),
+  );
+
+  /*
+   * Fetch
+   */
+  const selectedActionList = Array.from(actionMap.entries()).filter(v => v[1]).map(v => v[0]);
+  const searchFilter = { action: selectedActionList };
+
+  const { data: activityListData, error } = useSWRxActivityList(PAGING_LIMIT, offset, searchFilter);
+  const activityList = activityListData?.docs != null ? activityListData.docs : [];
+  const totalActivityNum = activityListData?.totalDocs != null ? activityListData.totalDocs : 0;
+  const isLoading = activityListData === undefined && error == null;
+
+  /*
+   * Functions
+   */
+  const setActivePageHandler = useCallback((selectedPageNum: number) => {
+    setActivePage(selectedPageNum);
+  }, []);
+
+  const selectActionCheckboxChangedHandler = useCallback((action: SupportedActionType) => {
+    setActivePage(1);
+    actionMap.set(action, !actionMap.get(action));
+    setActionMap(new Map(actionMap.entries()));
+  }, [actionMap, setActionMap]);
+
+  const selectMultipleActionCheckboxChangedHandler = useCallback((actions: SupportedActionType[], isChecked) => {
+    setActivePage(1);
+    actions.forEach(action => actionMap.set(action, isChecked));
+    setActionMap(new Map(actionMap.entries()));
+  }, [actionMap, setActionMap]);
+
+  // eslint-disable-next-line max-len
+  const activityCounter = `<b>${activityList.length === 0 ? 0 : offset + 1}</b> - <b>${(PAGING_LIMIT * activePage) - (PAGING_LIMIT - activityList.length)}</b> of <b>${totalActivityNum}<b/>`;
+
+  return (
+    <div data-testid="admin-auditlog">
+      <h2 className="admin-setting-header mb-3">{t('AuditLog')}</h2>
+      <SelectActionDropdown
+        dropdownItems={[
+          { actionCategory: 'Page', actionNames: PageActions },
+          { actionCategory: 'Comment', actionNames: CommentActions },
+        ]}
+        actionMap={actionMap}
+        onSelectAction={selectActionCheckboxChangedHandler}
+        onSelectMultipleAction={selectMultipleActionCheckboxChangedHandler}
+      />
+
+      { isLoading
+        ? (
+          <div className="text-muted text-center mb-5">
+            <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+          </div>
+        )
+        : (
+          <>
+            <p
+              className="ml-2"
+              // eslint-disable-next-line react/no-danger
+              dangerouslySetInnerHTML={{ __html: activityCounter }}
+            />
+            <ActivityTable activityList={activityList} />
+            <PaginationWrapper
+              activePage={activePage}
+              changePage={setActivePageHandler}
+              totalItemsCount={totalActivityNum}
+              pagingLimit={PAGING_LIMIT}
+              align="center"
+              size="sm"
+            />
+          </>
+        )
+      }
+    </div>
+  );
+};

+ 7 - 1
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -2,13 +2,15 @@
 /* eslint-disable react/jsx-props-no-multi-spaces */
 
 import React from 'react';
+
+import { pathUtils } from '@growi/core';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import urljoin from 'url-join';
 
-import { pathUtils } from '@growi/core';
 
 import AppContainer from '~/client/services/AppContainer';
+
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 const AdminNavigation = (props) => {
@@ -33,6 +35,8 @@ const AdminNavigation = (props) => {
       case 'users':                    return <><i className="icon-fw icon-user"></i>            { t('User_Management') }</>;
       case 'user-groups':              return <><i className="icon-fw icon-people"></i>          { t('UserGroup Management') }</>;
       case 'search':                   return <><i className="icon-fw icon-magnifier"></i>       { t('Full Text Search Management') }</>;
+      // TODO: Consider where to place the "AuditLog"
+      case 'audit-log':                return <><i className="icon-fw icon-feed"></i>            { t('AuditLog')}</>;
       case 'cloud':                    return <><i className="icon-fw icon-share-alt"></i>       { t('to_cloud_settings')} </>;
       default:                         return <><i className="icon-fw icon-home"></i>            { t('Wiki Management Home Page') }</>;
     }
@@ -82,6 +86,7 @@ const AdminNavigation = (props) => {
         <MenuLink menu="users"        isListGroupItems isActive={isActiveMenu('/users')} />
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
+        <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
         {growiCloudUri != null && growiAppIdForGrowiCloud != null
           && (
             <a
@@ -127,6 +132,7 @@ const AdminNavigation = (props) => {
             {isActiveMenu('/users') &&             <MenuLabel menu="users" />}
             {isActiveMenu('/user-groups') &&       <MenuLabel menu="user-groups" />}
             {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
+            {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
           </span>
         </button>
         <div className="dropdown-menu" aria-labelledby="dropdown-admin-navigation">

+ 37 - 3
packages/app/src/interfaces/activity.ts

@@ -1,4 +1,5 @@
-import { IUser } from '~/interfaces/user';
+import { HasObjectId } from './has-object-id';
+import { IUser } from './user';
 
 // Model
 const MODEL_PAGE = 'Page';
@@ -39,8 +40,41 @@ export const SUPPORTED_ACTION_TYPE = {
 export const AllSupportedTargetModelType = Object.values(SUPPORTED_TARGET_MODEL_TYPE);
 export const AllSupportedActionType = Object.values(SUPPORTED_ACTION_TYPE);
 
-// type supportedTargetModelType = typeof SUPPORTED_TARGET_MODEL_NAMES[keyof typeof SUPPORTED_TARGET_MODEL_NAMES];
-// type supportedActionType = typeof SUPPORTED_ACTION_NAMES[keyof typeof SUPPORTED_ACTION_NAMES];
+/*
+ * For AuditLogManagement.tsx
+ */
+export const PageActions = Object.values({
+  ACTION_PAGE_LIKE,
+  ACTION_PAGE_BOOKMARK,
+  ACTION_PAGE_CREATE,
+  ACTION_PAGE_UPDATE,
+  ACTION_PAGE_RENAME,
+  ACTION_PAGE_DUPLICATE,
+  ACTION_PAGE_DELETE,
+  ACTION_PAGE_DELETE_COMPLETELY,
+  ACTION_PAGE_REVERT,
+} as const);
+
+export const CommentActions = Object.values({
+  ACTION_COMMENT_CREATE,
+  ACTION_COMMENT_UPDATE,
+} as const);
+
+
+export type SupportedTargetModelType = typeof SUPPORTED_TARGET_MODEL_TYPE[keyof typeof SUPPORTED_TARGET_MODEL_TYPE];
+// type supportedEventModelType = typeof SUPPORTED_EVENT_MODEL_TYPE[keyof typeof SUPPORTED_EVENT_MODEL_TYPE];
+export type SupportedActionType = typeof SUPPORTED_ACTION_TYPE[keyof typeof SUPPORTED_ACTION_TYPE];
+
+
+export type IActivity = {
+  user?: IUser
+  targetModel: SupportedTargetModelType
+  targe: string
+  action: SupportedActionType
+  createdAt: Date
+}
+
+export type IActivityHasId = IActivity & HasObjectId;
 
 export type ISnapshot = {
   username?: Pick<IUser, 'username'>

+ 13 - 0
packages/app/src/interfaces/mongoose-utils.ts

@@ -0,0 +1,13 @@
+export interface PaginateResult<T> {
+  docs: T[];
+  hasNextPage: boolean;
+  hasPrevPage: boolean;
+  limit: number;
+  nextPage: number | null;
+  offset: number;
+  page: number;
+  pagingCounter: number;
+  prevPage: number | null;
+  totalDocs: number;
+  totalPages: number;
+}

+ 16 - 0
packages/app/src/server/models/activity.ts

@@ -2,6 +2,7 @@ import { getOrCreateModel, getModelSafely } from '@growi/core';
 import {
   Types, Document, Model, Schema,
 } from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
 
 import { AllSupportedTargetModelType, AllSupportedActionType, ISnapshot } from '~/interfaces/activity';
 
@@ -24,6 +25,7 @@ export interface ActivityDocument extends Document {
 }
 
 export interface ActivityModel extends Model<ActivityDocument> {
+  [x:string]: any
   getActionUsersFromActivities(activities: ActivityDocument[]): any[]
 }
 
@@ -65,6 +67,7 @@ activitySchema.index({ target: 1, action: 1 });
 activitySchema.index({
   user: 1, target: 1, action: 1, createdAt: 1,
 }, { unique: true });
+activitySchema.plugin(mongoosePaginate);
 
 
 activitySchema.methods.getNotificationTargetUsers = async function() {
@@ -101,4 +104,17 @@ activitySchema.post('save', async(savedActivity: ActivityDocument) => {
   activityEvent.emit('create', targetUsers, savedActivity);
 });
 
+activitySchema.statics.getPaginatedActivity = async function(limit: number, offset: number, query) {
+  const paginateResult = await this.paginate(
+    query,
+    {
+      limit,
+      offset,
+      sort: { createdAt: -1 },
+      populate: 'user',
+    },
+  );
+  return paginateResult;
+};
+
 export default getOrCreateModel<ActivityDocument, ActivityModel>('Activity', activitySchema);

+ 7 - 1
packages/app/src/server/routes/admin.js

@@ -1,5 +1,5 @@
-import loggerFactory from '~/utils/logger';
 import UserGroup from '~/server/models/user-group';
+import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:admin');
 const debug = require('debug')('growi:routes:admin');
@@ -289,6 +289,12 @@ module.exports = function(crowi, app) {
     return res.render('admin/user-group-detail', { userGroup });
   };
 
+  // AuditLog
+  actions.auditLog = {};
+  actions.auditLog.index = (req, res) => {
+    return res.render('admin/audit-log');
+  };
+
   // Importer management
   actions.importer = {};
   actions.importer.api = api;

+ 77 - 0
packages/app/src/server/routes/apiv3/activity.ts

@@ -0,0 +1,77 @@
+import express, { Request, Router } from 'express';
+import rateLimit from 'express-rate-limit';
+import { query } from 'express-validator';
+
+import { IActivity, AllSupportedActionType } from '~/interfaces/activity';
+import Activity from '~/server/models/activity';
+import loggerFactory from '~/utils/logger';
+
+import Crowi from '../../crowi';
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import { serializeUserSecurely } from '../../models/serializers/user-serializer';
+
+import { ApiV3Response } from './interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:activity');
+
+
+const validator = {
+  list: [
+    query('limit').optional().isInt({ max: 100 }).withMessage('limit must be a number less than or equal to 100'),
+    query('offset').optional().isInt().withMessage('page must be a number'),
+    query('searchFilter').optional().isString().withMessage('query must be a string'),
+  ],
+};
+
+const apiLimiter = rateLimit({
+  windowMs: 15 * 60 * 1000, // 15 minutes
+  max: 30, // limit each IP to 30 requests per windowMs
+  message:
+    'Too many requests sent from this IP, please try again after 15 minutes.',
+});
+
+module.exports = (crowi: Crowi): Router => {
+  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+
+  const router = express.Router();
+
+  // eslint-disable-next-line max-len
+  router.get('/', apiLimiter, accessTokenParser, loginRequiredStrictly, adminRequired, validator.list, apiV3FormValidator, async(req: Request, res: ApiV3Response) => {
+    const limit = req.query.limit || await crowi.configManager?.getConfig('crowi', 'customize:showPageLimitationS') || 10;
+    const offset = req.query.offset || 1;
+
+    try {
+      const parsedSearchFilter = JSON.parse(req.query.searchFilter as string || '');
+      const canContainActionFilterToQuery = parsedSearchFilter.action.every(a => AllSupportedActionType.includes(a));
+      const query = {
+        action: canContainActionFilterToQuery ? parsedSearchFilter.action : [],
+      };
+
+      const paginationResult = await Activity.getPaginatedActivity(limit, offset, query);
+
+      const User = crowi.model('User');
+      const serializedDocs = paginationResult.docs.map((doc: IActivity) => {
+        if (doc.user != null && doc.user instanceof User) {
+          doc.user = serializeUserSecurely(doc.user);
+        }
+        return doc;
+      });
+
+      const serializedPaginationResult = {
+        ...paginationResult,
+        docs: serializedDocs,
+      };
+
+      return res.apiv3({ serializedPaginationResult });
+    }
+    catch (err) {
+      logger.error('Failed to get paginated activity', err);
+      return res.apiv3Err(err);
+    }
+  });
+
+  return router;
+};

+ 1 - 0
packages/app/src/server/routes/apiv3/index.js

@@ -35,6 +35,7 @@ module.exports = (crowi) => {
   routerForAdmin.use('/mongo', require('./mongo')(crowi));
   routerForAdmin.use('/slack-integration-settings', require('./slack-integration-settings')(crowi));
   routerForAdmin.use('/slack-integration-legacy-settings', require('./slack-integration-legacy-settings')(crowi));
+  routerForAdmin.use('/activity', require('./activity')(crowi));
 
   // auth
   routerForAuth.use('/logout', require('./logout')(crowi));

+ 3 - 0
packages/app/src/server/routes/index.js

@@ -138,6 +138,9 @@ module.exports = function(crowi, app) {
   app.get('/admin/user-groups'                          , loginRequiredStrictly, adminRequired, admin.userGroup.index);
   app.get('/admin/user-group-detail/:id'                , loginRequiredStrictly, adminRequired, admin.userGroup.detail);
 
+  // auditLog admin
+  app.get('/admin/audit-log'                            , loginRequiredStrictly, adminRequired, admin.auditLog.index);
+
   // importer management for admin
   app.get('/admin/importer'                     , loginRequiredStrictly , adminRequired , admin.importer.index);
   app.post('/_api/admin/settings/importerEsa'   , loginRequiredStrictly , adminRequired , csrf, admin.importer.api.validators.importer.esa(),admin.api.importerSettingEsa);

+ 1 - 1
packages/app/src/server/service/activity.ts

@@ -1,6 +1,6 @@
 import { getModelSafely } from '@growi/core';
-import Crowi from '../crowi';
 
+import Crowi from '../crowi';
 
 class ActivityService {
 

+ 11 - 0
packages/app/src/server/views/admin/audit-log.html

@@ -0,0 +1,11 @@
+{% extends '../layout/admin.html' %}
+
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('AuditLog')) }}{% endblock %}
+
+{% block content_header %}
+<h1 class="title">{{ t('AuditLog') }}</h1>
+{% endblock %}
+
+{% block content_main %}
+<div id ="admin-audit-log"></div>
+{% endblock content_main %}

+ 20 - 0
packages/app/src/stores/activity.ts

@@ -0,0 +1,20 @@
+import { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+
+import { apiv3Get } from '../client/util/apiv3-client';
+import { IActivityHasId, SupportedActionType } from '../interfaces/activity';
+import { PaginateResult } from '../interfaces/mongoose-utils';
+
+
+type ISearchFilter = {
+  action?: SupportedActionType[]
+}
+
+export const useSWRxActivityList = (limit?: number, offset?: number, searchFilter?: ISearchFilter): SWRResponse<PaginateResult<IActivityHasId>, Error> => {
+  const stringifiedSearchFilter = JSON.stringify(searchFilter);
+  return useSWRImmutable(
+    ['/activity', limit, offset, stringifiedSearchFilter],
+    (endpoint, limit, offset, stringifiedSearchFilter) => apiv3Get(endpoint, { limit, offset, searchFilter: stringifiedSearchFilter })
+      .then(result => result.data.serializedPaginationResult),
+  );
+};

+ 45 - 0
yarn.lock

@@ -2313,6 +2313,11 @@
   resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.4.tgz#d8c7b8db9226d2d7664553a0741ad7d0397ee503"
   integrity sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg==
 
+"@popperjs/core@^2.9.2":
+  version "2.11.5"
+  resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.5.tgz#db5a11bf66bdab39569719555b0f76e138d7bd64"
+  integrity sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw==
+
 "@promster/express@^7.0.2":
   version "7.0.2"
   resolved "https://registry.yarnpkg.com/@promster/express/-/express-7.0.2.tgz#d60b10d373fd572275714426dc90a7049fd26d4f"
@@ -5663,6 +5668,11 @@ classnames@^2.2.3:
   resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
   integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==
 
+classnames@^2.2.6:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e"
+  integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==
+
 clean-stack@^2.0.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b"
@@ -6967,6 +6977,11 @@ date-fns@^2.23.0:
   resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.23.0.tgz#4e886c941659af0cf7b30fafdd1eaa37e88788a9"
   integrity sha512-5ycpauovVyAk0kXNZz6ZoB9AYMZB4DObse7P3BPWmyEjXNORTI8EJ6X0uaSAq4sCHzM1uajzrkr6HnsLQpxGXA==
 
+date-fns@^2.24.0:
+  version "2.28.0"
+  resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2"
+  integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==
+
 date-format@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/date-format/-/date-format-2.1.0.tgz#31d5b5ea211cf5fd764cd38baf9d033df7e125cf"
@@ -16647,6 +16662,18 @@ react-copy-to-clipboard@^5.0.1:
     copy-to-clipboard "^3"
     prop-types "^15.5.8"
 
+react-datepicker@^4.7.0:
+  version "4.7.0"
+  resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-4.7.0.tgz#75e03b0a6718b97b84287933307faf2ed5f03cf4"
+  integrity sha512-FS8KgbwqpxmJBv/bUdA42MYqYZa+fEYcpc746DZiHvVE2nhjrW/dg7c5B5fIUuI8gZET6FOzuDgezNcj568Czw==
+  dependencies:
+    "@popperjs/core" "^2.9.2"
+    classnames "^2.2.6"
+    date-fns "^2.24.0"
+    prop-types "^15.7.2"
+    react-onclickoutside "^6.12.0"
+    react-popper "^2.2.5"
+
 react-dnd-html5-backend@^14.1.0:
   version "14.1.0"
   resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz#b35a3a0c16dd3a2bfb5eb7ec62cf0c2cace8b62f"
@@ -16684,6 +16711,11 @@ react-dropzone@^11.2.4:
     file-selector "^0.2.2"
     prop-types "^15.7.2"
 
+react-fast-compare@^3.0.1:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
+  integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
+
 react-frame-component@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/react-frame-component/-/react-frame-component-4.0.0.tgz#57d51cdb2da3b204cc34577349f9f5bb84a76aac"
@@ -16769,6 +16801,11 @@ react-multiline-clamp@^2.0.0:
   resolved "https://registry.yarnpkg.com/react-multiline-clamp/-/react-multiline-clamp-2.0.0.tgz#913a2092368ef1b52c1c79364d506ba4af27e019"
   integrity sha512-iPm3HxFD6LO63lE5ZnThiqs+6A3c+LW3WbsEM0oa0iNTa0qN4SKx/LK/6ZToSmXundEcQXBFVNzKDvgmExawTw==
 
+react-onclickoutside@^6.12.0:
+  version "6.12.1"
+  resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.12.1.tgz#92dddd28f55e483a1838c5c2930e051168c1e96b"
+  integrity sha512-a5Q7CkWznBRUWPmocCvE8b6lEYw1s6+opp/60dCunhO+G6E4tDTO2Sd2jKE+leEnnrLAE2Wj5DlDHNqj5wPv1Q==
+
 react-overlays@^5.1.0:
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-5.1.1.tgz#2e7cf49744b56537c7828ccb94cfc63dd778ae4f"
@@ -16807,6 +16844,14 @@ react-popper@^1.3.6:
     typed-styles "^0.0.7"
     warning "^4.0.2"
 
+react-popper@^2.2.5:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.3.0.tgz#17891c620e1320dce318bad9fede46a5f71c70ba"
+  integrity sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==
+  dependencies:
+    react-fast-compare "^3.0.1"
+    warning "^4.0.2"
+
 react-scrolllock@^1.0.9:
   version "1.0.9"
   resolved "https://registry.yarnpkg.com/react-scrolllock/-/react-scrolllock-1.0.9.tgz#7c9c3c0cce2ed55042af2808b6483b85b121cdcb"