فهرست منبع

Merge branch 'support/share-link-for-outside-for-merge' into improve/manage-share_link_for_outside

熊谷洸介(Kousuke Kumagai) 5 سال پیش
والد
کامیت
824d79311f

+ 2 - 1
resource/locales/en-US/translation.json

@@ -245,7 +245,8 @@
       "unlinked": "Redirect pages to this page have been deleted.",
       "restricted": "Access to this page is restricted",
       "stale": "More than {{count}} year has passed since last update.",
-      "stale_plural": "More than {{count}} years has passed since last update."
+      "stale_plural": "More than {{count}} years has passed since last update.",
+      "expiration": "This share link will expire <strong>{{expiredAt}}</strong>."
     }
   },
   "page_edit": {

+ 2 - 1
resource/locales/ja/translation.json

@@ -243,7 +243,8 @@
       "duplicated": "このページは <code>%s</code> から複製されました。",
       "unlinked": "このページへのリダイレクトは削除されました。",
       "restricted": "このページの閲覧は制限されています",
-      "stale": "このページは最終更新日から{{count}}年以上が経過しています。"
+      "stale": "このページは最終更新日から{{count}}年以上が経過しています。",
+      "expiration": "この共有パーマリンクの有効期限は <strong>{{expiredAt}}</strong> です。"
     }
   },
   "page_edit": {

+ 3 - 0
src/client/js/bootstrap.jsx

@@ -7,6 +7,7 @@ import SearchTop from './components/Navbar/SearchTop';
 import NavbarToggler from './components/Navbar/NavbarToggler';
 import PersonalDropdown from './components/Navbar/PersonalDropdown';
 import Sidebar from './components/Sidebar';
+import ShareLinkAlert from './components/Page/ShareLinkAlert';
 import StaffCredit from './components/StaffCredit/StaffCredit';
 
 import AppContainer from './services/AppContainer';
@@ -51,6 +52,8 @@ const componentMappings = {
 
   'grw-sidebar-wrapper': <Sidebar />,
 
+  'share-link-alert': <ShareLinkAlert />,
+
   'staff-credit': <StaffCredit />,
 };
 

+ 21 - 2
src/client/js/components/Admin/Security/ShareLinkSetting.jsx

@@ -11,8 +11,27 @@ class ShareLinkSetting extends React.Component {
 
   render() {
     return (
-      <div>
-        Here is ShareLinkSetting.
+      <div className="container">
+        <div className="form-inline mb-3">
+          <h2 className="alert-anchor border-bottom">Shared Link List</h2>
+        </div>
+        <div>
+            <button className="ml-auto btn btn-danger" type="button">Delete all links</button>
+          </div>
+
+        <div>
+        <tr>
+            <td>link</td>
+            <td>pagePath</td>
+            <td>expiration</td>
+            <td>description</td>
+            <td>
+              <button className="btn btn-outline-warning" type="button">
+                <i className="icon-trash"></i>Delete
+              </button>
+            </td>
+          </tr>
+        </div>
       </div>
     );
   }

+ 17 - 8
src/client/js/components/OutsideShareLinkModal.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 
 import {
@@ -16,24 +16,33 @@ import ShareLinkList from './ShareLinkList';
 import ShareLinkForm from './ShareLinkForm';
 
 const OutsideShareLinkModal = (props) => {
+  const [isOpenShareLinkForm, setIsOpenShareLinkForm] = useState(false);
 
-  /* const { t } = props; */
+  function toggleShareLinkFormHandler() {
+    setIsOpenShareLinkForm(!isOpenShareLinkForm);
+  }
 
   return (
-    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose} className="grw-create-page">
+    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose}>
       <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">Title
       </ModalHeader>
       <ModalBody>
         <div className="container">
-          <div className="row align-items-center mb-3">
-            <h4 className="col-10">Shared Link List</h4>
-            <button className="col btn btn-danger" type="button">Delete all links</button>
+          <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>
 
           <div>
             <ShareLinkList />
-            <button className="btn btn-outline-secondary d-block mx-auto px-5 mb-3" type="button">+</button>
-            <ShareLinkForm />
+            <button
+              className="btn btn-outline-secondary d-block mx-auto px-5 mb-3"
+              type="button"
+              onClick={toggleShareLinkFormHandler}
+            >
+              {isOpenShareLinkForm ? 'Close' : 'New'}
+            </button>
+            {isOpenShareLinkForm && <ShareLinkForm />}
           </div>
         </div>
       </ModalBody>

+ 9 - 10
src/client/js/components/Page/PageShareManagement.jsx

@@ -10,7 +10,7 @@ import OutsideShareLinkModal from '../OutsideShareLinkModal';
 
 
 const PageShareManagement = (props) => {
-  const { t, appContainer/* , pageContainer */ } = props;
+  const { t, appContainer, pageContainer } = props;
 
   const { currentUser } = appContainer;
 
@@ -26,12 +26,10 @@ const PageShareManagement = (props) => {
 
   function renderModals() {
     return (
-      <>
-        <OutsideShareLinkModal
-          isOpen={isOutsideShareLinkModalShown}
-          onClose={closeOutsideShareLinkModalHandler}
-        />
-      </>
+      <OutsideShareLinkModal
+        isOpen={isOutsideShareLinkModalShown}
+        onClose={closeOutsideShareLinkModalHandler}
+      />
     );
   }
 
@@ -40,7 +38,7 @@ const PageShareManagement = (props) => {
       <>
         <button
           type="button"
-          className="nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret"
+          className="btn-link nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret"
           data-toggle="dropdown"
         >
           <i className="icon-share"></i>
@@ -54,7 +52,7 @@ const PageShareManagement = (props) => {
       <>
         <button
           type="button"
-          className="nav-link bg-transparent"
+          className="btn nav-link bg-transparent dropdown-toggle dropdown-toggle-no-caret disabled"
           id="auth-guest-tltips"
         >
           <i className="icon-share"></i>
@@ -72,7 +70,8 @@ const PageShareManagement = (props) => {
       {currentUser == null ? renderGuestUser() : renderCurrentUser()}
       <div className="dropdown-menu dropdown-menu-right">
         <button className="dropdown-item" type="button" onClick={openOutsideShareLinkModalHandler}>
-          <i className="icon-link"></i> {t('Shere this page link to public')}
+          <i className="icon-fw icon-link"></i>{t('Shere this page link to public')}
+          <span className="ml-2 badge badge-info badge-pill">{pageContainer.state.shareLinksNumber}</span>
         </button>
       </div>
       {renderModals()}

+ 46 - 0
src/client/js/components/Page/ShareLinkAlert.jsx

@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+const ShareLinkAlert = (props) => {
+  const { t } = props;
+  const shareContent = document.getElementById('is-shared-page');
+  const expiredAt = shareContent.getAttribute('data-share-link-expired-at');
+  const createdAt = shareContent.getAttribute('data-share-link-created-at');
+  const wholeTime = new Date(expiredAt).getTime() - new Date(createdAt).getTime();
+  const remainingTime = new Date(expiredAt).getTime() - new Date().getTime();
+  const ratio = remainingTime / wholeTime;
+
+  function specifyColor() {
+    let color;
+    if (ratio >= 0.75) {
+      color = 'success';
+    }
+    else if (ratio < 0.75 && ratio >= 0.5) {
+      color = 'info';
+    }
+    else if (ratio < 0.5 && ratio >= 0.25) {
+      color = 'warning';
+    }
+    else {
+      color = 'danger';
+    }
+    return color;
+  }
+
+  return (
+    <p className={`alert alert-${specifyColor()} py-3 px-4`}>
+      <i className="icon-fw icon-link"></i>
+      {/* eslint-disable-next-line react/no-danger */}
+      <span dangerouslySetInnerHTML={{ __html: t('page_page.notice.expiration', { expiredAt }) }} />
+    </p>
+  );
+};
+
+
+ShareLinkAlert.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+};
+
+export default withTranslation()(ShareLinkAlert);

+ 175 - 46
src/client/js/components/ShareLinkForm.jsx

@@ -1,61 +1,190 @@
 import React from 'react';
 
 import { withTranslation } from 'react-i18next';
+import dateFnsFormat from 'date-fns/format';
 
 import { createSubscribedElement } from './UnstatedUtils';
 
-
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 
-const ShareLinkForm = (props) => {
-  return (
-    <div className="share-link-form border">
-      <h4 className="ml-3">Expiration Date</h4>
-      <form>
-        <div className="form-group">
-          <div className="custom-control custom-radio offset-4 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 offset-4 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 offset-4 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>
-
-          <hr />
-
-          <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"></input>
-            </div>
-          </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 });
+  }
+
+  handleIssueShareLink() {
+    // use these options
+    console.log(this.state);
+    console.log('発行する!');
+  }
+
+  renderExpirationTypeOptions() {
+    const { expirationType } = this.state;
 
-          <div className="form-group row">
-            <div className="offset-8 col">
-              <button type="button" className="btn btn-primary">Issue</button>
+    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>
+          </label>
         </div>
-      </form>
-    </div>
-  );
-};
+
+        <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>
+    );
+  }
+
+  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>
+    );
+  }
+
+  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>
+      </div>
+    );
+  }
+
+}
 
 const ShareLinkFormWrapper = (props) => {
   return createSubscribedElement(ShareLinkForm, props, [AppContainer, PageContainer]);

+ 49 - 14
src/client/js/components/ShareLinkList.jsx

@@ -1,4 +1,5 @@
 import React from 'react';
+import * as toastr from 'toastr';
 
 import { withTranslation } from 'react-i18next';
 
@@ -8,8 +9,53 @@ import AppContainer from '../services/AppContainer';
 
 const ShareLinkList = (props) => {
 
-  function getShareLinkList() {
-    return ['Replace with API'];
+  function deleteLinkHandler(shareLink) {
+    try {
+      // call api
+      toastr.success(`Successfully deleted ${shareLink._id}`);
+    }
+    catch (err) {
+      toastr.error(new Error(`Failed to delete ${shareLink._id}`));
+    }
+  }
+
+  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',
+      },
+    ];
+
+    return (
+      <>
+        {shareLinks.map(shareLink => (
+          <tr>
+            <td>{shareLink.link}</td>
+            <td>{shareLink.expiration}</td>
+            <td>{shareLink.description}</td>
+            <td>
+              <button className="btn btn-outline-warning" type="button" onClick={() => deleteLinkHandler(shareLink)}>
+                <i className="icon-trash"></i>Delete
+              </button>
+            </td>
+          </tr>
+        ))}
+      </>
+    );
   }
 
   return (
@@ -24,18 +70,7 @@ const ShareLinkList = (props) => {
           </tr>
         </thead>
         <tbody>
-          {
-            getShareLinkList().map((shareLink) => {
-              return (
-                <>
-                  <td>{ shareLink }</td>
-                  <td>{ shareLink }</td>
-                  <td>{ shareLink }</td>
-                  <td>{ shareLink }</td>
-                </>
-              );
-            })
-          }
+          <GetShareLinkList />
         </tbody>
       </table>
     </div>

+ 1 - 0
src/client/js/services/PageContainer.js

@@ -54,6 +54,7 @@ export default class PageContainer extends Container {
       tags: [],
       hasChildren: JSON.parse(mainContent.getAttribute('data-page-has-children')),
       templateTagData: mainContent.getAttribute('data-template-tags') || null,
+      shareLinksNumber:  mainContent.getAttribute('data-share-links-number'),
 
       // latest(on remote) information
       remoteRevisionId: revisionId,

+ 2 - 0
src/client/styles/scss/_override-bootstrap.scss

@@ -79,7 +79,9 @@
   // Dropdowns
   .dropdown-toggle {
     &.btn.disabled {
+      pointer-events: auto;
       cursor: not-allowed;
+      opacity: unset;
     }
 
     // hide caret

+ 2 - 1
src/server/models/share-link.js

@@ -3,6 +3,7 @@
 
 const mongoose = require('mongoose');
 const uniqueValidator = require('mongoose-unique-validator');
+const mongoosePaginate = require('mongoose-paginate-v2');
 
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
@@ -20,7 +21,7 @@ const schema = new mongoose.Schema({
   description: { type: String },
   createdAt: { type: Date, default: Date.now, required: true },
 });
-
+schema.plugin(mongoosePaginate);
 schema.plugin(uniqueValidator);
 
 module.exports = function(crowi) {

+ 41 - 0
src/server/routes/apiv3/security-setting.js

@@ -547,6 +547,47 @@ module.exports = (crowi) => {
     }
   });
 
+
+  /**
+   * @swagger
+   *
+   *    /_api/v3/security-setting/all-share-links:
+   *      get:
+   *        tags: [ShareLinkSettings, apiv3]
+   *        description: Get All ShareLinks at Share Link Setting
+   *        responses:
+   *          200:
+   *            description: all share links
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    securityParams:
+   *                      type: object
+   *                      description: suceed to get all share links
+   */
+  router.get('/all-share-links/', /* loginRequiredStrictly, adminRequired, csrf, ApiV3FormValidator, */ 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(
+        linkQuery,
+        {
+          page,
+          limit,
+        },
+      );
+      return res.apiv3({ shareLinksResult });
+    }
+    catch (err) {
+      const msg = 'Error occured in get share link';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'get-all-share-links-failed'));
+    }
+  });
+
   /**
    * @swagger
    *

+ 35 - 5
src/server/routes/apiv3/share-links.js

@@ -51,8 +51,8 @@ module.exports = (crowi) => {
   router.get('/', loginRequired, csrf, ApiV3FormValidator, async(req, res) => {
     const { relatedPage } = req.query;
     try {
-      const paginateResult = await ShareLink.find({ relatedPage: { $in: relatedPage } });
-      return res.apiv3({ paginateResult });
+      const shareLinksResult = await ShareLink.find({ relatedPage: { $in: relatedPage } });
+      return res.apiv3({ shareLinksResult });
     }
     catch (err) {
       const msg = 'Error occurred in get share link';
@@ -118,10 +118,40 @@ module.exports = (crowi) => {
     }
   });
 
-  // TDOO write swagger
-  router.delete('/all', loginRequired, async(req, res) => {
+  /**
+  * @swagger
+  *
+  *    /share-links/:
+  *      delete:
+  *        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
+  *        responses:
+  *          200:
+  *            description: Succeeded to delete o all share links related one page
+  */
+  router.delete('/', loginRequired, csrf, async(req, res) => {
     const { relatedPage } = req.body;
-    // TODO GW-2694 Delete all share links
+    const ShareLink = crowi.model('ShareLink');
+
+    try {
+      const deletedShareLink = await ShareLink.remove({ relatedPage });
+      return res.apiv3(deletedShareLink);
+    }
+    catch (err) {
+      const msg = 'Error occured in delete share link';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'delete-shareLink-failed'));
+    }
   });
 
   /**

+ 1 - 1
src/server/routes/index.js

@@ -180,7 +180,7 @@ module.exports = function(crowi, app) {
   app.post('/_api/hackmd.discard'        , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.discard);
   app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
 
-  app.get('/share/:linkId', page.showSharedPage, page.notFound);
+  app.get('/share/:linkId', page.showSharedPage);
 
   app.get('/*/$'                   , loginRequired , page.showPageWithEndOfSlash, page.notFound);
   app.get('/*'                     , loginRequired , page.showPage, page.notFound);

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

