Explorar el Código

Merge branch 'master' into feat/search-plpages

Taichi Masuyama hace 4 años
padre
commit
c52e349c35
Se han modificado 25 ficheros con 196 adiciones y 65 borrados
  1. 1 0
      packages/app/resource/locales/en_US/admin/admin.json
  2. 3 1
      packages/app/resource/locales/en_US/notifications/passwordReset.txt
  3. 3 1
      packages/app/resource/locales/en_US/notifications/userActivation.txt
  4. 1 0
      packages/app/resource/locales/ja_JP/admin/admin.json
  5. 3 1
      packages/app/resource/locales/ja_JP/notifications/passwordReset.txt
  6. 3 1
      packages/app/resource/locales/ja_JP/notifications/userActivation.txt
  7. 1 0
      packages/app/resource/locales/zh_CN/admin/admin.json
  8. 3 1
      packages/app/resource/locales/zh_CN/notifications/passwordReset.txt
  9. 3 1
      packages/app/resource/locales/zh_CN/notifications/userActivation.txt
  10. 25 1
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  11. 21 0
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  12. 24 10
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  13. 27 8
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  14. 18 17
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  15. 2 4
      packages/app/src/components/PageList/PageListItemL.tsx
  16. 5 2
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  17. 1 1
      packages/app/src/components/SearchPage/SortControl.tsx
  18. 4 0
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  19. 11 2
      packages/app/src/server/models/password-reset-order.ts
  20. 12 3
      packages/app/src/server/models/user-registration-order.ts
  21. 7 5
      packages/app/src/server/routes/apiv3/forgot-password.js
  22. 1 1
      packages/app/src/server/routes/apiv3/user-group.js
  23. 5 1
      packages/app/src/server/routes/user-activation.ts
  24. 0 4
      packages/app/src/styles/_search.scss
  25. 12 0
      packages/app/src/styles/style-app.scss

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

