Przeglądaj źródła

Merge branch 'support/share-link-for-outside-for-merge' into feat/bulk-export-pages-for-merge

# Conflicts:
#	resource/locales/en_US/translation.json
#	resource/locales/ja_JP/translation.json
#	src/client/js/components/Page/PageShareManagement.jsx
#	src/client/js/components/ShareLinkForm.jsx
#	src/client/js/components/ShareLinkList.jsx
yusuketk 5 lat temu
rodzic
commit
af05a406c6

+ 8 - 3
resource/locales/en_US/translation.json

@@ -60,6 +60,8 @@
   "Last_Login": "Last login",
   "Share": "Share",
   "Share Link": "Share Link",
+  "share_link_notice":"remove {{count}} share links",
+  "delete_all_share_links":"Delete all share links",
   "Markdown Link": "Markdown Link",
   "Create/Edit Template": "Create/Edit template page",
   "Go to this version": "View this version",
@@ -345,11 +347,14 @@
   "toaster": {
     "update_successed": "Succeeded to update {{target}}",
     "give_user_admin": "Succeeded to give {{username}} admin",
-    "remove_user_admin": "Succeeded to remove {{username}} admin ",
+    "remove_user_admin": "Succeeded to remove {{username}} admin",
     "activate_user_success": "Succeeded to activating {{username}}",
     "deactivate_user_success": "Succeeded to deactivate {{username}}",
-    "remove_user_success": "Succeeded to removing {{username}} ",
-    "remove_external_user_success": "Succeeded to remove {{accountId}} "
+    "remove_user_success": "Succeeded to removing {{username}}",
+    "remove_external_user_success": "Succeeded to remove {{accountId}}",
+    "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
+    "issue_share_link": "Succeeded to issue new share link",
+    "remove_share_link": "Succeeded to remove {{count}} share links"
   },
   "template": {
     "modal_label": {

+ 6 - 1
resource/locales/ja_JP/translation.json

@@ -60,6 +60,8 @@
   "Last_Login": "最終ログイン",
   "Share": "共有",
   "Share Link": "共有用リンク",
+  "share_link_notice":"{{count}} 件の共有リンクを削除します",
+  "delete_all_share_links":"全ての共有リンクを削除します",
   "Markdown Link": "Markdown形式のリンク",
   "Create/Edit Template": "テンプレートページの作成/編集",
   "Go to this version": "このバージョンを見る",
@@ -350,7 +352,10 @@
     "activate_user_success": "{{username}}を有効化しました",
     "deactivate_user_success": "{{username}}を無効化しました",
     "remove_user_success": "{{username}}を削除しました",
-    "remove_external_user_success": "{{accountId}}を削除しました"
+    "remove_external_user_success": "{{accountId}}を削除しました",
+    "remove_share_link_success": "{{shareLinkId}}を削除しました",
+    "issue_share_link": "共有リンクを作成しました",
+    "remove_share_link": "共有リンクを{{count}}件削除しました"
   },
   "template": {
     "modal_label": {

+ 4 - 2
resource/locales/zh_CN/translation.json

@@ -53,7 +53,9 @@
 	"Last updated": "上次更新",
 	"Last_Login": "上次登录",
 	"Share": "分享",
-	"Share Link": "分享链接",
+  "Share Link": "分享链接",
+  "share_link_notice":"remove {{count}} share links",
+  "delete_all_share_links":"Delete all share links",
 	"Markdown Link": "Markdown链接",
 	"Create/Edit Template": "创建/编辑 模板页面",
 	"Unportalize": "未启动",
@@ -697,4 +699,4 @@
 		"Registration successful": "注册成功",
 		"Setup": "安装程序"
 	}
-}
+}

+ 67 - 0
src/client/js/components/Admin/Security/DeleteAllShareLinksModal.jsx

@@ -0,0 +1,67 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+import {
+  Button, Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+const DeleteAllShareLinksModal = React.memo((props) => {
+  const { t } = props;
+
+  function closeModal() {
+    if (props.onClose == null) {
+      return;
+    }
+
+    props.onClose();
+  }
+
+  function deleteAllLinkHandler() {
+    if (props.onClickDeleteButton == null) {
+      return;
+    }
+
+    props.onClickDeleteButton();
+
+    closeModal();
+  }
+
+  function closeButtonHandler() {
+    closeModal();
+  }
+
+  return (
+    <Modal isOpen={props.isOpen} toggle={closeButtonHandler} className="page-comment-delete-modal">
+      <ModalHeader tag="h4" toggle={closeButtonHandler} className="bg-danger text-light">
+        <span>
+          <i className="icon-fw icon-fire"></i>
+          {t('delete_all_share_links')}
+        </span>
+      </ModalHeader>
+      <ModalBody>
+        { t('share_link_notice', { count: props.count })}
+      </ModalBody>
+      <ModalFooter>
+        <Button onClick={closeButtonHandler}>Cancel</Button>
+        <Button color="danger" onClick={deleteAllLinkHandler}>
+          <i className="icon icon-fire"></i>
+            Delete
+        </Button>
+      </ModalFooter>
+    </Modal>
+  );
+
+});
+
+DeleteAllShareLinksModal.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func,
+  count: PropTypes.number.isRequired,
+  onClickDeleteButton: PropTypes.func,
+};
+
+export default withTranslation()(DeleteAllShareLinksModal);