@@ -395,6 +395,9 @@ module.exports = function(crowi, app) {
     await addRenderVarsForSlack(renderVars, page);
     await addRenderVarsForDescendants(renderVars, path, req.user, offset, limit, true);
 
+    const sharelinksNumber = await ShareLink.countDocuments({ relatedPage: page._id });
+    renderVars.sharelinksNumber = sharelinksNumber;
+
     if (isUserPage(page.path)) {
       // change template
       view = `layout-${layoutName}/user_page`;
@@ -446,17 +449,26 @@ module.exports = function(crowi, app) {
     const layoutName = configManager.getConfig('crowi', 'customize:layout');
     const view = `layout-${layoutName}/shared_page`;
 
-    const shareLink = await ShareLink.find({ _id: linkId }).populate('Page');
-    const page = shareLink.relatedPage;
+    const shareLink = await ShareLink.findOne({ _id: linkId }).populate('relatedPage');
+    let page = shareLink.relatedPage;
 
     if (page == null) {
       // page is not found
-      // TODO GW-2735 create not found page
-      // return res.render(`layout-${layoutName}/not_found_shared_page`);
+      return res.render(`layout-${layoutName}/not_found_shared_page`);
+    }
+
+    // check if share link is expired
+    if (shareLink.expiredAt.getTime() < new Date().getTime()) {
+      // page is not found
+      return res.render(`layout-${layoutName}/expired_shared_page`);
     }
 
     const renderVars = {};
 
+    renderVars.sharelink = shareLink;
+
+    // populate
+    page = await page.populateDataToShowRevision();
     addRendarVarsForPage(renderVars, page);
     addRendarVarsForScope(renderVars, page);
 

+ 13 - 0
src/server/views/layout-growi/expired_shared_page.html

@@ -0,0 +1,13 @@
+{% extends './shared_page.html' %}
+
+{% block content_header %}
+{% endblock %}
+
+{% block content_page %}
+  <div class="col-md-12">
+    <h2 class="text-muted">
+      <i class="icon-ban" aria-hidden="true"></i>
+      Page is expired
+    </h2>
+  </div>
+{% endblock %}

+ 13 - 0
src/server/views/layout-growi/not_found_shared_page.html

@@ -0,0 +1,13 @@
+{% extends './shared_page.html' %}
+
+{% block content_header %}
+{% endblock %}
+
+{% block content_page %}
+  <div class="col-md-12">
+    <h2 class="text-muted">
+      <i class="icon-info" aria-hidden="true"></i>
+      Page is not found
+    </h2>
+  </div>
+{% endblock %}

+ 16 - 13
src/server/views/layout-growi/shared_page.html

@@ -14,23 +14,26 @@
 {% endblock %}
 
 {% block content_main %}
-  <div class="row" id="is-shared-page">
-    <div class="col grw-page-content-container">
+  <div class="row" id="is-shared-page" data-share-link-expired-at="{{ sharelink.expiredAt|datetz('Y/m/d H:i:s')}}" data-share-link-created-at="{{ sharelink.createdAt|datetz('Y/m/d H:i:s')}}">
+    {% block content_page %}
+      <div class="col grw-page-content-container">
+      <div id="share-link-alert"></div>
 
-      {% include '../widget/page_content.html' %}
-      {# force remove #revision-toc from #content_main of parent #}
-      <script>
-        $('#revision-toc').remove();
-      </script>
+        {% include '../widget/page_content.html' %}
+        {# force remove #revision-toc from #content_main of parent #}
+        <script>
+          $('#revision-toc').remove();
+        </script>
 
-    </div>
+      </div>
 
-    {# relocate #revision-toc #}
-    <div class="col-xl-2 col-lg-3 d-none d-lg-block revision-toc-container">
-      <div id="revision-toc" class="revision-toc mt-3 sps sps--abv" data-sps-offset="123">
-        <div id="revision-toc-content" class="revision-toc-content"></div>
+      {# relocate #revision-toc #}
+      <div class="col-xl-2 col-lg-3 d-none d-lg-block revision-toc-container">
+        <div id="revision-toc" class="revision-toc mt-3 sps sps--abv" data-sps-offset="123">
+          <div id="revision-toc-content" class="revision-toc-content"></div>
+        </div>
       </div>
-    </div>
+    {% endblock %}
 
   </div>
 

+ 13 - 0
src/server/views/layout-kibela/expired_shared_page.html

@@ -0,0 +1,13 @@
+{% extends './shared_page.html' %}
+
+{% block content_header %}
+{% endblock %}
+
+{% block content_page %}
+  <div class="col-md-12">
+    <h2 class="text-muted">
+      <i class="icon-ban" aria-hidden="true"></i>
+      Page is expired
+    </h2>
+  </div>
+{% endblock %}

+ 13 - 0
src/server/views/layout-kibela/not_found_shared_page.html

@@ -0,0 +1,13 @@
+{% extends './shared_page.html' %}
+
+{% block content_header %}
+{% endblock %}
+
+{% block content_page %}
+  <div class="col-md-12">
+    <h2 class="text-muted">
+      <i class="icon-info" aria-hidden="true"></i>
+      Page is not found
+    </h2>
+  </div>
+{% endblock %}

+ 16 - 13
src/server/views/layout-kibela/shared_page.html

@@ -14,23 +14,26 @@
 {% endblock %}
 
 {% block content_main %}
-  <div class="row" id="is-shared-page">
-    <div class="col-12 col-xl-9 col-lg-8 bg-white round-corner">
+  <div class="row" id="is-shared-page" data-share-link-expired-at="{{ sharelink.expiredAt|datetz('Y/m/d H:i:s')}}" data-share-link-created-at="{{ sharelink.createdAt|datetz('Y/m/d H:i:s')}}">
+    {% block content_page %}
+      <div class="col-12 col-xl-9 col-lg-8 bg-white round-corner">
+        <div id="share-link-alert"></div>
 
-      {% include '../widget/page_content.html' %}
-      {# force remove #revision-toc from #content_main of parent #}
-      <script>
-        $('#revision-toc').remove();
-      </script>
+        {% include '../widget/page_content.html' %}
+        {# force remove #revision-toc from #content_main of parent #}
+        <script>
+          $('#revision-toc').remove();
+        </script>
 
-    </div>
+      </div>
 
-    {# relocate #revision-toc #}
-    <div class="col-xl-3 col-lg-4 d-none d-lg-block revision-toc-container">
-      <div id="revision-toc" class="revision-toc mt-3 sps sps--abv" data-sps-offset="123">
-        <div id="revision-toc-content" class="revision-toc-content"></div>
+      {# relocate #revision-toc #}
+      <div class="col-xl-3 col-lg-4 d-none d-lg-block revision-toc-container">
+        <div id="revision-toc" class="revision-toc mt-3 sps sps--abv" data-sps-offset="123">
+          <div id="revision-toc-content" class="revision-toc-content"></div>
+        </div>
       </div>
-    </div>
+    {% endblock %}
 
   </div>
 {% endblock %}

+ 1 - 0
src/server/views/widget/page_content.html

@@ -19,6 +19,7 @@
   data-page-creator="{% if page %}{{ page.creator|json }}{% endif %}"
   data-page-updated-at="{% if page %}{{ page.updatedAt|datetz('Y/m/d H:i:s') }}{% endif %}"
   data-page-has-children="{% if pages.length > 0 %}true{% else %}false{% endif %}"
+  data-share-links-number="{% if page %}{{ sharelinksNumber }}{% endif %}"
   >
 {% else %}
 <div id="content-main" class="content-main"