@@ -472,6 +472,7 @@
   "user_group_management": {
     "create_group": "Create new group",
     "add_child_group": "Add child group",
+    "remove_child_group": "Remove",
     "deny_create_group": "You can't create a new group with the current settings.",
     "group_name": "Group name",
     "group_example": "e.g. : Group1",

+ 3 - 1
packages/app/resource/locales/en_US/notifications/passwordReset.txt

@@ -2,9 +2,11 @@ Password Reset
 
 Hi, {{ email }}
 
-A request has been received to change the password your GROWI account {{ appTitle }}.
+A request has been received to change the password your GROWI ({{ appTitle }}) account.
 To reset your password, click on the link below.
 
 {{ url }}
 
+This link will expire in 10 minutes at  {{ expiredAt }}.
+
 If you did not request a password reset, you can safely ignore this email.

+ 3 - 1
packages/app/resource/locales/en_US/notifications/userActivation.txt

@@ -2,9 +2,11 @@ Account confirmation
 
 Hi, {{ email }}
 
-An acount has been created in GROWI {{ appTitle }}.
+An acount has been created in GROWI ({{ appTitle }}).
 To activate your account, click on the link below.
 
 {{ url }}
 
+This link will expire in 1 hour at  {{ expiredAt }}.
+
 If you did not created the account, you can safely ignore this email.

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

@@ -471,6 +471,7 @@
   "user_group_management": {
     "create_group": "新規グループの作成",
     "add_child_group": "子グループの追加",
+    "remove_child_group": "解除",
     "deny_create_group": "新規グループの作成はできません。",
     "group_name": "グループ名",
     "group_example": "例: Group1",

+ 3 - 1
packages/app/resource/locales/ja_JP/notifications/passwordReset.txt

@@ -2,9 +2,11 @@
 
 こんにちは, {{ email }}
 
-あなたのGROWIアカウント {{ appTitle }} から、パスワード再設定のリクエストがありました。
+あなたのGROWI ({{ appTitle }}) アカウントから、パスワード再設定のリクエストがありました。
 パスワードをリセットするには、以下のリンクをクリックしてください。
 
 {{ url }}
 
+このリンクは10分後の {{ expiredAt }} に失効します。
+
 もしこのリクエストに心当たりがない場合は、このメールを無視してください。

+ 3 - 1
packages/app/resource/locales/ja_JP/notifications/userActivation.txt

@@ -2,10 +2,12 @@
 
 {{ email }} さん
 
-GROWI {{ appTitle }} で仮登録が完了いたしました。
+GROWI ({{ appTitle }}) で仮登録が完了いたしました。
 
 ご本人様確認のため、下記リンクをクリックし、アカウントの本登録を完了させて下さい。
 
 {{ url }}
 
+このリンクは1時間後の {{ expiredAt }} に失効します。
+
 ※当メールの内容に心当たりがない場合は、このメールを無視してください。

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

@@ -481,6 +481,7 @@
   "user_group_management": {
     "create_group": "创建新组",
     "add_child_group": "添加一个子组",
+    "remove_child_group": "移除",
     "deny_create_group": "不能用当前设置创建新组。",
     "group_name": "组名",
     "group_example": "e.g.:第1组",

+ 3 - 1
packages/app/resource/locales/zh_CN/notifications/passwordReset.txt

@@ -2,9 +2,11 @@
 
 嗨,{{ email }}
 
-已收到更改您 GROWI 帐户 {{appTitle}} 密码的请求。
+已收到更改您 GROWI ({{appTitle}}) 帐户 密码的请求。
 要重置密码,请单击下面的链接。
 
 {{ url }}
 
+这个链接在10分钟后的{ expiredAt }}失效。
+
 如果您没有要求重置密码,则可以放心地忽略此电子邮件。

+ 3 - 1
packages/app/resource/locales/zh_CN/notifications/userActivation.txt

@@ -2,9 +2,11 @@
 
 致{{ email }},
 
-已使用 GROWI {{ appTitle }} 创建帐户。
+已使用 GROWI ({{ appTitle }}) 创建帐户。
 单击下面的链接以激活您的帐户。
 
 {{ url }}
 
+这个链接将在1小时后即{{ expiredAt }}失效。
+
 如果您尚未创建,请忽略此电子邮件。

+ 25 - 1
packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -1,6 +1,7 @@
 import React, {
   FC, useState, useCallback, useEffect,
 } from 'react';
+
 import { useTranslation } from 'react-i18next';
 import { TFunctionResult } from 'i18next';
 import dateFnsFormat from 'date-fns/format';
@@ -16,6 +17,7 @@ type Props = {
   childUserGroups: IUserGroupHasId[],
   isAclEnabled: boolean,
   onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
+  onRemove?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
 };
 
@@ -73,7 +75,7 @@ const UserGroupTable: FC<Props> = (props: Props) => {
     });
   };
 
-  const onClickEdit = (e) => {
+  const onClickEdit = async(e) => {
     if (props.onEdit == null) {
       return;
     }
@@ -86,6 +88,25 @@ const UserGroupTable: FC<Props> = (props: Props) => {
     props.onEdit(userGroup);
   };
 
+  const onClickRemove = async(e) => {
+    if (props.onRemove == null) {
+      return;
+    }
+
+    const userGroup = findUserGroup(e);
+    if (userGroup == null) {
+      return;
+    }
+
+    try {
+      await props.onRemove(userGroup);
+      userGroup.parent = null;
+    }
+    catch {
+      //
+    }
+  };
+
   const onClickDelete = (e) => { // no preventDefault
     if (props.onDelete == null) {
       return;
@@ -179,6 +200,9 @@ const UserGroupTable: FC<Props> = (props: Props) => {
                           <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={group._id}>
                             <i className="icon-fw icon-note"></i> {t('Edit')}
                           </button>
+                          <button className="dropdown-item" type="button" role="button" onClick={onClickRemove} data-user-group-id={group._id}>
+                            <i className="icon-fw fa fa-chain-broken"></i> {t('admin:user_group_management.remove_child_group')}
+                          </button>
                           <button className="dropdown-item" type="button" role="button" onClick={onClickDelete} data-user-group-id={group._id}>
                             <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
                           </button>

+ 21 - 0
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -267,6 +267,26 @@ const UserGroupDetailPage: FC = () => {
     }
   }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown]);
 
+  const removeChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
+    try {
+      await apiv3Put(`/user-groups/${userGroupData._id}`, {
+        name: userGroupData.name,
+        description: userGroupData.description,
+        parentId: null,
+      });
+
+      toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
+
+      // mutate
+      mutateChildUserGroups();
+      mutateSelectableChildUserGroups();
+    }
+    catch (err) {
+      toastError(err);
+      throw err;
+    }
+  }, [t, mutateChildUserGroups, mutateSelectableChildUserGroups]);
+
   /*
    * Dependencies
    */