+ 8 - 1
src/client/js/components/Admin/Security/SecurityManagement.jsx

@@ -18,6 +18,7 @@ import GoogleSecuritySetting from './GoogleSecuritySetting';
 import GitHubSecuritySetting from './GitHubSecuritySetting';
 import TwitterSecuritySetting from './TwitterSecuritySetting';
 import FacebookSecuritySetting from './FacebookSecuritySetting';
+import ShareLinkSetting from './ShareLinkSetting';
 
 class SecurityManagement extends React.Component {
 
@@ -44,10 +45,16 @@ class SecurityManagement extends React.Component {
     const { activeTab, activeComponents } = this.state;
     return (
       <Fragment>
-        <div>
+        <div className="mb-5">
           <SecuritySetting />
         </div>
 
+        {/* Shared Link List */}
+        <div className="mb-5">
+          <ShareLinkSetting />
+        </div>
+
+
         {/* XSS configuration link */}
         <div className="mb-5">
           <h2 className="border-bottom">{t('security_setting.xss_prevent_setting')}</h2>

+ 178 - 0
src/client/js/components/Admin/Security/ShareLinkSetting.jsx

@@ -0,0 +1,178 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import PaginationWrapper from '../../PaginationWrapper';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminGeneralSecurityContainer from '../../../services/AdminGeneralSecurityContainer';
+
+import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';
+
+class ShareLinkSetting extends React.Component {
+
+  constructor() {
+    super();
+
+    this.state = {
+      isDeleteConfirmModalShown: false,
+    };
+    this.getShareLinkList = this.getShareLinkList.bind(this);
+    this.showDeleteConfirmModal = this.showDeleteConfirmModal.bind(this);
+    this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
+    this.deleteAllLinksButtonHandler = this.deleteAllLinksButtonHandler.bind(this);
+    this.deleteLinkById = this.deleteLinkById.bind(this);
+  }
+
+  componentWillMount() {
+    this.getShareLinkList(1);
+  }
+
+  async getShareLinkList(page) {
+    try {
+      await this.props.adminGeneralSecurityContainer.retrieveShareLinksByPagingNum(page);
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }
+
+  showDeleteConfirmModal() {
+    this.setState({ isDeleteConfirmModalShown: true });
+  }
+
+  closeDeleteConfirmModal() {
+    this.setState({ isDeleteConfirmModalShown: false });
+  }
+
+  async deleteAllLinksButtonHandler() {
+    const { t, appContainer } = this.props;
+
+    try {
+      const res = await appContainer.apiv3Delete('/share-links/all');
+      const { deletedCount } = res.data;
+      toastSuccess(t('toaster.remove_share_link', { count: deletedCount }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    this.getShareLinkList(1);
+  }
+
+  async deleteLinkById(shareLinkId) {
+    const { t, appContainer, adminGeneralSecurityContainer } = this.props;
+
+    try {
+      const res = await appContainer.apiv3Delete(`/share-links/${shareLinkId}`);
+      const { deletedShareLink } = res.data;
+      toastSuccess(t('toaster.remove_share_link_success', { shareLinkId: deletedShareLink._id }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+    this.getShareLinkList(adminGeneralSecurityContainer.state.shareLinksActivePage);
+  }
+
+
+  render() {
+    const { adminGeneralSecurityContainer } = this.props;
+
+    const pager = (
+      <div className="pull-right my-3">
+        <PaginationWrapper
+          activePage={adminGeneralSecurityContainer.state.shareLinksActivePage}
+          changePage={this.getShareLinkList}
+          totalItemsCount={adminGeneralSecurityContainer.state.totalshareLinks}
+          pagingLimit={adminGeneralSecurityContainer.state.shareLinksPagingLimit}
+        />
+      </div>
+    );
+
+    const deleteAllButton = (
+      adminGeneralSecurityContainer.state.shareLinks.length > 0
+        ? (
+          <button
+            className="pull-right btn btn-danger"
+            type="button"
+            onClick={this.showDeleteConfirmModal}
+          >
+            Delete all links
+          </button>
+        )
+        : (
+          <p className="pull-right mr-2">No share links</p>
+        )
+    );
+
+    return (
+      <Fragment>
+        <div className="mb-3">
+          {deleteAllButton}
+          <h2 className="alert-anchor border-bottom">Shared Link List</h2>
+        </div>
+
+        {pager}
+        <div className="table-responsive">
+          <table className="table table-bordered">
+            <thead>
+              <tr>
+                <th>Link</th>
+                <th>PagePath</th>
+                <th>Expiration</th>
+                <th>Description</th>
+                <th>Order</th>
+              </tr>
+            </thead>
+            <tbody>
+              {adminGeneralSecurityContainer.state.shareLinks.map((sharelink) => {
+                return (
+                  <tr key={sharelink._id}>
+                    <td>{sharelink._id}</td>
+                    <td><a href={sharelink.relatedPage.path}>{sharelink.relatedPage.path}</a></td>
+                    <td>{sharelink.expiredAt && <span>{dateFnsFormat(new Date(sharelink.expiredAt), 'yyyy-MM-dd HH:mm')}</span>}</td>
+                    <td>{sharelink.description}</td>
+                    <td>
+                      <button
+                        className="btn btn-outline-warning"
+                        type="button"
+                        shareLinks={sharelink._id}
+                        onClick={() => { this.deleteLinkById(sharelink._id) }}
+                      >
+                        <i className="icon-trash mr-2"></i>Delete
+                      </button>
+                    </td>
+                  </tr>
+                );
+              })}
+            </tbody>
+          </table>
+        </div>
+
+        <DeleteAllShareLinksModal
+          isOpen={this.state.isDeleteConfirmModalShown}
+          onClose={this.closeDeleteConfirmModal}
+          count={adminGeneralSecurityContainer.state.shareLinks.length}
+          onClickDeleteButton={this.deleteAllLinksButtonHandler}
+        />
+
+      </Fragment>
+    );
+  }
+
+}
+
+const ShareLinkSettingWrapper = withUnstatedContainers(ShareLinkSetting, [AppContainer, AdminGeneralSecurityContainer]);
+
+ShareLinkSetting.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+};
+
+export default withTranslation()(ShareLinkSettingWrapper);

+ 102 - 23
src/client/js/components/OutsideShareLinkModal.jsx

@@ -15,31 +15,110 @@ import PageContainer from '../services/PageContainer';
 import ShareLinkList from './ShareLinkList';
 import ShareLinkForm from './ShareLinkForm';
 
-const OutsideShareLinkModal = (props) => {
-
-  /* const { t } = props; */
-
-  return (
-    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose} className="grw-create-page">
-      <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">Title
-      </ModalHeader>
-      <ModalBody>
-        <div className="container">
-          <div className="form-inline mb-3">
-            <h4>Shared Link List</h4>
-            <button className="ml-auto btn btn-danger" type="button">Delete all links</button>
-          </div>
+import { toastSuccess, toastError } from '../util/apiNotification';
+
+class OutsideShareLinkModal extends React.Component {
+
+  constructor() {
+    super();
+    this.state = {
+      shareLinks: [],
+      isOpenShareLinkForm: false,
+    };
+
+    this.toggleShareLinkFormHandler = this.toggleShareLinkFormHandler.bind(this);
+    this.deleteAllLinksButtonHandler = this.deleteAllLinksButtonHandler.bind(this);
+    this.deleteLinkById = this.deleteLinkById.bind(this);
+  }
+
+  componentDidMount() {
+    this.retrieveShareLinks();
+  }
+
+  async retrieveShareLinks() {
+    const { appContainer, pageContainer } = this.props;
+    const { pageId } = pageContainer.state;
+
+    try {
+      const res = await appContainer.apiv3.get('/share-links/', { relatedPage: pageId });
+      const { shareLinksResult } = res.data;
+      this.setState({ shareLinks: shareLinksResult });
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }
+
+  toggleShareLinkFormHandler() {
+    this.setState({ isOpenShareLinkForm: !this.state.isOpenShareLinkForm });
+    this.retrieveShareLinks();
+  }
 
-          <div>
-            <ShareLinkList />
-            <button className="btn btn-outline-secondary d-block mx-auto px-5 mb-3" type="button">+</button>
-            <ShareLinkForm />
+  async deleteAllLinksButtonHandler() {
+    const { t, appContainer, pageContainer } = this.props;
+    const { pageId } = pageContainer.state;
+
+    try {
+      const res = await appContainer.apiv3.delete('/share-links/', { relatedPage: pageId });
+      const count = res.data.n;
+      toastSuccess(t('toaster.remove_share_link', { count }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+    this.retrieveShareLinks();
+  }
+
+  async deleteLinkById(shareLinkId) {
+    const { t, appContainer } = this.props;
+
+    try {
+      const res = await appContainer.apiv3Delete(`/share-links/${shareLinkId}`);
+      const { deletedShareLink } = res.data;
+      toastSuccess(t('toaster.remove_share_link_success', { shareLinkId: deletedShareLink._id }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+    this.retrieveShareLinks();
+  }
+
+  render() {
+    return (
+      <Modal size="lg" isOpen={this.props.isOpen} toggle={this.props.onClose}>
+        <ModalHeader tag="h4" toggle={this.props.onClose} className="bg-primary text-light">Title
+        </ModalHeader>
+        <ModalBody>
+          <div className="container">
+            <div className="form-inline mb-3">
+              <h4>Shared Link List</h4>
+              <button className="ml-auto btn btn-danger" type="button" onClick={this.deleteAllLinksButtonHandler}>Delete all links</button>
+            </div>
+
+            <div>
+              <ShareLinkList
+                shareLinks={this.state.shareLinks}
+                onClickDeleteButton={this.deleteLinkById}
+              />
+              <button
+                className="btn btn-outline-secondary d-block mx-auto px-5 mb-3"
+                type="button"
+                onClick={this.toggleShareLinkFormHandler}
+              >
+                {this.state.isOpenShareLinkForm ? 'Close' : 'New'}
+              </button>
+              {this.state.isOpenShareLinkForm && <ShareLinkForm onCloseForm={this.toggleShareLinkFormHandler} />}
+            </div>
           </div>
-        </div>
-      </ModalBody>
-    </Modal>
-  );
-};
+        </ModalBody>
+      </Modal>
+    );
+  }
+
+}
 
 /**
  * Wrapper component for using unstated

+ 0 - 1
src/client/js/components/Page/PageShareManagement.jsx

@@ -2,7 +2,6 @@ import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
-
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 import AppContainer from '../../services/AppContainer';

+ 237 - 45
src/client/js/components/ShareLinkForm.jsx

@@ -1,68 +1,260 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
+import parse from 'date-fns/parse';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 
+import { toastSuccess, toastError } from '../util/apiNotification';
 
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 
-const ShareLinkForm = (props) => {
-  return (
-    <div className="share-link-form border p-3">
-      <h4>Expiration Date</h4>
-      <form>
-        <fieldset className="form-group">
-          <div className="row">
-            <legend className="col-form-label col-3"></legend>
-            <div>
-              <div className="custom-control custom-radio mb-2">
-                <input id="customRadio1" name="customRadio" type="radio" className="custom-control-input"></input>
-                <label className="custom-control-label" htmlFor="customRadio1">Unlimited</label>
-              </div>
-
-              <div className="custom-control custom-radio mb-2">
-                <input id="customRadio2" name="customRadio" type="radio" className="custom-control-input"></input>
-                <label className="custom-control-label" htmlFor="customRadio2">
-                  <div className="row align-items-center m-0">
-                    <input className="form-control col-2" type="number" min="1" max="7" value="7"></input>
-                    <span className="col-auto">Days</span>
-                  </div>
-                </label>
-              </div>
-
-              <div className="custom-control custom-radio mb-2">
-                <input id="customRadio3" name="customRadio" type="radio" className="custom-control-input"></input>
-                <label className="custom-control-label" htmlFor="customRadio3">
-                  Custom
-                  <div className="date-picker">Date Picker</div>
-                </label>
-              </div>
+class ShareLinkForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      expirationType: 'unlimited',
+      numberOfDays: 7,
+      description: '',
+      customExpirationDate: dateFnsFormat(new Date(), 'yyyy-MM-dd'),
+      customExpirationTime: dateFnsFormat(new Date(), 'hh:mm:s'),
+    };
+
+    this.handleChangeExpirationType = this.handleChangeExpirationType.bind(this);
+    this.handleChangeNumberOfDays = this.handleChangeNumberOfDays.bind(this);
+    this.handleChangeDescription = this.handleChangeDescription.bind(this);
+    this.handleIssueShareLink = this.handleIssueShareLink.bind(this);
+  }
+
+  /**
+   * change expirationType
+   * @param {string} expirationType
+   */
+  handleChangeExpirationType(expirationType) {
+    this.setState({ expirationType });
+  }
+
+  /**
+   * change numberOfDays
+   * @param {number} numberOfDays
+   */
+  handleChangeNumberOfDays(numberOfDays) {
+    this.setState({ numberOfDays });
+  }
+
+  /**
+   * change description
+   * @param {string} description
+   */
+  handleChangeDescription(description) {
+    this.setState({ description });
+  }
+
+  /**
+   * change customExpirationDate
+   * @param {date} customExpirationDate
+   */
+  handleChangeCustomExpirationDate(customExpirationDate) {
+    this.setState({ customExpirationDate });
+  }
+
+  /**
+   * change customExpirationTime
+   * @param {date} customExpirationTime
+   */
+  handleChangeCustomExpirationTime(customExpirationTime) {
+    this.setState({ customExpirationTime });
+  }
+
+  /**
+   * Generate expiredAt by expirationType
+   */
+  generateExpired() {
+    const { expirationType } = this.state;
+    let expiredAt;
+
+    if (expirationType === 'unlimited') {
+      return null;
+    }
+
+    if (expirationType === 'numberOfDays') {
+      const date = new Date();
+      date.setDate(date.getDate() + this.state.numberOfDays);
+      expiredAt = date;
+    }
+
+    if (expirationType === 'custom') {
+      const { customExpirationDate, customExpirationTime } = this.state;
+      expiredAt = parse(`${customExpirationDate}T${customExpirationTime}`, "yyyy-MM-dd'T'HH:mm:ss", new Date());
+    }
+
+    return expiredAt;
+  }
+
+  closeForm() {
+    const { onCloseForm } = this.props;
+
+    if (onCloseForm == null) {
+      return;
+    }
+    onCloseForm();
+  }
+
+  async handleIssueShareLink() {
+    const {
+      t, appContainer, pageContainer,
+    } = this.props;
+    const { pageId } = pageContainer.state;
+    const { description } = this.state;
+
+    let expiredAt;
+
+    try {
+      expiredAt = this.generateExpired();
+    }
+    catch (err) {
+      return toastError(err);
+    }
+
+    try {
+      await appContainer.apiv3Post('/share-links/', { relatedPage: pageId, expiredAt, description });
+      this.closeForm();
+      toastSuccess(t('toaster.issue_share_link'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  }
+
+  renderExpirationTypeOptions() {
+    const { expirationType } = this.state;
+
+    return (
+      <div className="form-group">
+        <div className="custom-control custom-radio offset-4 mb-2">
+          <input
+            type="radio"
+            className="custom-control-input"
+            id="customRadio1"
+            name="expirationType"
+            value="customRadio1"
+            checked={expirationType === 'unlimited'}
+            onChange={() => { this.handleChangeExpirationType('unlimited') }}
+          />
+          <label className="custom-control-label" htmlFor="customRadio1">Unlimited</label>
+        </div>
+
+        <div className="custom-control custom-radio offset-4 mb-2">
+          <input
+            type="radio"
+            className="custom-control-input"
+            id="customRadio2"
+            value="customRadio2"
+            checked={expirationType === 'numberOfDays'}
+            onChange={() => { this.handleChangeExpirationType('numberOfDays') }}
+            name="expirationType"
+          />
+          <label className="custom-control-label" htmlFor="customRadio2">
+            <div className="row align-items-center m-0">
+              <input
+                type="number"
+                min="1"
+                className="col-4"
+                name="expirationType"
+                value={this.state.numberOfDays}
+                onFocus={() => { this.handleChangeExpirationType('numberOfDays') }}
+                onChange={e => this.handleChangeNumberOfDays(Number(e.target.value))}
+              />
+              <span className="col-auto">Days</span>
             </div>
-          </div>
-        </fieldset>
+          </label>
+        </div>
 
-        <hr />
+        <div className="custom-control custom-radio offset-4 mb-2">
+          <input
+            type="radio"
+            className="custom-control-input"
+            id="customRadio3"
+            name="expirationType"
+            value="customRadio3"
+            checked={expirationType === 'custom'}
+            onChange={() => { this.handleChangeExpirationType('custom') }}
+          />
+          <label className="custom-control-label" htmlFor="customRadio3">
+            Custom
+          </label>
+          <input
+            type="date"
+            className="ml-3"
+            name="customExpirationDate"
+            value={this.state.customExpirationDate}
+            onFocus={() => { this.handleChangeExpirationType('custom') }}
+            onChange={e => this.handleChangeCustomExpirationDate(e.target.value)}
+          />
+          <input
+            type="time"
+            className="ml-3"
+            name="customExpiration"
+            value={this.state.customExpirationTime}
+            onFocus={() => { this.handleChangeExpirationType('custom') }}
+            onChange={e => this.handleChangeCustomExpirationTime(e.target.value)}
+          />
+        </div>
+      </div>
+    );
+  }
 
-        <div className="form-group row">
-          <label htmlFor="inputDesc" className="offset-3 col-form-label">Description</label>
-          <div className="col-5">
-            <input type="text" className="form-control" id="inputDesc" placeholder="Enter description"></input>
-          </div>
+  renderDescriptionForm() {
+    return (
+      <div className="form-group row">
+        <label htmlFor="inputDesc" className="col-md-4 col-form-label">Description</label>
+        <div className="col-md-4">
+          <input
+            type="text"
+            className="form-control"
+            id="inputDesc"
+            placeholder="Enter description"
+            value={this.state.description}
+            onChange={e => this.handleChangeDescription(e.target.value)}
+          />
         </div>
+      </div>
+    );
+  }
 
-        <div className="form-inline">
-          <button type="button" className="ml-auto btn btn-primary">Issue</button>
+  render() {
+    return (
+      <div className="share-link-form border p-3">
+        <h4>Expiration Date</h4>
+        {this.renderExpirationTypeOptions()}
+        <hr />
+        {this.renderDescriptionForm()}
+        <div className="text-right">
+          <button type="button" className="btn btn-primary" onClick={this.handleIssueShareLink}>
+            Issue
+          </button>
         </div>
-      </form>
-    </div>
-  );
-};
+      </div>
+    );
+  }
+
+}
 
 /**
  * Wrapper component for using unstated
  */
 const ShareLinkFormWrapper = withUnstatedContainers(ShareLinkForm, [AppContainer, PageContainer]);
 
+ShareLinkForm.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+  onCloseForm: PropTypes.func,
+
 export default withTranslation()(ShareLinkFormWrapper);

+ 21 - 35
src/client/js/components/ShareLinkList.jsx

@@ -1,7 +1,9 @@
 import React from 'react';
-import * as toastr from 'toastr';
+import PropTypes from 'prop-types';
+
 
 import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
 
 import { withUnstatedContainers } from './UnstatedUtils';
 
@@ -9,46 +11,23 @@ import AppContainer from '../services/AppContainer';
 
 const ShareLinkList = (props) => {
 
-  function deleteLinkHandler(shareLink) {
-    try {
-      // call api
-      toastr.success(`Successfully deleted ${shareLink._id}`);
-    }
-    catch (err) {
-      toastr.error(new Error(`Failed to delete ${shareLink._id}`));
+  function deleteLinkHandler(shareLinkId) {
+    if (props.onClickDeleteButton == null) {
+      return;
     }
+    props.onClickDeleteButton(shareLinkId);
   }
 
-  function GetShareLinkList() {
-    // dummy data
-    const dummyDate = new Date().toString();
-    const shareLinks = [
-      {
-        _id: '507f1f77bcf86cd799439011', link: '/507f1f77bcf86cd799439011', expiration: dummyDate, description: 'foobar',
-      },
-      {
-        _id: '52fcebd19a5c4ea066dbfa12', link: '/52fcebd19a5c4ea066dbfa12', expiration: dummyDate, description: 'test',
-      },
-      {
-        _id: '54759eb3c090d83494e2d804', link: '/54759eb3c090d83494e2d804', expiration: dummyDate, description: 'hoge',
-      },
-      {
-        _id: '5349b4ddd2781d08c09890f3', link: '/5349b4ddd2781d08c09890f3', expiration: dummyDate, description: 'fuga',
-      },
-      {
-        _id: '5349b4ddd2781d08c09890f4', link: '/5349b4ddd2781d08c09890f4', expiration: dummyDate, description: 'piyo',
-      },
-    ];
-
+  function renderShareLinks() {
     return (
       <>
-        {shareLinks.map(shareLink => (
-          <tr>
-            <td>{shareLink.link}</td>
-            <td>{shareLink.expiration}</td>
+        {props.shareLinks.map(shareLink => (
+          <tr key={shareLink._id}>
+            <td>{shareLink._id}</td>
+            <td>{shareLink.expiredAt && <span>{dateFnsFormat(new Date(shareLink.expiredAt), 'yyyy-MM-dd HH:mm')}</span>}</td>
             <td>{shareLink.description}</td>
             <td>
-              <button className="btn btn-outline-warning" type="button" onClick={() => deleteLinkHandler(shareLink)}>
+              <button className="btn btn-outline-warning" type="button" onClick={() => deleteLinkHandler(shareLink._id)}>
                 <i className="icon-trash"></i>Delete
               </button>
             </td>
@@ -70,7 +49,7 @@ const ShareLinkList = (props) => {
           </tr>
         </thead>
         <tbody>
-          <GetShareLinkList />
+          {renderShareLinks()}
         </tbody>
       </table>
     </div>
@@ -82,4 +61,11 @@ const ShareLinkList = (props) => {
  */
 const ShareLinkListWrapper = withUnstatedContainers(ShareLinkList, [AppContainer]);
 
+ShareLinkList.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  shareLinks: PropTypes.array.isRequired,
+  onClickDeleteButton: PropTypes.func,
+
 export default withTranslation()(ShareLinkListWrapper);

+ 29 - 0
src/client/js/services/AdminGeneralSecurityContainer.js

@@ -30,6 +30,10 @@ export default class AdminGeneralSecurityContainer extends Container {
       isGitHubEnabled: false,
       isTwitterEnabled: false,
       setupStrategies: [],
+      shareLinks: [],
+      totalshareLinks: 0,
+      shareLinksPagingLimit: Infinity,
+      shareLinksActivePage: 1,
     };
 
   }
@@ -151,6 +155,31 @@ export default class AdminGeneralSecurityContainer extends Container {
     }
   }
 
+  /**
+   * Retrieve All Sharelinks
+   */
+  async retrieveShareLinksByPagingNum(page) {
+
+    const params = {
+      page,
+    };
+
+    const { data } = await this.appContainer.apiv3.get('/security-setting/all-share-links', params);
+
+    if (data.paginateResult == null) {
+      throw new Error('data must conclude \'paginateResult\' property.');
+    }
+
+    const { docs: shareLinks, totalDocs: totalshareLinks, limit: shareLinksPagingLimit } = data.paginateResult;
+
+    this.setState({
+      shareLinks,
+      totalshareLinks,
+      shareLinksPagingLimit,
+      shareLinksActivePage: page,
+    });
+  }
+
   /**
    * Switch local enabled
    */

+ 8 - 0
src/server/models/share-link.js

@@ -25,6 +25,14 @@ schema.plugin(mongoosePaginate);
 schema.plugin(uniqueValidator);
 
 module.exports = function(crowi) {
+
+  schema.methods.isExpired = function() {
+    if (this.expiredAt == null) {
+      return false;
+    }
+    return this.expiredAt.getTime() < new Date().getTime();
+  };
+
   const model = mongoose.model('ShareLink', schema);
   return model;
 };

+ 33 - 3
src/server/routes/apiv3/security-setting.js

@@ -565,20 +565,24 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: suceed to get all share links
    */
-  router.get('/all-share-links/', /* loginRequiredStrictly, adminRequired, csrf, ApiV3FormValidator, */ async(req, res) => {
+  router.get('/all-share-links/', loginRequiredStrictly, adminRequired, async(req, res) => {
     const ShareLink = crowi.model('ShareLink');
     const page = parseInt(req.query.page) || 1;
     const limit = 10;
     const linkQuery = {};
     try {
-      const shareLinksResult = await ShareLink.paginate(
+      const paginateResult = await ShareLink.paginate(
         linkQuery,
         {
           page,
           limit,
+          populate: {
+            path: 'relatedPage',
+            select: 'path',
+          },
         },
       );
-      return res.apiv3({ shareLinksResult });
+      return res.apiv3({ paginateResult });
     }
     catch (err) {
       const msg = 'Error occured in get share link';
@@ -587,6 +591,32 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *    /_api/v3/security-setting/all-share-links:
+   *      delete:
+   *        tags: [ShareLinkSettings, apiv3]
+   *        description: Delete All ShareLinks at Share Link Setting
+   *        responses:
+   *          200:
+   *            description: succeed to delete all share links
+   */
+
+  router.delete('/all-share-links/', loginRequiredStrictly, adminRequired, async(req, res) => {
+    const ShareLink = crowi.model('ShareLink');
+    try {
+      const removedAct = await ShareLink.remove({});
+      const removeTotal = await removedAct.n;
+      return res.apiv3({ removeTotal });
+    }
+    catch (err) {
+      const msg = 'Error occured in delete all share links';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'failed-to-delete-all-share-links'));
+    }
+  });
+
   /**
    * @swagger
    *

+ 42 - 22
src/server/routes/apiv3/share-links.js

@@ -8,7 +8,7 @@ const express = require('express');
 
 const router = express.Router();
 
-const { body, query } = require('express-validator/check');
+const { body } = require('express-validator/check');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
@@ -23,9 +23,10 @@ const today = new Date();
  */
 
 module.exports = (crowi) => {
-  const loginRequired = require('../../middleware/login-required')(crowi);
-  const csrf = require('../../middleware/csrf')(crowi);
-  const { ApiV3FormValidator } = crowi.middlewares;
+  const loginRequired = require('../../middlewares/login-required')(crowi);
+  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const csrf = require('../../middlewares/csrf')(crowi);
+  const apiV3FormValidator = require('../../middlewares/apiv3-form-validator')(crowi);
   const ShareLink = crowi.model('ShareLink');
 
 
@@ -48,7 +49,7 @@ module.exports = (crowi) => {
    *          200:
    *            description: Succeeded to get share links
    */
-  router.get('/', loginRequired, csrf, ApiV3FormValidator, async(req, res) => {
+  router.get('/', loginRequired, async(req, res) => {
     const { relatedPage } = req.query;
     try {
       const shareLinksResult = await ShareLink.find({ relatedPage: { $in: relatedPage } });
@@ -64,13 +65,10 @@ module.exports = (crowi) => {
   validator.shareLinkStatus = [
     // validate the page id is null
     body('relatedPage').not().isEmpty().withMessage('Page Id is null'),
-
     // validate expireation date is not empty, is not before today and is date.
-    body('expiredAt').isAfter(today.toString()).withMessage('Your Selected date is past'),
-
+    body('expiredAt').if(value => value != null).isAfter(today.toString()).withMessage('Your Selected date is past'),
     // validate the length of description is max 100.
     body('description').isLength({ min: 0, max: 100 }).withMessage('Max length is 100'),
-
   ];
 
   /**
@@ -103,7 +101,7 @@ module.exports = (crowi) => {
    *            description: Succeeded to create one share link
    */
 
-  router.post('/', loginRequired, csrf, validator.shareLinkStatus, ApiV3FormValidator, async(req, res) => {
+  router.post('/', loginRequired, csrf, validator.shareLinkStatus, apiV3FormValidator, async(req, res) => {
     const { relatedPage, expiredAt, description } = req.body;
     const ShareLink = crowi.model('ShareLink');
 
@@ -126,22 +124,19 @@ module.exports = (crowi) => {
   *        tags: [ShareLinks]
   *        summary: /share-links/
   *        description: delete all share links related one page
-  *        requestBody:
-  *           required: true
-  *           content:
-  *             application/json:
-  *               schema:
-  *                 properties:
-  *                   relatedPage:
-  *                     type: string
-  *                     description: delete all share links that related one page
+  *        parameters:
+  *          - name: relatedPage
+  *            in: query
+  *            required: true
+  *            description: page id of share link
+  *            schema:
+  *              type: string
   *        responses:
   *          200:
   *            description: Succeeded to delete o all share links related one page
   */
   router.delete('/', loginRequired, csrf, async(req, res) => {
-    const { relatedPage } = req.body;
-    const ShareLink = crowi.model('ShareLink');
+    const { relatedPage } = req.query;
 
     try {
       const deletedShareLink = await ShareLink.remove({ relatedPage });
@@ -154,6 +149,31 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+  * @swagger
+  *
+  *    /share-links/all:
+  *      delete:
+  *        tags: [ShareLinks]
+  *        description: delete all share links
+  *        responses:
+  *          200:
+  *            description: Succeeded to remove all share links
+  */
+  router.delete('/all', loginRequired, adminRequired, csrf, async(req, res) => {
+
+    try {
+      const deletedShareLink = await ShareLink.deleteMany({});
+      const { deletedCount } = deletedShareLink;
+      return res.apiv3({ deletedCount });
+    }
+    catch (err) {
+      const msg = 'Error occurred in delete all share link';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'delete-all-shareLink-failed'));
+    }
+  });
+
   /**
   * @swagger
   *
@@ -177,7 +197,7 @@ module.exports = (crowi) => {
 
     try {
       const deletedShareLink = await ShareLink.findOneAndRemove({ _id: id });
-      return res.apiv3(deletedShareLink);
+      return res.apiv3({ deletedShareLink });
     }
     catch (err) {
       const msg = 'Error occurred in delete share link';

+ 5 - 4
src/server/routes/page.js

@@ -449,15 +449,16 @@ module.exports = function(crowi, app) {
     const view = `layout-${layoutName}/shared_page`;
 
     const shareLink = await ShareLink.findOne({ _id: linkId }).populate('relatedPage');
-    let page = shareLink.relatedPage;
 
-    if (page == null) {
-      // page is not found
+    if (shareLink == null || shareLink.relatedPage == null) {
+      // page or sharelink are not found
       return res.render(`layout-${layoutName}/not_found_shared_page`);
     }
 
+    let page = shareLink.relatedPage;
+
     // check if share link is expired
-    if (shareLink.expiredAt.getTime() < new Date().getTime()) {
+    if (shareLink.isExpired()) {
       // page is not found
       return res.render(`layout-${layoutName}/expired_shared_page`);
     }

+ 108 - 0
src/test/models/shareLink.test.js

@@ -0,0 +1,108 @@
+const { getInstance } = require('../setup-crowi');
+
+describe('ShareLink', () => {
+  // eslint-disable-next-line no-unused-vars
+  let crowi;
+  let ShareLink;
+  let Page;
+
+  beforeAll(async(done) => {
+    crowi = await getInstance();
+    ShareLink = crowi.model('ShareLink');
+    Page = require('@server/routes/page')(crowi);
+
+
+    done();
+  });
+
+  describe('accessShareLink', () => {
+    const req = {
+      path: '/share/:id',
+      params: {
+        linkId: 'someLinkId',
+      },
+    };
+
+    const res = {
+      render: jest.fn((page, renderVars = null) => { return { page, renderVars } }),
+    };
+
+    const findOneResult = {
+      populate: null,
+    };
+
+    const relatedPage = {
+      path: '/somePath',
+      populateDataToShowRevision: () => {
+        return {
+          revision: {},
+          creator: {},
+        };
+      },
+    };
+
+    test('share link is not found', async() => {
+
+      findOneResult.populate = jest.fn(() => { return null });
+
+      jest.spyOn(ShareLink, 'findOne').mockImplementation(() => {
+        return findOneResult;
+      });
+
+      const response = await Page.showSharedPage(req, res);
+
+      expect(findOneResult.populate).toHaveBeenCalled();
+      expect(res.render).toHaveBeenCalled();
+      expect(response.page).toEqual('layout-growi/not_found_shared_page');
+      expect(response.renderVars).toEqual(null);
+    });
+
+    test('share link is found, but it does not have Page', async() => {
+
+      findOneResult.populate = jest.fn(() => { return { _id: 'somePageId' } });
+
+      jest.spyOn(ShareLink, 'findOne').mockImplementation(() => {
+        return findOneResult;
+      });
+      const response = await Page.showSharedPage(req, res);
+
+      expect(findOneResult.populate).toHaveBeenCalled();
+      expect(res.render).toHaveBeenCalled();
+      expect(response.page).toEqual('layout-growi/not_found_shared_page');
+      expect(response.renderVars).toEqual(null);
+    });
+
+
+    test('share link is found, but it is expired', async() => {
+
+      findOneResult.populate = jest.fn(() => { return { _id: 'somePageId', relatedPage, isExpired: () => { return true } } });
+
+      jest.spyOn(ShareLink, 'findOne').mockImplementation(() => {
+        return findOneResult;
+      });
+
+      const response = await Page.showSharedPage(req, res);
+
+      expect(findOneResult.populate).toHaveBeenCalled();
+      expect(res.render).toHaveBeenCalled();
+      expect(response.page).toEqual('layout-growi/expired_shared_page');
+      expect(response.renderVars).toEqual(null);
+    });
+
+    test('share link is found, and it has the page you can see', async() => {
+
+      findOneResult.populate = jest.fn(() => { return { _id: 'somePageId', relatedPage, isExpired: () => { return false } } });
+
+      jest.spyOn(ShareLink, 'findOne').mockImplementation(() => {
+        return findOneResult;
+      });
+      const response = await Page.showSharedPage(req, res);
+
+      expect(findOneResult.populate).toHaveBeenCalled();
+      expect(res.render).toHaveBeenCalled();
+      expect(response.page).toEqual('layout-growi/shared_page');
+      expect(response.renderVars).not.toEqual(null);
+    });
+  });
+
+});