@@ -337,6 +357,7 @@ const UserGroupDetailPage: FC = () => {
         childUserGroups={grandChildUserGroups}
         isAclEnabled={isAclEnabled ?? false}
         onEdit={showUpdateModal}
+        onRemove={removeChildUserGroup}
         onDelete={showDeleteModal}
         userGroupRelations={childUserGroupRelations}
       />

+ 24 - 10
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -134,32 +134,46 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* Bookmark */}
         { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
-          <DropdownItem onClick={bookmarkItemClickedHandler}>
-            <i className="fa fa-fw fa-bookmark-o"></i>
+          <DropdownItem
+            onClick={bookmarkItemClickedHandler}
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
             { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
           </DropdownItem>
         ) }
 
         {/* Duplicate */}
         { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && (
-          <DropdownItem onClick={duplicateItemClickedHandler} data-testid="open-page-duplicate-modal-btn">
-            <i className="icon-fw icon-docs"></i>
+          <DropdownItem
+            onClick={duplicateItemClickedHandler}
+            data-testid="open-page-duplicate-modal-btn"
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="icon-fw icon-docs grw-page-control-dropdown-icon"></i>
             {t('Duplicate')}
           </DropdownItem>
         ) }
 
         {/* Move/Rename */}
         { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && pageInfo.isMovable && (
-          <DropdownItem onClick={renameItemClickedHandler} data-testid="open-page-move-rename-modal-btn">
-            <i className="icon-fw  icon-action-redo"></i>
+          <DropdownItem
+            onClick={renameItemClickedHandler}
+            data-testid="open-page-move-rename-modal-btn"
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="icon-fw icon-action-redo grw-page-control-dropdown-icon"></i>
             {t(isInstantRename ? 'Rename' : 'Move/Rename')}
           </DropdownItem>
         ) }
 
         {/* Revert */}
         { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && pageInfo.isRevertible && (
-          <DropdownItem onClick={revertItemClickedHandler}>
-            <i className="icon-fw  icon-action-undo"></i>
+          <DropdownItem
+            onClick={revertItemClickedHandler}
+            className="grw-page-control-dropdown-item"
+          >
+            <i className="icon-fw icon-action-undo grw-page-control-dropdown-icon"></i>
             {t('modal_putback.label.Put Back Page')}
           </DropdownItem>
         ) }
@@ -177,12 +191,12 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem
-              className={`pt-2 ${pageInfo.isDeletable ? 'text-danger' : ''}`}
+              className={`pt-2 grw-page-control-dropdown-item ${pageInfo.isDeletable ? 'text-danger' : ''}`}
               disabled={!pageInfo.isDeletable}
               onClick={deleteItemClickedHandler}
               data-testid="open-page-delete-modal-btn"
             >
-              <i className="icon-fw icon-trash"></i>
+              <i className="icon-fw icon-trash grw-page-control-dropdown-icon"></i>
               {t('Delete')}
             </DropdownItem>
           </>

+ 27 - 8
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -75,14 +75,20 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       <DropdownItem
         onClick={() => openPresentationModal(hrefForPresentationModal)}
         data-testid="open-presentation-modal-btn"
+        className="grw-page-control-dropdown-item"
       >
-        <i className="icon-fw"><PresentationIcon /></i>
+        <i className="icon-fw grw-page-control-dropdown-icon">
+          <PresentationIcon />
+        </i>
         { t('Presentation Mode') }
       </DropdownItem>
 
       {/* Export markdown */}
-      <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
-        <i className="icon-fw icon-cloud-download"></i>
+      <DropdownItem
+        onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}
+        className="grw-page-control-dropdown-item"
+      >
+        <i className="icon-fw icon-cloud-download grw-page-control-dropdown-icon"></i>
         {t('export_bulk.export_page_markdown')}
       </DropdownItem>
 
@@ -95,31 +101,44 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.PageHistory)}
         disabled={isGuestUser || isSharedUser}
+        className="grw-page-control-dropdown-item"
       >
-        <span className="mr-1"><HistoryIcon /></span>
+        <span className="grw-page-control-dropdown-icon">
+          <HistoryIcon />
+        </span>
         {t('History')}
       </DropdownItem>
 
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.Attachment)}
+        className="grw-page-control-dropdown-item"
       >
-        <span className="mr-1"><AttachmentIcon /></span>
+        <span className="grw-page-control-dropdown-icon">
+          <AttachmentIcon />
+        </span>
         {t('attachment_data')}
       </DropdownItem>
 
       <DropdownItem
         onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
         disabled={isGuestUser || isSharedUser || isLinkSharingDisabled}
+        className="grw-page-control-dropdown-item"
       >
-        <span className="mr-1"><ShareLinkIcon /></span>
+        <span className="grw-page-control-dropdown-icon">
+          <ShareLinkIcon />
+        </span>
         {t('share_links.share_link_management')}
       </DropdownItem>
 
       <DropdownItem divider />
 
       {/* Create template */}
-      <DropdownItem onClick={openPageTemplateModalHandler}>
-        <i className="icon-fw icon-magic-wand"></i> { t('template.option_label.create/edit') }
+      <DropdownItem
+        onClick={openPageTemplateModalHandler}
+        className="grw-page-control-dropdown-item"
+      >
+        <i className="icon-fw icon-magic-wand grw-page-control-dropdown-icon"></i>
+        { t('template.option_label.create/edit') }
       </DropdownItem>
     </>
   );

+ 18 - 17
packages/app/src/components/Navbar/PageEditorModeManager.jsx

@@ -48,16 +48,15 @@ function PageEditorModeManager(props) {
   const isAdmin = appContainer.isAdmin;
   const isHackmdEnabled = appContainer.config.env.HACKMD_URI != null;
   const showHackmdBtn = isHackmdEnabled || isAdmin;
-  const showHackmdDisabledTooltip = isAdmin && !isHackmdEnabled && editorMode !== EditorMode.HackMD;
 
   const pageEditorModeButtonClickedHandler = useCallback((viewType) => {
-    if (isBtnDisabled) {
+    if (isBtnDisabled || !isHackmdEnabled) {
       return;
     }
     if (onPageEditorModeButtonClicked != null) {
       onPageEditorModeButtonClicked(viewType);
     }
-  }, [isBtnDisabled, onPageEditorModeButtonClicked]);
+  }, [isBtnDisabled, isHackmdEnabled, onPageEditorModeButtonClicked]);
 
   return (
     <>
@@ -88,15 +87,22 @@ function PageEditorModeManager(props) {
           />
         )}
         {(!isDeviceSmallerThanMd || editorMode === EditorMode.View) && showHackmdBtn && (
-          <PageEditorModeButtonWrapper
-            editorMode={editorMode}
-            isBtnDisabled={isBtnDisabled}
-            onClick={pageEditorModeButtonClickedHandler}
-            targetMode={EditorMode.HackMD}
-            icon={<i className="fa fa-file-text-o" />}
-            label={t('hackmd.hack_md')}
-            id="grw-page-editor-mode-manager-hackmd-button"
-          />
+          <>
+            <PageEditorModeButtonWrapper
+              editorMode={editorMode}
+              isBtnDisabled={isBtnDisabled || !isHackmdEnabled}
+              onClick={pageEditorModeButtonClickedHandler}
+              targetMode={EditorMode.HackMD}
+              icon={<i className="fa fa-file-text-o" />}
+              label={t('hackmd.hack_md')}
+              id="grw-page-editor-mode-manager-hackmd-button"
+            />
+            { !isHackmdEnabled && (
+              <UncontrolledTooltip placement="top" target="grw-page-editor-mode-manager-hackmd-button" fade={false}>
+                {t('hackmd.not_set_up')}
+              </UncontrolledTooltip>
+            )}
+          </>
         )}
       </div>
       {isBtnDisabled && (
@@ -104,11 +110,6 @@ function PageEditorModeManager(props) {
           {t('Not available for guest')}
         </UncontrolledTooltip>
       )}
-      {!isBtnDisabled && showHackmdDisabledTooltip && (
-        <UncontrolledTooltip placement="top" target="grw-page-editor-mode-manager-hackmd-button" fade={false}>
-          {t('hackmd.not_set_up')}
-        </UncontrolledTooltip>
-      )}
     </>
   );
 

+ 2 - 4
packages/app/src/components/PageList/PageListItemL.tsx

@@ -170,11 +170,9 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     <li
       key={pageData._id}
       className={`list-group-item d-flex align-items-center px-3 px-md-1 ${styleListGroupItem} ${styleActive}`}
+      onClick={clickHandler}
     >
-      <div
-        className="text-break w-100"
-        onClick={clickHandler}
-      >
+      <div className="text-break w-100">
         <div className="d-flex">
           {/* checkbox */}
           {onCheckboxChanged != null && (

+ 5 - 2
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -42,8 +42,11 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
 
   return (
     // Export markdown
-    <DropdownItem onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}>
-      <i className="icon-fw icon-cloud-download"></i>
+    <DropdownItem
+      onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}
+      className="grw-page-control-dropdown-item"
+    >
+      <i className="icon-fw icon-cloud-download grw-page-control-dropdown-icon"></i>
       {t('export_bulk.export_page_markdown')}
     </DropdownItem>
   );

+ 1 - 1
packages/app/src/components/SearchPage/SortControl.tsx

@@ -38,7 +38,7 @@ const SortControl: FC <Props> = (props: Props) => {
         <div className="border rounded-right">
           <button
             type="button"
-            className="btn dropdown-toggle search-sort-option-btn py-1"
+            className="btn dropdown-toggle py-1"
             data-toggle="dropdown"
           >
             <span className="mr-2 text-secondary">{t(`search_result.sort_axis.${sort}`)}</span>

+ 4 - 0
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -195,6 +195,10 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
       await mutateChildren();
 
+      if (onRenamed != null) {
+        onRenamed();
+      }
+
       // force open
       setIsOpen(true);
     }

+ 11 - 2
packages/app/src/server/models/password-reset-order.ts

@@ -2,6 +2,7 @@ import mongoose, {
   Schema, Model, Document,
 } from 'mongoose';
 
+import { addMinutes } from 'date-fns';
 import uniqueValidator from 'mongoose-unique-validator';
 import crypto from 'crypto';
 import { getOrCreateModel } from '@growi/core';
@@ -28,13 +29,21 @@ export interface PasswordResetOrderModel extends Model<PasswordResetOrderDocumen
   createPasswordResetOrder(email: string): PasswordResetOrderDocument
 }
 
+const expiredAt = (): Date => {
+  return addMinutes(new Date(), 10);
+};
+
 const schema = new Schema<PasswordResetOrderDocument, PasswordResetOrderModel>({
   token: { type: String, required: true, unique: true },
   email: { type: String, required: true },
   relatedUser: { type: ObjectId, ref: 'User' },
   isRevoked: { type: Boolean, default: false, required: true },
-  createdAt: { type: Date, default: new Date(Date.now()), required: true },
-  expiredAt: { type: Date, default: new Date(Date.now() + 600000), required: true },
+  expiredAt: { type: Date, default: expiredAt, required: true },
+}, {
+  timestamps: {
+    createdAt: true,
+    updatedAt: false,
+  },
 });
 schema.plugin(uniqueValidator);
 

+ 12 - 3
packages/app/src/server/models/user-registration-order.ts

@@ -1,7 +1,8 @@
-import mongoose, {
+import {
   Schema, Model, Document,
 } from 'mongoose';
 
+import { addHours } from 'date-fns';
 import uniqueValidator from 'mongoose-unique-validator';
 import crypto from 'crypto';
 import { getOrCreateModel } from '@growi/core';
@@ -24,12 +25,20 @@ export interface UserRegistrationOrderModel extends Model<UserRegistrationOrderD
   createUserRegistrationOrder(email: string): UserRegistrationOrderDocument
 }
 
+const expiredAt = (): Date => {
+  return addHours(new Date(), 1);
+};
+
 const schema = new Schema<UserRegistrationOrderDocument, UserRegistrationOrderModel>({
   token: { type: String, required: true, unique: true },
   email: { type: String, required: true },
   isRevoked: { type: Boolean, default: false, required: true },
-  createdAt: { type: Date, default: new Date(Date.now()), required: true },
-  expiredAt: { type: Date, default: new Date(Date.now() + 600000), required: true },
+  expiredAt: { type: Date, default: expiredAt, required: true },
+}, {
+  timestamps: {
+    createdAt: true,
+    updatedAt: false,
+  },
 });
 schema.plugin(uniqueValidator);
 

+ 7 - 5
packages/app/src/server/routes/apiv3/forgot-password.js

@@ -1,3 +1,4 @@
+import { format } from 'date-fns';
 import rateLimit from 'express-rate-limit';
 
 import PasswordResetOrder from '~/server/models/password-reset-order';
@@ -45,23 +46,23 @@ module.exports = (crowi) => {
 
   const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
 
-  async function sendPasswordResetEmail(txtFileName, i18n, email, url) {
+  async function sendPasswordResetEmail(txtFileName, i18n, email, url, expiredAt) {
     return mailService.send({
       to: email,
-      subject: txtFileName,
+      subject: '[GROWI] Password Reset',
       template: path.join(crowi.localeDir, `${i18n}/notifications/${txtFileName}.txt`),
       vars: {
         appTitle: appService.getAppTitle(),
         email,
         url,
+        expiredAt,
       },
     });
   }
 
   router.post('/', checkPassportStrategyMiddleware, async(req, res) => {
     const { email } = req.body;
-    const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
-    const i18n = req.language || grobalLang;
+    const i18n = configManager.getConfig('crowi', 'app:globalLang');
     const appUrl = appService.getSiteUrl();
 
     try {
@@ -76,7 +77,8 @@ module.exports = (crowi) => {
       const passwordResetOrderData = await PasswordResetOrder.createPasswordResetOrder(email);
       const url = new URL(`/forgot-password/${passwordResetOrderData.token}`, appUrl);
       const oneTimeUrl = url.href;
-      await sendPasswordResetEmail('passwordReset', i18n, email, oneTimeUrl);
+      const expiredAt = format(passwordResetOrderData.expiredAt, 'yyyy/MM/dd HH:mm');
+      await sendPasswordResetEmail('passwordReset', i18n, email, oneTimeUrl, expiredAt);
       return res.apiv3();
     }
     catch (err) {

+ 1 - 1
packages/app/src/server/routes/apiv3/user-group.js

@@ -49,7 +49,7 @@ module.exports = (crowi) => {
     update: [
       body('name', 'Group name must be a string').optional().trim().isString(),
       body('description', 'Group description must be a string').optional().isString(),
-      body('parentId', 'parentId must be a string').optional().isString(),
+      body('parentId', 'ParentId must be a string or null').optional({ nullable: true }).isString(),
       body('forceUpdateParents', 'forceUpdateParents must be a boolean').optional().isBoolean(),
     ],
     delete: [

+ 5 - 1
packages/app/src/server/routes/user-activation.ts

@@ -1,5 +1,7 @@
 import path from 'path';
+import { format } from 'date-fns';
 import { body, validationResult } from 'express-validator';
+
 import UserRegistrationOrder from '../models/user-registration-order';
 
 export const form = (req, res): void => {
@@ -20,17 +22,19 @@ async function makeRegistrationEmailToken(email, crowi) {
   const appUrl = appService.getSiteUrl();
 
   const userRegistrationOrder = await UserRegistrationOrder.createUserRegistrationOrder(email);
+  const expiredAt = format(userRegistrationOrder.expiredAt, 'yyyy/MM/dd HH:mm');
   const url = new URL(`/user-activation/${userRegistrationOrder.token}`, appUrl);
   const oneTimeUrl = url.href;
   const txtFileName = 'userActivation';
 
   return mailService.send({
     to: email,
-    subject: txtFileName,
+    subject: '[GROWI] User Activation',
     template: path.join(localeDir, `${i18n}/notifications/${txtFileName}.txt`),
     vars: {
       appTitle: appService.getAppTitle(),
       email,
+      expiredAt,
       url: oneTimeUrl,
     },
   });

+ 0 - 4
packages/app/src/styles/_search.scss

@@ -142,10 +142,6 @@
     padding-bottom: unset;
   }
 
-  // To fix the sort options position
-  .search-sort-option-btn {
-    min-width: 150px;
-  }
   .search-control-include-options {
     .card-body {
       padding: 5px 10px;

+ 12 - 0
packages/app/src/styles/style-app.scss

@@ -150,3 +150,15 @@
     content: 'Ctrl';
   }
 }
+
+.grw-page-control-dropdown-item {
+  display: flex !important;
+  align-items: center;
+
+  .grw-page-control-dropdown-icon {
+    display: flex;
+    justify-content: center;
+    width: 25px;
+  }
+
+}