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

Merge branch 'master' into feat/questionnaire

ryoji-s 2 лет назад
Родитель
Сommit
60d01b798f
100 измененных файлов с 757 добавлено и 1034 удалено
  1. 9 0
      .github/workflows/cache-clear.yml
  2. 4 13
      .github/workflows/ci-app-prod.yml
  3. 2 8
      .github/workflows/ci-app.yml
  4. 1 0
      .github/workflows/ci-slackbot-proxy.yml
  5. 2 0
      .github/workflows/codeql-analysis.yml
  6. 7 1
      .github/workflows/reusable-app-prod.yml
  7. 3 2
      apps/app/docker/Dockerfile
  8. 2 0
      apps/app/docker/Dockerfile.dockerignore
  9. 0 3
      apps/app/jest.config.js
  10. 5 0
      apps/app/next.config.js
  11. 2 5
      apps/app/package.json
  12. 0 3
      apps/app/public/static/dict/base.dat.gz
  13. 0 3
      apps/app/public/static/dict/cc.dat.gz
  14. 0 3
      apps/app/public/static/dict/check.dat.gz
  15. 0 3
      apps/app/public/static/dict/tid.dat.gz
  16. 0 3
      apps/app/public/static/dict/tid_map.dat.gz
  17. 0 3
      apps/app/public/static/dict/tid_pos.dat.gz
  18. 0 3
      apps/app/public/static/dict/unk.dat.gz
  19. 0 3
      apps/app/public/static/dict/unk_char.dat.gz
  20. 0 3
      apps/app/public/static/dict/unk_compat.dat.gz
  21. 0 3
      apps/app/public/static/dict/unk_invoke.dat.gz
  22. 0 3
      apps/app/public/static/dict/unk_map.dat.gz
  23. 0 3
      apps/app/public/static/dict/unk_pos.dat.gz
  24. 1 2
      apps/app/public/static/locales/en_US/admin.json
  25. 2 1
      apps/app/public/static/locales/en_US/commons.json
  26. 4 42
      apps/app/public/static/locales/en_US/translation.json
  27. 1 2
      apps/app/public/static/locales/ja_JP/admin.json
  28. 2 1
      apps/app/public/static/locales/ja_JP/commons.json
  29. 2 6
      apps/app/public/static/locales/ja_JP/translation.json
  30. 1 2
      apps/app/public/static/locales/zh_CN/admin.json
  31. 2 1
      apps/app/public/static/locales/zh_CN/commons.json
  32. 3 40
      apps/app/public/static/locales/zh_CN/translation.json
  33. 0 1
      apps/app/src/client/services/renderer/renderer.tsx
  34. 0 2
      apps/app/src/components/Admin/Notification/NotificationSetting.jsx
  35. 12 14
      apps/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  36. 0 7
      apps/app/src/components/Admin/Security/SecurityManagementContents.jsx
  37. 62 64
      apps/app/src/components/CustomNavigation/CustomNav.tsx
  38. 2 1
      apps/app/src/components/CustomNavigation/CustomTabContent.tsx
  39. 0 2
      apps/app/src/components/DescendantsPageListModal.tsx
  40. 5 1
      apps/app/src/components/Fab.tsx
  41. 20 3
      apps/app/src/components/InAppNotification/InAppNotificationElm.tsx
  42. 0 2
      apps/app/src/components/InAppNotification/InAppNotificationPage.tsx
  43. 46 0
      apps/app/src/components/InAppNotification/PageNotification/UserModelNotification.tsx
  44. 17 261
      apps/app/src/components/Me/EditorSettings.tsx
  45. 6 12
      apps/app/src/components/Me/PersonalSettings.jsx
  46. 1 1
      apps/app/src/components/Navbar/GrowiSubNavigationSwitcher.tsx
  47. 0 2
      apps/app/src/components/NotFoundPage.tsx
  48. 1 4
      apps/app/src/components/PageAccessoriesModal.tsx
  49. 0 2
      apps/app/src/components/PageComment/CommentEditor.tsx
  50. 12 6
      apps/app/src/components/PageEditor.tsx
  51. 0 30
      apps/app/src/components/PageEditor/CodeMirrorEditor.jsx
  52. 0 69
      apps/app/src/components/PageEditor/DownloadDictModal.tsx
  53. 8 8
      apps/app/src/components/PageEditor/DrawioModal.tsx
  54. 0 1
      apps/app/src/components/PageEditor/Editor.tsx
  55. 4 72
      apps/app/src/components/PageEditor/OptionsSelector.tsx
  56. 24 3
      apps/app/src/components/PageEditorByHackmd.tsx
  57. 33 14
      apps/app/src/components/PasswordResetRequestForm.tsx
  58. 14 2
      apps/app/src/components/SavePageControls.tsx
  59. 14 8
      apps/app/src/components/Script/DrawioViewerScript.tsx
  60. 0 1
      apps/app/src/components/TrashPageList.tsx
  61. 8 3
      apps/app/src/components/UncontrolledCodeMirror.tsx
  62. 6 0
      apps/app/src/interfaces/activity.ts
  63. 0 12
      apps/app/src/interfaces/editor-settings.ts
  64. 8 4
      apps/app/src/interfaces/in-app-notification.ts
  65. 2 2
      apps/app/src/interfaces/services/renderer.ts
  66. 1 2
      apps/app/src/interfaces/ui.ts
  67. 15 0
      apps/app/src/models/serializers/in-app-notification-snapshot/user.ts
  68. 3 4
      apps/app/src/pages/[[...path]].page.tsx
  69. 3 9
      apps/app/src/pages/_private-legacy-pages.page.tsx
  70. 3 9
      apps/app/src/pages/_search.page.tsx
  71. 20 7
      apps/app/src/pages/forgot-password.page.tsx
  72. 0 2
      apps/app/src/pages/installer.page.tsx
  73. 2 2
      apps/app/src/pages/me/[[...path]].page.tsx
  74. 3 4
      apps/app/src/pages/share/[[...path]].page.tsx
  75. 1 19
      apps/app/src/pages/tags.page.tsx
  76. 1 19
      apps/app/src/pages/trash.page.tsx
  77. 1 11
      apps/app/src/server/models/editor-settings.ts
  78. 0 14
      apps/app/src/server/routes/apiv3/personal-setting.js
  79. 2 2
      apps/app/src/server/routes/attachment.js
  80. 19 8
      apps/app/src/server/routes/login.js
  81. 7 7
      apps/app/src/server/service/config-loader.ts
  82. 3 1
      apps/app/src/server/service/file-uploader/gcs.js
  83. 19 8
      apps/app/src/server/service/in-app-notification.ts
  84. 8 6
      apps/app/src/server/service/page.ts
  85. 8 2
      apps/app/src/server/service/search.ts
  86. 0 18
      apps/app/src/services/renderer/markdown-it/blockdiag.ts
  87. 2 2
      apps/app/src/services/renderer/remark-plugins/plantuml.ts
  88. 2 2
      apps/app/src/services/renderer/renderer.tsx
  89. 0 4
      apps/app/src/stores/context.tsx
  90. 5 17
      apps/app/src/stores/editor.tsx
  91. 13 2
      apps/app/src/stores/in-app-notification.ts
  92. 137 0
      apps/app/test/cypress/integration/20-basic-features/20-basic-features--sticky-features.spec.ts
  93. 13 6
      apps/app/test/cypress/integration/20-basic-features/20-basic-features--use-tools.spec.ts
  94. 58 0
      apps/app/test/cypress/integration/21-basic-features-for-guest/21-basic-features-for-guest--sticky-for-guest.spec.ts
  95. 1 16
      apps/app/test/cypress/integration/60-home/60-home--home.spec.ts
  96. 2 4
      apps/app/test/integration/service/page.test.js
  97. 0 2
      apps/app/tsconfig.build.client.json
  98. 0 2
      apps/app/tsconfig.json
  99. 42 44
      apps/slackbot-proxy/docker/Dockerfile
  100. 3 0
      apps/slackbot-proxy/docker/Dockerfile.dockerignore

+ 9 - 0
.github/workflows/cache-clear.yml

@@ -0,0 +1,9 @@
+name: Cache Clear
+
+on: workflow_dispatch
+
+jobs:
+  clear:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: snnaplab/delete-branch-cache-action@v1

+ 4 - 13
.github/workflows/ci-app-prod.yml

@@ -11,15 +11,10 @@ on:
       - tsconfig.base.json
       - turbo.json
       - yarn.lock
+      - package.json
       - apps/app/**
       - '!apps/app/docker/**'
-      - packages/codemirror-textlint/**
-      - packages/core/**
-      - packages/preset-themes/**
-      - packages/presentation/**
-      - packages/remark-*/**
-      - packages/slack/**
-      - packages/ui/**
+      - packages/**
   pull_request:
     branches:
       - master
@@ -31,14 +26,10 @@ on:
       - tsconfig.base.json
       - yarn.lock
       - turbo.json
+      - package.json
       - apps/app/**
       - '!apps/app/docker/**'
-      - packages/codemirror-textlint/**
-      - packages/core/**
-      - packages/preset-themes/**
-      - packages/remark-*/**
-      - packages/slack/**
-      - packages/ui/**
+      - packages/**
   workflow_call:
     inputs:
       cypress-config-video:

+ 2 - 8
.github/workflows/ci-app.yml

@@ -11,16 +11,10 @@ on:
       - tsconfig.base.json
       - turbo.json
       - yarn.lock
+      - package.json
       - apps/app/**
       - '!apps/app/docker/**'
-      - packages/codemirror-textlint/**
-      - packages/core/**
-      - packages/hackmd/**
-      - packages/presentation/**
-      - packages/preset-themes/**
-      - packages/remark-*/**
-      - packages/slack/**
-      - packages/ui/**
+      - packages/**
 
 concurrency:
   group: ${{ github.workflow }}-${{ github.ref }}

+ 1 - 0
.github/workflows/ci-slackbot-proxy.yml

@@ -12,6 +12,7 @@ on:
       - tsconfig.base.json
       - turbo.json
       - yarn.lock
+      - package.json
       - apps/slackbot-proxy/**
       - '!apps/slackbot-proxy/docker/**'
       - packages/slack/**

+ 2 - 0
.github/workflows/codeql-analysis.yml

@@ -16,12 +16,14 @@ on:
     branches: [ master, dev/*, release/current ]
     paths:
       - .github/workflows/codeql-analysis.yml
+      - apps/**
       - packages/**
   pull_request:
     # The branches below must be a subset of the branches above
     branches: [ master ]
     paths:
       - .github/workflows/codeql-analysis.yml
+      - apps/**
       - packages/**
   schedule:
     - cron: '28 20 * * 6'

+ 7 - 1
.github/workflows/reusable-app-prod.yml

@@ -272,6 +272,11 @@ jobs:
         restore-keys: |
           deps-for-cypress-${{ runner.OS }}-node${{ inputs.node-version }}-
 
+    - name: Install dependencies
+      run: |
+        yarn --frozen-lockfile
+        yarn cypress install
+
     - name: Download production files artifact
       uses: actions/download-artifact@v3
       with:
@@ -305,11 +310,12 @@ jobs:
         cat config/ci/.env.local.for-auto-install-with-allowing-guest >> .env.production.local
 
     - name: Cypress Run
-      uses: cypress-io/github-action@v3
+      uses: cypress-io/github-action@v5
       with:
         browser: chrome
         working-directory: ./apps/app
         spec: '${{ steps.determine-spec-exp.outputs.value }}'
+        install: false
         start: yarn server
         wait-on: 'http://localhost:3000'
         config: video=${{ inputs.cypress-config-video }}

+ 3 - 2
apps/app/docker/Dockerfile

@@ -90,13 +90,14 @@ RUN tar -cf packages.tar \
   package.json \
   apps/app/.next \
   apps/app/config \
+  apps/app/dist \
   apps/app/public \
   apps/app/resource \
   apps/app/tmp \
   apps/app/.env.production* \
   apps/app/next.config.js \
-  **/package.json \
-  **/dist
+  packages/*/package.json \
+  packages/*/dist
 
 
 

+ 2 - 0
apps/app/docker/Dockerfile.dockerignore

@@ -5,3 +5,5 @@
 **/*.dockerignore
 **/.next
 **/.turbo
+out
+apps/slackbot-proxy

+ 0 - 3
apps/app/jest.config.js

@@ -4,9 +4,6 @@
 const MODULE_NAME_MAPPING = {
   '^\\^/(.+)$': '<rootDir>/$1',
   '^~/(.+)$': '<rootDir>/src/$1',
-  '^@growi/codemirror-textlint$': '<rootDir>/../../packages/codemirror-textlint/src',
-  '^@growi/remark-drawio$': '<rootDir>/../../packages/remark-drawio/src',
-  '^@growi/remark-growi-directive$': '<rootDir>/../../packages/remark-growi-directive/src',
 };
 
 module.exports = {

+ 5 - 0
apps/app/next.config.js

@@ -19,7 +19,11 @@ const getTranspilePackages = () => {
     'react-markdown',
     'unified',
     'markdown-table',
+    'bail',
+    'ccount',
+    'character-entities',
     'character-entities-html4',
+    'character-entities-legacy',
     'comma-separated-tokens',
     'decode-named-character-reference',
     'escape-string-regexp',
@@ -27,6 +31,7 @@ const getTranspilePackages = () => {
     'html-void-elements',
     'is-absolute-url',
     'longest-streak',
+    'micromark',
     'property-information',
     'space-separated-tokens',
     'stringify-entities',

+ 2 - 5
apps/app/package.json

@@ -47,7 +47,6 @@
   },
   "// comments for dependencies": {
     "escape-string-regexp": "5.0.0 or above exports only ESM",
-    "next": ">=13.1.2 with Node v14 have the problem => https://github.com/vercel/next.js/issues/45052",
     "string-width": "5.0.0 or above exports only ESM.",
     "prom-client": "!!DO NOT REMOVE!! A peer dependency of @promster.",
     "remark-wiki-link": "!!DO NOT REMOVE!! including 'mdast-util-wiki-link' and 'micromark-extension-wiki-link' required by pukiwiki-like-linker"
@@ -61,7 +60,6 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^6.1.0-RC.0",
     "@growi/core": "^6.1.0-RC.0",
     "@growi/hackmd": "^6.1.0-RC.0",
     "@growi/preset-themes": "^6.1.0-RC.0",
@@ -130,7 +128,7 @@
     "mongoose-unique-validator": "^2.0.3",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
-    "next": "=13.1.1",
+    "next": "^13.3.0",
     "next-i18next": "^13.2.1",
     "next-superjson": "^0.0.4",
     "next-themes": "^0.2.1",
@@ -215,7 +213,7 @@
     "codemirror": "^5.64.0",
     "connect-browser-sync": "^2.1.0",
     "core-js": "=2.6.9",
-    "diff2html": "^3.1.2",
+    "diff2html": "^3.4.35",
     "eazy-logger": "^3.1.0",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
     "eslint-plugin-cypress": "^2.12.1",
@@ -225,7 +223,6 @@
     "i18next-hmr": "^1.11.0",
     "jquery-slimscroll": "^1.3.8",
     "jquery.cookie": "~1.4.1",
-    "jshint": "^2.13.0",
     "load-css-file": "^1.0.0",
     "material-icons": "^1.11.3",
     "morgan": "^1.10.0",

+ 0 - 3
apps/app/public/static/dict/base.dat.gz

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:0803327762e1c93ca731e4319ab8343340f2806bb84941207782cde9d2d5a8eb
-size 3956825

+ 0 - 3
apps/app/public/static/dict/cc.dat.gz

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:02b7631be0d4de3a1a75cd9f9cc51536e4f94c9e6b389b813e06ba0f6e7de765
-size 1692067

+ 0 - 3
apps/app/public/static/dict/check.dat.gz

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:193ae0035fff6fe812b58d9ee730e7a7d7ee601d918481ce51075c58114f6cc9
-size 3111633

+ 0 - 3
apps/app/public/static/dict/tid.dat.gz

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:d43d831cb6fb0f0a411739cd287a6d5e998e121a8daca614df14a81a0dcac586
-size 1605820

+ 0 - 3
apps/app/public/static/dict/tid_map.dat.gz

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:33efd5ffd87a70f669add093fa39dee44341d58f940844ef107c8fd98bb795b2
-size 1485576

+ 0 - 3
apps/app/public/static/dict/tid_pos.dat.gz

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:60dbfc99a6ab993f30c5dab648bec6ad7f9aaefa5c14e1843837d95e509f8895
-size 5916009

+ 0 - 3
apps/app/public/static/dict/unk.dat.gz

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:f7f991cdeb9bfd3e9c0e4577cc50ee0815a11c508cccd444a9d3ab3c81521100
-size 10512

+ 0 - 3
apps/app/public/static/dict/unk_char.dat.gz

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:9a8e86fd9aff32d323fbb59f5a7006f05927a11f8173c90712cc56293aeb3225
-size 306

+ 0 - 3
apps/app/public/static/dict/unk_compat.dat.gz

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:50f60aa29bc2e86c2903ab8c825bb6fa604d2b294d96941c1d3924259791899d
-size 338

+ 0 - 3
apps/app/public/static/dict/unk_invoke.dat.gz

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:6b210889548457c3006913afd12c8b525562255f2709e404604be9614a25e94c
-size 1140

+ 0 - 3
apps/app/public/static/dict/unk_map.dat.gz

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:6df12460e5477230bb6fd9641def918b699fc0a8868016b6c9f794488630509b
-size 1190

+ 0 - 3
apps/app/public/static/dict/unk_pos.dat.gz

@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:5b183a29f281acc7e0542beca47b83f7985047c0a2d27e78a66f32276be5ad11
-size 10540

+ 1 - 2
apps/app/public/static/locales/en_US/admin.json

@@ -106,8 +106,7 @@
       "password_reset_desc": "when forgot password, users are able to reset it by themselves.",
       "email_authentication": "Email authentication on user registration",
       "enable_email_authentication": "Enable email authentication",
-      "enable_email_authentication_desc": "Email authentication is going to be performed for user registration.",
-      "need_complete_mail_setting_warning": "To use the following functions, please complete the mail settings."
+      "enable_email_authentication_desc": "Email authentication is going to be performed for user registration."
     },
     "ldap": {
       "enable_ldap": "Enable LDAP",

+ 2 - 1
apps/app/public/static/locales/en_US/commons.json

@@ -20,7 +20,8 @@
   },
   "alert": {
     "siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",
-    "please_enable_mailer": "Please setup mailer first."
+    "please_enable_mailer": "Please setup mailer first.",
+    "password_reset_please_enable_mailer": "Please setup mailer first."
   },
   "headers": {
     "app_settings": "App Settings"

+ 4 - 42
apps/app/public/static/locales/en_US/translation.json

@@ -247,41 +247,7 @@
     }
   },
   "editor_settings": {
-    "editor_settings": "Editor Settings",
-    "common_settings": {
-      "common_settings": "Common Settings",
-      "common_misspellings": "Textlint rules to find common misspellings from Wikipedia.",
-      "max_comma": "Textlint rule is that limit maximum ten(,) count of sentence. Default: 4",
-      "sentence_length": "Textlint rules that limit Maximum Length of Sentence. Default: 100",
-      "en_capitalization": "Textlint rule that check capitalization in english text.",
-      "no_unmatched_pair": "Textlint rule that check unmatched pairs like ( and ]",
-      "date_weekday_mismatch": "Textlint rule that found mismatch between date and weekday.",
-      "no_kangxi_radicals": "Textlint rule to prevent using kangxi radicals.",
-      "no_surrogate_pair": "Detects surrogate pairs (D800-DBFF and DC00-DFFF) in sentences.",
-      "no_zero_width_spaces": "Textlint rule that disallow zero width spaces.",
-      "period_in_list_item": "Textlint rule that check with or without period in list item.",
-      "use_si_units": "Use of units other than SI unit units is prohibited."
-
-      },
-    "japanese_settings": {
-      "japanese_settings": "Japanese Settings",
-      "ja_no_abusage": "Textlint rules to check for common misuse.",
-      "ja_hiragana_keishikimeishi": "Textlint rules to check easy-to-read Keishikimeishi(pronouns) written in Hiragana than Kanji.",
-      "ja_no_inappropriate_words": "Textlint rules to check for inappropriate expressions",
-      "ja_no_mixed_period": "Textlint rules to check that a paragraph always has a punctuation mark at the end.",
-      "ja_no_redundant_expression": "Textlint rules that prohibits redundant expressions. Redundant expressions are expressions that make sense even if they are omitted from the sentence.",
-      "max_kanji_continuous_len": "Textlint rules that limits the maximum number of consecutive Kanji. Default: 5",
-      "max_ten": "Textlint rule is that limit maximum ten(、) count of sentence.",
-      "no_double_negative_ja": "Textlint rules that detects double negation.",
-      "no_doubled_conjunction": "Textlint rules to check duplicated same conjunctions.",
-      "no_doubled_joshi": "Textlint rules that checks that the same particle appears consecutively in one sentence.",
-      "no_dropping_the_ra": "Textlint rules that detects the word dropping the ra.",
-      "no_hankaku_kana": "Textlint rules that disallow to use Half-width kana.",
-      "prefer_tari_tari": "Textlint rules that checks tari tari.",
-      "ja_unnatural_alphabet": "Detects unnatural alphabets.",
-      "no_mixed_zenkaku_and_hankaku_alphabet": "Check for mixed full-width and half-width alphabets.",
-      "no_nfd": "textlint rule that disallow to use NFD like UTF8-MAC Sonant mark."
-    }
+    "editor_settings": "Editor Settings"
   },
   "search_help": {
     "title": "Searching Help",
@@ -444,11 +410,6 @@
       "Post": "Post"
     }
   },
-  "modal_enable_textlint": {
-    "confirm_download_dict_and_enable_textlint": "Are you sure you want to enable Textlint? This will download 20MB of dictionary file.",
-    "enable_textlint": "Enable Textlint",
-    "dont_ask_again": "Don't ask again"
-  },
   "modal_resolve_conflict": {
     "file_conflicting_with_newer_remote": "This file is conflicting with newer remote file",
     "resolve_conflict_message": "Please select page body",
@@ -615,7 +576,7 @@
   "login": {
     "title": "Login",
     "sign_in_error": "Login error",
-    "registration_successful": "registration_successful. Please wait for administrator approval.",
+    "registration_successful": "Registration successful. Please wait for administrator approval.",
     "Setup": "Setup",
     "enabled_ldap_has_configuration_problem":"LDAP is enabled but the configuration has something wrong.",
     "set_env_var_for_logs": "(Please set the environment variables <code>DEBUG=crowi:service:PassportService</code> to get the logs)"
@@ -702,7 +663,8 @@
     "success_to_send_email": "Success to send email",
     "feature_is_unavailable": "This feature is unavailable.",
     "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired. Please resend a password reset request via the link below.",
-    "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
+    "password_and_confirm_password_does_not_match": "Password and confirm password does not match",
+    "please_enable_mailer_alert": "The password reset feature is disabled because email setup has not been completed. Please ask administrator to complete the email setup."
   },
   "emoji" :{
     "title": "Pick an Emoji",

+ 1 - 2
apps/app/public/static/locales/ja_JP/admin.json

@@ -114,8 +114,7 @@
       "password_reset_desc": "ログイン時のパスワードを忘れた際に、ユーザー自身がパスワードを再設定できます。",
       "email_authentication": "ユーザー登録時のメール認証",
       "enable_email_authentication": "メール認証を有効にする",
-      "enable_email_authentication_desc": "ユーザー登録時にメール認証を行います。",
-      "need_complete_mail_setting_warning": "以下の機能を使えるようにするには、メール設定を完了させてください。"
+      "enable_email_authentication_desc": "ユーザー登録時にメール認証を行います。"
     },
     "ldap": {
       "enable_ldap": "LDAP を有効にする",

+ 2 - 1
apps/app/public/static/locales/ja_JP/commons.json

@@ -19,7 +19,8 @@
   },
   "alert": {
     "siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
-    "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。"
+    "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。",
+    "password_reset_please_enable_mailer": "パスワード再設定を有効にするには、メール設定を完了させてください。"
   },
   "headers": {
     "app_settings": "アプリ設定"

+ 2 - 6
apps/app/public/static/locales/ja_JP/translation.json

@@ -443,11 +443,6 @@
       "Post": "投稿"
     }
   },
-  "modal_enable_textlint": {
-    "confirm_download_dict_and_enable_textlint": "Textlintを有効にしますか?20MBの辞書ファイルをダウンロードします。",
-    "enable_textlint": "Textlintを有効にする",
-    "dont_ask_again": "常に許可する"
-  },
   "modal_resolve_conflict": {
     "file_conflicting_with_newer_remote": "サーバー側の新しいファイルと衝突します。",
     "resolve_conflict_message": "ページ本文を選んでください",
@@ -701,7 +696,8 @@
     "success_to_send_email": "メールを送信しました",
     "feature_is_unavailable": "この機能を利用することはできません。",
     "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
-    "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
+    "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません",
+    "please_enable_mailer_alert": "メール設定が完了していないため、パスワード再設定機能が無効になっています。メール設定を完了させるよう管理者に依頼してください。"
   },
   "emoji" :{
     "title": "絵文字を選択",

+ 1 - 2
apps/app/public/static/locales/zh_CN/admin.json

@@ -114,8 +114,7 @@
       "password_reset_desc": "忘记密码时,用户可以自行重置",
       "email_authentication": "用户注册时的电子邮件身份验证",
       "enable_email_authentication": "启用电子邮件身份验证",
-      "enable_email_authentication_desc": "用户注册将执行电子邮件身份验证。",
-      "need_complete_mail_setting_warning": "要使用以下功能,请完成邮件设置。"
+      "enable_email_authentication_desc": "用户注册将执行电子邮件身份验证。"
 		},
 		"ldap": {
 			"enable_ldap": "Enable LDAP",

+ 2 - 1
apps/app/public/static/locales/zh_CN/commons.json

@@ -20,7 +20,8 @@
   },
   "alert": {
     "siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
-    "please_enable_mailer": "请先设置邮件程序。"
+    "please_enable_mailer": "请先设置邮件程序。",
+    "password_reset_please_enable_mailer": "请先设置邮件程序。"
   },
   "headers": {
     "app_settings": "系统设置"

+ 3 - 40
apps/app/public/static/locales/zh_CN/translation.json

@@ -238,40 +238,7 @@
     }
   },
   "editor_settings": {
-    "editor_settings": "编辑器设置",
-    "common_settings": {
-      "common_settings": "常用设置",
-      "common_misspellings": "从 Wikipedia 中查找常见拼写错误的 Textlint。",
-      "max_comma": "Textlint 规则是限制句子的最大十(,)个计数。默认:4。",
-      "sentence_length": "限制最大句子长度的 Textlint 默认: 100。",
-      "en_capitalization": "检查英文文本大小写的 Textlint 规则。",
-      "no_unmatched_pair": "检查不匹配对的 Textlint 规则,如 ( 和 ]",
-      "date_weekday_mismatch": "发现日期和工作日之间不匹配的 Textlint 规则。",
-      "no_kangxi_radicals": "防止使用康熙部首的 Textlint 规则。",
-      "no_surrogate_pair": "检测句子中的代理对(D800-DBFF 和 DC00-DFFF)。",
-      "no_zero_width_spaces": "不允许零宽度空格的 Textlint 规则。",
-      "period_in_list_item": "在列表项中检查是否有句点的 Textlint 规则。",
-      "use_si_units": "禁止使用 SI 单位以外的单位。"
-      },
-    "japanese_settings": {
-      "japanese_settings": "日语设置",
-      "ja_no_abusage": "用于检查常见误用的 Textlint 规则。",
-      "ja_hiragana_keishikimeishi": "Textlint 规则检查易于阅读的 Keishikimeishi(代词)用平假名而不是汉字编写。",
-      "ja_no_inappropriate_words": "Textlint 规则来检查不适当的表达",
-      "ja_no_mixed_period": "Textlint 规则用于检查段落末尾是否总是有标点符号。",
-      "ja_no_redundant_expression": "禁止冗余表达式的 Textlint 规则。冗余表达式是即使从句子中省略也有意义的表达式。",
-      "max_kanji_continuous_len": "限制连续汉字的最大数量的 Textlint 规则。默认:5。",
-      "max_ten": "Textlint 规则是限制句子的最大十(、)个计数。",
-      "no_double_negative_ja": "检测双重否定的 Textlint 规则。",
-      "no_doubled_conjunction": "Textlint 规则来检查重复的相同连词。",
-      "no_doubled_joshi": "Textlint 规则,用于检查同一个粒子是否连续出现在一个句子中。",
-      "no_dropping_the_ra": "检测丢弃 ra 的单词的 Textlint 规则。",
-      "no_hankaku_kana": "不允许使用半角假名的 Textlint 规则。",
-      "prefer_tari_tari": "检查 tari tari 的 Textlint 规则。",
-      "ja_unnatural_alphabet": "检测不自然的字母。",
-      "no_mixed_zenkaku_and_hankaku_alphabet": "检查混合的全角和半角字母。",
-      "no_nfd": "禁止使用 UTF8-MAC 浊音等 NFD。"
-    }
+    "editor_settings": "编辑器设置"
   },
 	"search_help": {
 		"title": "搜索帮助",
@@ -432,11 +399,6 @@
 			"Post": "提交"
 		}
 	},
-  "modal_enable_textlint": {
-    "confirm_download_dict_and_enable_textlint": "您确定要启用 Textlint 吗?这将下载 20MB 的字典文件。",
-    "enable_textlint": "启用Textlint",
-    "dont_ask_again": "不要再问"
-  },
   "modal_resolve_conflict": {
     "file_conflicting_with_newer_remote": "此文件与较新的远程文件冲突",
     "resolve_conflict_message": "选择页面正文",
@@ -706,7 +668,8 @@
     "success_to_send_email": "我发了一封电子邮件",
     "feature_is_unavailable": "此功能不可用",
     "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
-    "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
+    "password_and_confirm_password_does_not_match": "密码和确认密码不匹配",
+    "please_enable_mailer_alert": "密码重置功能被禁用,因为电子邮件设置尚未完成。请要求管理员完成电子邮件的设置。"
   },
   "emoji" :{
     "title": "选择一个表情符号",

+ 0 - 1
apps/app/src/client/services/renderer/renderer.tsx

@@ -31,7 +31,6 @@ import {
 import loggerFactory from '~/utils/logger';
 
 // import EasyGrid from './PreProcessor/EasyGrid';
-// import BlockdiagConfigurer from './markdown-it/blockdiag';
 
 import '@growi/remark-lsx/dist/client/style.css';
 

+ 0 - 2
apps/app/src/components/Admin/Notification/NotificationSetting.jsx

@@ -126,12 +126,10 @@ function NotificationSetting(props) {
       user_trigger_notification: {
         Icon: () => <i className="icon-settings" />,
         i18n: 'User trigger notification',
-        index: 0,
       },
       global_notification: {
         Icon: () => <i className="icon-settings" />,
         i18n: 'Global notification',
-        index: 1,
       },
     };
   }, []);

+ 12 - 14
apps/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx

@@ -1,9 +1,9 @@
 import React from 'react';
 
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 import PropTypes from 'prop-types';
 
-
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
@@ -52,17 +52,6 @@ class LocalSecuritySettingContents extends React.Component {
         )}
         <h2 className="alert-anchor border-bottom">{t('security_settings.Local.name')}</h2>
 
-        {!isMailerSetup && (
-          <div className="row">
-            <div className="col-12">
-              <div className="alert alert-danger">
-                <span>{t('security_settings.Local.need_complete_mail_setting_warning')}</span>
-                <a href="/admin/app#mail-settings"> <i className="fa fa-link"></i> {t('admin:app_setting.mail_settings')}</a>
-              </div>
-            </div>
-          </div>
-        )}
-
         {adminLocalSecurityContainer.state.useOnlyEnvVars && (
           <p
             className="alert alert-info"
@@ -146,7 +135,6 @@ class LocalSecuritySettingContents extends React.Component {
                     </button>
                   </div>
                 </div>
-
                 <p className="form-text text-muted small">{t('security_settings.register_limitation_desc')}</p>
               </div>
             </div>
@@ -189,6 +177,14 @@ class LocalSecuritySettingContents extends React.Component {
                     {t('security_settings.Local.enable_password_reset_by_users')}
                   </label>
                 </div>
+                {!isMailerSetup && (
+                  <div className="alert alert-warning p-1 my-1 small d-inline-block">
+                    <span>{t('commons:alert.password_reset_please_enable_mailer')}</span>
+                    <Link href="/admin/app#mail-settings">
+                      <i className="fa fa-link"></i> {t('app_setting.mail_settings')}
+                    </Link>
+                  </div>
+                )}
                 <p className="form-text text-muted small">
                   {t('security_settings.Local.password_reset_desc')}
                 </p>
@@ -213,7 +209,9 @@ class LocalSecuritySettingContents extends React.Component {
                 {!isMailerSetup && (
                   <div className="alert alert-warning p-1 my-1 small d-inline-block">
                     <span>{t('commons:alert.please_enable_mailer')}</span>
-                    <a href="/admin/app#mail-settings"> <i className="fa fa-link"></i> {t('app_setting.mail_settings')}</a>
+                    <Link href="/admin/app#mail-settings">
+                      <i className="fa fa-link"></i> {t('app_setting.mail_settings')}
+                    </Link>
                   </div>
                 )}
                 <p className="form-text text-muted small">

+ 0 - 7
apps/app/src/components/Admin/Security/SecurityManagementContents.jsx

@@ -32,37 +32,30 @@ const SecurityManagementContents = () => {
       passport_local: {
         Icon: () => <i className="fa fa-users" />,
         i18n: 'ID/Pass',
-        index: 0,
       },
       passport_ldap: {
         Icon: () => <i className="fa fa-sitemap" />,
         i18n: 'LDAP',
-        index: 1,
       },
       passport_saml: {
         Icon: () => <i className="fa fa-key" />,
         i18n: 'SAML',
-        index: 2,
       },
       passport_oidc: {
         Icon: () => <i className="fa fa-key" />,
         i18n: 'OIDC',
-        index: 3,
       },
       passport_google: {
         Icon: () => <i className="fa fa-google" />,
         i18n: 'Google',
-        index: 4,
       },
       passport_github: {
         Icon: () => <i className="fa fa-github" />,
         i18n: 'GitHub',
-        index: 5,
       },
       // passport_facebook: {
       //   Icon: () => <i className="fa fa-facebook" />,
       //   i18n: '(TBD) Facebook',
-      //   index: 7,
       // },
     };
   }, []);

+ 62 - 64
apps/app/src/components/CustomNavigation/CustomNav.jsx → apps/app/src/components/CustomNavigation/CustomNav.tsx

@@ -2,16 +2,20 @@ import React, {
   useEffect, useState, useRef, useMemo, useCallback,
 } from 'react';
 
-import PropTypes from 'prop-types';
+import { Breakpoint } from '@growi/ui/dist/interfaces/breakpoints';
 import {
   Nav, NavItem, NavLink,
 } from 'reactstrap';
 
+import { ICustomNavTabMappings } from '~/interfaces/ui';
+
 import styles from './CustomNav.module.scss';
 
 
-function getBreakpointOneLevelLarger(breakpoint) {
+function getBreakpointOneLevelLarger(breakpoint: Breakpoint): Omit<Breakpoint, 'xs' | 'sm'> {
   switch (breakpoint) {
+    case 'xs':
+      return 'sm';
     case 'sm':
       return 'md';
     case 'md':
@@ -25,12 +29,18 @@ function getBreakpointOneLevelLarger(breakpoint) {
 }
 
 
-export const CustomNavDropdown = (props) => {
+type CustomNavDropdownProps = {
+  navTabMapping: ICustomNavTabMappings,
+  activeTab: string,
+  onNavSelected?: (selectedTabKey: string) => void,
+};
+
+export const CustomNavDropdown = (props: CustomNavDropdownProps): JSX.Element => {
   const {
     activeTab, navTabMapping, onNavSelected,
   } = props;
 
-  const activeObj = navTabMapping[activeTab];
+  const { Icon, i18n } = navTabMapping[activeTab];
 
   const menuItemClickHandler = useCallback((key) => {
     if (onNavSelected != null) {
@@ -48,16 +58,15 @@ export const CustomNavDropdown = (props) => {
         aria-expanded="false"
       >
         <span className="float-left">
-          { activeObj != null && (
-            <><activeObj.Icon /> {activeObj.i18n}</>
-          ) }
+          { Icon != null && <Icon /> } {i18n}
         </span>
       </button>
       <div className="dropdown-menu dropdown-menu-right">
         {Object.entries(navTabMapping).map(([key, value]) => {
 
           const isActive = activeTab === key;
-          const isLinkEnabled = value.isLinkEnabled != null ? value.isLinkEnabled(value) : true;
+          const _isLinkEnabled = value.isLinkEnabled ?? true;
+          const isLinkEnabled = typeof _isLinkEnabled === 'boolean' ? _isLinkEnabled : _isLinkEnabled(value);
           const { Icon, i18n } = value;
 
           return (
@@ -68,7 +77,7 @@ export const CustomNavDropdown = (props) => {
               disabled={!isLinkEnabled}
               onClick={() => menuItemClickHandler(key)}
             >
-              <Icon /> {i18n}
+              { Icon != null && <Icon /> } {i18n}
             </button>
           );
         })}
@@ -77,23 +86,29 @@ export const CustomNavDropdown = (props) => {
   );
 };
 
-CustomNavDropdown.propTypes = {
-  navTabMapping: PropTypes.object.isRequired,
-  activeTab: PropTypes.string,
-  onNavSelected: PropTypes.func,
-};
 
+type CustomNavTabProps = {
+  activeTab: string,
+  navTabMapping: ICustomNavTabMappings,
+  onNavSelected?: (selectedTabKey: string) => void,
+  hideBorderBottom?: boolean,
+  breakpointToHideInactiveTabsDown?: Breakpoint,
+  navRightElement?: JSX.Element,
+};
 
-export const CustomNavTab = (props) => {
-  const navContainer = useRef();
+export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
   const [sliderWidth, setSliderWidth] = useState(0);
   const [sliderMarginLeft, setSliderMarginLeft] = useState(0);
 
   const {
-    activeTab, navTabMapping, onNavSelected, hideBorderBottom, breakpointToHideInactiveTabsDown, navRightElement,
+    activeTab, navTabMapping, onNavSelected,
+    hideBorderBottom,
+    breakpointToHideInactiveTabsDown, navRightElement,
   } = props;
 
-  const navTabRefs = useMemo(() => {
+  const navContainerRef = useRef<HTMLDivElement>(null);
+
+  const navTabRefs: { [key: string]: HTMLAnchorElement } = useMemo(() => {
     const obj = {};
     Object.keys(navTabMapping).forEach((key) => {
       obj[key] = React.createRef();
@@ -107,9 +122,9 @@ export const CustomNavTab = (props) => {
     }
   }, [onNavSelected]);
 
-  function registerNavLink(key, elm) {
-    if (elm != null) {
-      navTabRefs[key] = elm;
+  function registerNavLink(key: string, anchorElem: HTMLAnchorElement | null) {
+    if (anchorElem != null) {
+      navTabRefs[key] = anchorElem;
     }
   }
 
@@ -123,27 +138,28 @@ export const CustomNavTab = (props) => {
       return;
     }
 
-    if (navContainer == null) {
+    if (navContainerRef.current == null) {
       return;
     }
 
-    let tempML = 0;
+    const navContainer = navContainerRef.current;
 
-    const styles = Object.entries(navTabRefs).map((el) => {
-      const width = getPercentage(el[1].offsetWidth, navContainer.current.offsetWidth);
-      const marginLeft = tempML;
-      tempML += width;
-      return { width, marginLeft };
-    });
-    const { width, marginLeft } = styles[navTabMapping[activeTab].index];
+    let marginLeft = 0;
+    for (const [key, anchorElem] of Object.entries(navTabRefs)) {
+      const width = getPercentage(anchorElem.offsetWidth, navContainer.offsetWidth);
 
-    setSliderWidth(width);
-    setSliderMarginLeft(marginLeft);
+      if (key === activeTab) {
+        setSliderWidth(width);
+        setSliderMarginLeft(marginLeft);
+        break;
+      }
 
+      marginLeft += width;
+    }
   }, [activeTab, navTabRefs, navTabMapping]);
 
   // determine inactive classes to hide NavItem
-  const inactiveClassnames = [];
+  const inactiveClassnames: string[] = [];
   if (breakpointToHideInactiveTabsDown != null) {
     const breakpointOneLevelLarger = getBreakpointOneLevelLarger(breakpointToHideInactiveTabsDown);
     inactiveClassnames.push('d-none');
@@ -152,12 +168,13 @@ export const CustomNavTab = (props) => {
 
   return (
     <div className={`grw-custom-nav-tab ${styles['grw-custom-nav-tab']}`}>
-      <div ref={navContainer} className="d-flex justify-content-between">
+      <div ref={navContainerRef} className="d-flex justify-content-between">
         <Nav className="nav-title">
           {Object.entries(navTabMapping).map(([key, value]) => {
 
             const isActive = activeTab === key;
-            const isLinkEnabled = value.isLinkEnabled != null ? value.isLinkEnabled(value) : true;
+            const _isLinkEnabled = value.isLinkEnabled ?? true;
+            const isLinkEnabled = typeof _isLinkEnabled === 'boolean' ? _isLinkEnabled : _isLinkEnabled(value);
             const { Icon, i18n } = value;
 
             return (
@@ -166,7 +183,7 @@ export const CustomNavTab = (props) => {
                 className={`p-0 ${isActive ? 'active' : inactiveClassnames.join(' ')}`}
               >
                 <NavLink type="button" key={key} innerRef={elm => registerNavLink(key, elm)} disabled={!isLinkEnabled} onClick={() => navLinkClickHandler(key)}>
-                  <Icon /> {i18n}
+                  { Icon != null && <Icon /> } {i18n}
                 </NavLink>
               </NavItem>
             );
@@ -181,27 +198,23 @@ export const CustomNavTab = (props) => {
 
 };
 
-CustomNavTab.propTypes = {
-  activeTab: PropTypes.string.isRequired,
-  navTabMapping: PropTypes.object.isRequired,
-  onNavSelected: PropTypes.func,
-  hideBorderBottom: PropTypes.bool,
-  breakpointToHideInactiveTabsDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
-  navRightElement: PropTypes.node,
-};
 
-CustomNavTab.defaultProps = {
-  hideBorderBottom: false,
+type CustomNavProps = {
+  activeTab: string,
+  navTabMapping: ICustomNavTabMappings,
+  onNavSelected?: (selectedTabKey: string) => void,
+  hideBorderBottom?: boolean,
+  breakpointToHideInactiveTabsDown?: Breakpoint,
+  breakpointToSwitchDropdownDown?: Breakpoint,
 };
 
-
-const CustomNav = (props) => {
+const CustomNav = (props: CustomNavProps): JSX.Element => {
 
   const tabClassnames = ['d-none'];
   const dropdownClassnames = ['d-block'];
 
   // determine classes to show/hide
-  const breakpointOneLevelLarger = getBreakpointOneLevelLarger(props.breakpointToSwitchDropdownDown);
+  const breakpointOneLevelLarger = getBreakpointOneLevelLarger(props.breakpointToSwitchDropdownDown ?? 'sm');
   tabClassnames.push(`d-${breakpointOneLevelLarger}-block`);
   dropdownClassnames.push(`d-${breakpointOneLevelLarger}-none`);
 
@@ -218,19 +231,4 @@ const CustomNav = (props) => {
 
 };
 
-CustomNav.propTypes = {
-  activeTab: PropTypes.string.isRequired,
-  navTabMapping: PropTypes.object.isRequired,
-  onNavSelected: PropTypes.func,
-  hideBorderBottom: PropTypes.bool,
-  breakpointToHideInactiveTabsDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
-  breakpointToSwitchDropdownDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
-};
-
-CustomNav.defaultProps = {
-  hideBorderBottom: false,
-  breakpointToSwitchDropdownDown: 'sm',
-};
-
-
 export default CustomNav;

+ 2 - 1
apps/app/src/components/CustomNavigation/CustomTabContent.tsx

@@ -24,11 +24,12 @@ const CustomTabContent = (props: Props): JSX.Element => {
       {Object.entries(navTabMapping).map(([key, value]) => {
 
         const { Content } = value;
+        const content = Content != null ? <Content /> : <></>;
 
         return (
           <TabPane key={key} tabId={key}>
             <LazyRenderer shouldRender={key === activeTab}>
-              <Content />
+              {content}
             </LazyRenderer>
           </TabPane>
         );

+ 0 - 2
apps/app/src/components/DescendantsPageListModal.tsx

@@ -54,7 +54,6 @@ export const DescendantsPageListModal = (): JSX.Element => {
           return <DescendantsPageList path={status.path} />;
         },
         i18n: t('page_list'),
-        index: 0,
         isLinkEnabled: () => !isSharedUser,
       },
       timeline: {
@@ -66,7 +65,6 @@ export const DescendantsPageListModal = (): JSX.Element => {
           return <PageTimeline />;
         },
         i18n: t('Timeline View'),
-        index: 1,
         isLinkEnabled: () => !isSharedUser,
       },
     };

+ 5 - 1
apps/app/src/components/Fab.tsx

@@ -68,7 +68,11 @@ export const Fab = (): JSX.Element => {
 
   const PageCreateButton = useCallback(() => {
     return (
-      <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
+      <div
+        className={`rounded-circle position-absolute ${animateClasses}`}
+        style={{ bottom: '2.3rem', right: '4rem' }}
+        data-testid="grw-fab-page-create-button"
+      >
         <button
           type="button"
           className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 ${buttonClasses}`}

+ 20 - 3
apps/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -8,11 +8,12 @@ import { DropdownItem } from 'reactstrap';
 
 import { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
 import { apiv3Post } from '~/client/util/apiv3-client';
+import { SupportedTargetModel } from '~/interfaces/activity';
 import { IInAppNotification, InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 
 // Change the display for each targetmodel
 import PageModelNotification from './PageNotification/PageModelNotification';
-
+import UserModelNotification from './PageNotification/UserModelNotification';
 
 interface Props {
   notification: IInAppNotification & HasObjectId
@@ -40,6 +41,10 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
   };
 
   const getActionUsers = () => {
+    if (notification.targetModel === SupportedTargetModel.MODEL_USER) {
+      return notification.target.username;
+    }
+
     const latestActionUsers = notification.actionUsers.slice(0, 3);
     const latestUsers = latestActionUsers.map((user) => {
       return `@${user.name}`;
@@ -75,7 +80,6 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
         <div className="position-absolute" style={{ top: 10, left: 10 }}>
           <UserPicture user={actionUsers[1]} size="md" noTooltip />
         </div>
-
       </div>
     );
   };
@@ -139,6 +143,10 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       actionMsg = 'commented on';
       actionIcon = 'icon-bubble';
       break;
+    case 'USER_REGISTRATION_APPROVAL_REQUEST':
+      actionMsg = 'requested registration approval';
+      actionIcon = 'icon-bubble';
+      break;
     default:
       actionMsg = '';
       actionIcon = '';
@@ -163,7 +171,7 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
         >
         </span>
         {renderActionUserPictures()}
-        {notification.targetModel === 'Page' && (
+        {notification.targetModel === SupportedTargetModel.MODEL_PAGE && (
           <PageModelNotification
             ref={notificationRef}
             notification={notification}
@@ -172,6 +180,15 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
             actionUsers={actionUsers}
           />
         )}
+        {notification.targetModel === SupportedTargetModel.MODEL_USER && (
+          <UserModelNotification
+            ref={notificationRef}
+            notification={notification}
+            actionMsg={actionMsg}
+            actionIcon={actionIcon}
+            actionUsers={actionUsers}
+          />
+        )}
       </div>
     </TagElem>
   );

+ 0 - 2
apps/app/src/components/InAppNotification/InAppNotificationPage.tsx

@@ -127,13 +127,11 @@ export const InAppNotificationPage: FC = () => {
       Icon: () => <></>,
       Content: () => InAppNotificationCategoryByStatus(),
       i18n: t('in_app_notification.all'),
-      index: 0,
     },
     external_accounts: {
       Icon: () => <></>,
       Content: () => InAppNotificationCategoryByStatus(InAppNotificationStatuses.STATUS_UNOPENED),
       i18n: t('in_app_notification.unopend'),
-      index: 1,
     },
   };
 

+ 46 - 0
apps/app/src/components/InAppNotification/PageNotification/UserModelNotification.tsx

@@ -0,0 +1,46 @@
+import React, {
+  forwardRef, ForwardRefRenderFunction, useImperativeHandle,
+} from 'react';
+
+import { HasObjectId } from '@growi/core';
+import { useRouter } from 'next/router';
+
+import type { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
+import type { IInAppNotification } from '~/interfaces/in-app-notification';
+
+import FormattedDistanceDate from '../../FormattedDistanceDate';
+
+const UserModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable, {
+  notification: IInAppNotification & HasObjectId
+  actionMsg: string
+  actionIcon: string
+  actionUsers: string
+}> = ({
+  notification, actionMsg, actionIcon, actionUsers,
+}, ref) => {
+  const router = useRouter();
+
+  // publish open()
+  useImperativeHandle(ref, () => ({
+    open() {
+      router.push('/admin/users');
+    },
+  }));
+
+  return (
+    <div className="p-2 overflow-hidden">
+      <div className="text-truncate">
+        <b>{actionUsers}</b> {actionMsg}
+      </div>
+      <i className={`${actionIcon} mr-2`} />
+      <FormattedDistanceDate
+        id={notification._id}
+        date={notification.createdAt}
+        isShowTooltip={false}
+        differenceForAvoidingFormat={Number.POSITIVE_INFINITY}
+      />
+    </div>
+  );
+};
+
+export default forwardRef(UserModelNotification);

+ 17 - 261
apps/app/src/components/Me/EditorSettings.tsx

@@ -1,273 +1,28 @@
-import React, {
-  Dispatch, memo,
-  FC, SetStateAction, useCallback, useEffect, useState,
-  useMemo,
-} from 'react';
-
-import { useTranslation } from 'next-i18next';
-
-import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useEditorSettings } from '~/stores/editor';
-
-
-type EditorSettingsBodyProps = Record<string, never>;
-
-type RuleListGroupProps = {
-  title: string;
-  ruleList: RulesMenuItem[]
-  textlintRules: LintRule[]
-  setTextlintRules: Dispatch<SetStateAction<LintRule[]>>
-}
-
-type LintRule = {
-  name: string
-  options?: unknown
-  isEnabled?: boolean
-}
-
-type RulesMenuItem = {
-  name: string
-  description: string
-}
-
-
-const commonRulesMenuItems = [
-  {
-    name: 'common-misspellings',
-    description: 'editor_settings.common_settings.common_misspellings',
-  },
-  {
-    name: 'max-comma',
-    description: 'editor_settings.common_settings.max_comma',
-  },
-  {
-    name: 'sentence-length',
-    description: 'editor_settings.common_settings.sentence_length',
-  },
-  // {  // omit because en-pos package is too big
-  //   name: 'en-capitalization',
-  //   description: 'editor_settings.common_settings.en_capitalization',
-  // },
-  {
-    name: 'no-unmatched-pair',
-    description: 'editor_settings.common_settings.no_unmatched_pair',
-  },
-  {
-    name: 'date-weekday-mismatch',
-    description: 'editor_settings.common_settings.date_weekday_mismatch',
-  },
-  {
-    name: 'no-kangxi-radicals',
-    description: 'editor_settings.common_settings.no_kangxi_radicals',
-  },
-  {
-    name: 'no-surrogate-pair',
-    description: 'editor_settings.common_settings.no_surrogate_pair',
-  },
-  {
-    name: 'no-zero-width-spaces',
-    description: 'editor_settings.common_settings.no_zero_width_spaces',
-  },
-  {
-    name: 'period-in-list-item',
-    description: 'editor_settings.common_settings.period_in_list_item',
-  },
-  {
-    name: 'use-si-units',
-    description: 'editor_settings.common_settings.use_si_units',
-  },
-];
-
-const japaneseRulesMenuItems = [
-  {
-    name: 'ja-hiragana-keishikimeishi',
-    description: 'editor_settings.japanese_settings.ja_hiragana_keishikimeishi',
-  },
-  {
-    name: 'ja-no-abusage',
-    description: 'editor_settings.japanese_settings.ja_no_abusage',
-  },
-  {
-    name: 'ja-no-inappropriate-words',
-    description: 'editor_settings.japanese_settings.ja_no_inappropriate_words',
-  },
-  {
-    name: 'ja-no-mixed-period',
-    description: 'editor_settings.japanese_settings.ja_no_mixed_period',
-  },
-  {
-    name: 'ja-no-redundant-expression',
-    description: 'editor_settings.japanese_settings.ja_no_redundant_expression',
-  },
-  {
-    name: 'max-kanji-continuous-len',
-    description: 'editor_settings.japanese_settings.max_kanji_continuous_len',
-  },
-  {
-    name: 'max-ten',
-    description: 'editor_settings.japanese_settings.max_ten',
-  },
-  {
-    name: 'no-double-negative-ja',
-    description: 'editor_settings.japanese_settings.no_double_negative_ja',
-  },
-  {
-    name: 'no-doubled-conjunction',
-    description: 'editor_settings.japanese_settings.no_doubled_conjunction',
-  },
-  {
-    name: 'no-doubled-joshi',
-    description: 'editor_settings.japanese_settings.no_doubled_joshi',
-  },
-  {
-    name: 'no-dropping-the-ra',
-    description: 'editor_settings.japanese_settings.no_dropping_the_ra',
-  },
-  {
-    name: 'no-hankaku-kana',
-    description: 'editor_settings.japanese_settings.no_hankaku_kana',
-  },
-  {
-    name: 'prefer-tari-tari',
-    description: 'editor_settings.japanese_settings.prefer_tari_tari',
-  },
-  {
-    name: 'ja-unnatural-alphabet',
-    description: 'editor_settings.japanese_settings.ja_unnatural_alphabet',
-  },
-  {
-    name: 'no-mixed-zenkaku-and-hankaku-alphabet',
-    description: 'editor_settings.japanese_settings.no_mixed_zenkaku_and_hankaku_alphabet',
-  },
-  {
-    name: 'no-nfd',
-    description: 'editor_settings.japanese_settings.no_nfd',
-  },
-
-];
-
-
-const RuleListGroup: FC<RuleListGroupProps> = ({
-  title, ruleList, textlintRules, setTextlintRules,
-}: RuleListGroupProps) => {
-  const { t } = useTranslation();
-
-  const isCheckedRule = (ruleName: string) => (
-    textlintRules.find(stateRule => (
-      stateRule.name === ruleName
-    ))?.isEnabled || false
-  );
-
-  const ruleCheckboxHandler = (isChecked: boolean, ruleName: string) => {
-    setTextlintRules(prevState => (
-      prevState.filter(rule => rule.name !== ruleName).concat({ name: ruleName, isEnabled: isChecked })
-    ));
-  };
-
-  return (
-    <>
-      <h2 className="border-bottom my-4">{t(title)}</h2>
-      <div className="form-group row">
-        <div className="offset-md-3 col-md-6 text-left">
-          {ruleList.map(rule => (
-            <div
-              key={rule.name}
-              className="custom-control custom-switch custom-checkbox-success"
-            >
-              <input
-                type="checkbox"
-                className="custom-control-input"
-                id={rule.name}
-                checked={isCheckedRule(rule.name)}
-                onChange={e => ruleCheckboxHandler(e.target.checked, rule.name)}
-              />
-              <label className="custom-control-label" htmlFor={rule.name}>
-                <strong>{rule.name}</strong>
-              </label>
-              <p className="form-text text-muted small">
-                {t(rule.description)}
-              </p>
-            </div>
-          ))}
-        </div>
-      </div>
-    </>
-  );
-};
-
-const createRulesFromDefaultList = (rule: { name: string }) => (
-  {
-    name: rule.name,
-    isEnabled: true,
-  }
-);
-
+import { memo } from 'react';
 
 export const EditorSettings = memo((): JSX.Element => {
-  const { t } = useTranslation();
-  const [textlintRules, setTextlintRules] = useState<LintRule[]>([]);
+  // const { t } = useTranslation();
 
-  const { data: dataEditorSettings, update: updateEditorSettings } = useEditorSettings();
+  // const { data: dataEditorSettings, update: updateEditorSettings } = useEditorSettings();
 
-  const defaultRules = useMemo(() => {
-    const defaultCommonRules = commonRulesMenuItems.map(rule => createRulesFromDefaultList(rule));
-    const defaultJapaneseRules = japaneseRulesMenuItems.map(rule => createRulesFromDefaultList(rule));
+  // const updateRulesHandler = useCallback(async() => {
+  //   try {
+  //     await updateEditorSettings({ textlintSettings: { textlintRules } });
+  //     toastSuccess(t('toaster.update_successed', { target: 'Updated Textlint Settings', ns: 'commons' }));
+  //   }
+  //   catch (err) {
+  //     toastError(err);
+  //   }
+  // }, [t, textlintRules, updateEditorSettings]);
 
-    return [...defaultCommonRules, ...defaultJapaneseRules];
-  }, []);
-
-  const initializeEditorSettings = useCallback(() => {
-    if (dataEditorSettings == null) {
-      return;
-    }
-
-    const retrievedRules: LintRule[] | undefined = dataEditorSettings?.textlintSettings?.textlintRules;
-
-    // If database is empty, add default rules to state
-    if (retrievedRules != null && retrievedRules.length > 0) {
-      setTextlintRules(retrievedRules);
-      return;
-    }
-    setTextlintRules(defaultRules);
-  }, [dataEditorSettings, defaultRules]);
-
-  const updateRulesHandler = useCallback(async() => {
-    try {
-      await updateEditorSettings({ textlintSettings: { textlintRules } });
-      toastSuccess(t('toaster.update_successed', { target: 'Updated Textlint Settings', ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [t, textlintRules, updateEditorSettings]);
-
-  useEffect(() => {
-    initializeEditorSettings();
-  }, [initializeEditorSettings]);
-
-  if (textlintRules == null) {
-    return (
-      <div className="text-muted text-center">
-        <i className="fa fa-2x fa-spinner fa-pulse"></i>
-      </div>
-    );
-  }
+  // useEffect(() => {
+  //   initializeEditorSettings();
+  // }, [initializeEditorSettings]);
 
   return (
     <div data-testid="grw-editor-settings">
-      <RuleListGroup
-        title="editor_settings.common_settings.common_settings"
-        ruleList={commonRulesMenuItems}
-        textlintRules={textlintRules}
-        setTextlintRules={setTextlintRules}
-      />
-      <RuleListGroup
-        title="editor_settings.japanese_settings.japanese_settings"
-        ruleList={japaneseRulesMenuItems}
-        textlintRules={textlintRules}
-        setTextlintRules={setTextlintRules}
-      />
 
+      {/*
       <div className="row my-3">
         <div className="offset-4 col-5">
           <button
@@ -280,6 +35,7 @@ export const EditorSettings = memo((): JSX.Element => {
           </button>
         </div>
       </div>
+      */}
     </div>
   );
 });

+ 6 - 12
apps/app/src/components/Me/PersonalSettings.jsx

@@ -6,7 +6,7 @@ import { useTranslation } from 'next-i18next';
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
 
 import ApiSettings from './ApiSettings';
-import { EditorSettings } from './EditorSettings';
+// import { EditorSettings } from './EditorSettings';
 import ExternalAccountLinkedMe from './ExternalAccountLinkedMe';
 import InAppNotificationSettings from './InAppNotificationSettings';
 import OtherSettings from './OtherSettings';
@@ -23,37 +23,31 @@ const PersonalSettings = () => {
         Icon: () => <i className="icon-fw icon-user"></i>,
         Content: UserSettings,
         i18n: t('User Information'),
-        index: 0,
       },
       external_accounts: {
         Icon: () => <i className="icon-fw icon-share-alt"></i>,
         Content: ExternalAccountLinkedMe,
         i18n: t('admin:user_management.external_accounts'),
-        index: 1,
       },
       password_settings: {
         Icon: () => <i className="icon-fw icon-lock"></i>,
         Content: PasswordSettings,
         i18n: t('Password Settings'),
-        index: 2,
       },
       api_settings: {
         Icon: () => <i className="icon-fw icon-paper-plane"></i>,
         Content: ApiSettings,
         i18n: t('API Settings'),
-        index: 3,
-      },
-      editor_settings: {
-        Icon: () => <i className="icon-fw icon-pencil"></i>,
-        Content: EditorSettings,
-        i18n: t('editor_settings.editor_settings'),
-        index: 4,
       },
+      // editor_settings: {
+      //   Icon: () => <i className="icon-fw icon-pencil"></i>,
+      //   Content: EditorSettings,
+      //   i18n: t('editor_settings.editor_settings'),
+      // },
       in_app_notification_settings: {
         Icon: () => <i className="icon-fw icon-bell"></i>,
         Content: InAppNotificationSettings,
         i18n: t('in_app_notification_settings.in_app_notification_settings'),
-        index: 5,
       },
       other_settings: {
         Icon: () => <i className="icon-fw icon-settings"></i>,

+ 1 - 1
apps/app/src/components/Navbar/GrowiSubNavigationSwitcher.tsx

@@ -83,7 +83,7 @@ export const GrowiSubNavigationSwitcher = (props: GrowiSubNavigationSwitcherProp
   }
 
   return (
-    <div className={`${styles['grw-subnav-switcher']} ${isSticky ? '' : 'grw-subnav-switcher-hidden'}`}>
+    <div className={`${styles['grw-subnav-switcher']} ${isSticky ? '' : 'grw-subnav-switcher-hidden'}`} data-testid="grw-subnav-switcher" >
       <div
         id="grw-subnav-fixed-container"
         className={`grw-subnav-fixed-container ${styles['grw-subnav-fixed-container']} position-fixed grw-subnav-append-shadow-container`}

+ 0 - 2
apps/app/src/components/NotFoundPage.tsx

@@ -24,13 +24,11 @@ const NotFoundPage = (props: NotFoundPageProps): JSX.Element => {
         Icon: PageListIcon,
         Content: () => <DescendantsPageList path={path} />,
         i18n: t('page_list'),
-        index: 0,
       },
       timeLine: {
         Icon: TimeLineIcon,
         Content: PageTimeline,
         i18n: t('Timeline View'),
-        index: 1,
       },
     };
   }, [path, t]);

+ 1 - 4
apps/app/src/components/PageAccessoriesModal.tsx

@@ -78,7 +78,6 @@ const PageAccessoriesModal = (): JSX.Element => {
           return <PageHistory onClose={close} sourceRevisionId={sourceRevisionId} targetRevisionId={targetRevisionId}/>;
         },
         i18n: t('History'),
-        index: 0,
         isLinkEnabled: () => !isGuestUser && !isSharedUser,
       },
       [PageAccessoriesModalContents.Attachment]: {
@@ -87,7 +86,6 @@ const PageAccessoriesModal = (): JSX.Element => {
           return <PageAttachment />;
         },
         i18n: t('attachment_data'),
-        index: 1,
       },
       [PageAccessoriesModalContents.ShareLink]: {
         Icon: ShareLinkIcon,
@@ -95,7 +93,6 @@ const PageAccessoriesModal = (): JSX.Element => {
           return <ShareLink />;
         },
         i18n: t('share_links.share_link_management'),
-        index: 2,
         isLinkEnabled: () => !isGuestUser && !isSharedUser && !isLinkSharingDisabled,
       },
     };
@@ -133,7 +130,7 @@ const PageAccessoriesModal = (): JSX.Element => {
           activeTab={activeTab}
           navTabMapping={navTabMapping}
           breakpointToHideInactiveTabsDown="md"
-          onNavSelected={(v) => {
+          onNavSelected={(v: PageAccessoriesModalContents) => {
             setActiveTab(v);
           }}
           hideBorderBottom

+ 0 - 2
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -36,12 +36,10 @@ const navTabMapping = {
   comment_editor: {
     Icon: () => <i className="icon-settings" />,
     i18n: 'Write',
-    index: 0,
   },
   comment_preview: {
     Icon: () => <i className="icon-settings" />,
     i18n: 'Preview',
-    index: 1,
   },
 };
 

+ 12 - 6
apps/app/src/components/PageEditor.tsx

@@ -25,10 +25,11 @@ import {
   useIsEditable, useIsUploadableFile, useIsUploadableImage, useIsIndentSizeForced,
 } from '~/stores/context';
 import {
-  useCurrentIndentSize, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
+  useCurrentIndentSize, useIsSlackEnabled, usePageTagsForEditors,
   useIsEnabledUnsavedWarning,
   useIsConflict,
   useEditingMarkdown,
+  useWaitingSaveProcessing,
 } from '~/stores/editor';
 import { useConflictDiffModal } from '~/stores/modal';
 import {
@@ -76,7 +77,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { t } = useTranslation();
   const router = useRouter();
 
-  const { data: isNotFound, mutate: mutateIsNotFound } = useIsNotFound();
+  const { data: isNotFound } = useIsNotFound();
   const { data: pageId, mutate: mutateCurrentPageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
@@ -89,9 +90,9 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
   const { data: templateBodyData } = useTemplateBodyData();
   const { data: isEditable } = useIsEditable();
+  const { mutate: mutateWaitingSaveProcessing } = useWaitingSaveProcessing();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { data: isUploadableFile } = useIsUploadableFile();
@@ -201,6 +202,8 @@ const PageEditor = React.memo((): JSX.Element => {
     const options = Object.assign(optionsToSave, opts);
 
     try {
+      mutateWaitingSaveProcessing(true);
+
       const { page } = await saveOrUpdate(
         markdownToSave.current,
         { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId },
@@ -223,10 +226,14 @@ const PageEditor = React.memo((): JSX.Element => {
       }
       return null;
     }
+    finally {
+      mutateWaitingSaveProcessing(false);
+    }
 
   }, [
     currentPathname, optionsToSave, grantData, isSlackEnabled, saveOrUpdate, pageId,
-    currentPagePath, currentRevisionId, mutateRemotePageId, mutateRemoteRevisionId, mutateRemoteRevisionLastUpdatedAt, mutateRemoteRevisionLastUpdateUser,
+    currentPagePath, currentRevisionId,
+    mutateWaitingSaveProcessing, mutateRemotePageId, mutateRemoteRevisionId, mutateRemoteRevisionLastUpdatedAt, mutateRemoteRevisionLastUpdateUser,
   ]);
 
   const saveAndReturnToViewHandler = useCallback(async(opts: {slackChannels: string, overwriteScopesOfDescendants?: boolean}) => {
@@ -331,7 +338,7 @@ const PageEditor = React.memo((): JSX.Element => {
     finally {
       editorRef.current.terminateUploadingState();
     }
-  }, [currentPagePath, mutateCurrentPage, mutateCurrentPageId, mutateGrant, mutateIsLatestRevision, mutateIsNotFound, pageId]);
+  }, [currentPagePath, mutateCurrentPage, mutateCurrentPageId, mutateGrant, mutateIsLatestRevision, pageId]);
 
 
   const scrollPreviewByEditorLine = useCallback((line: number) => {
@@ -528,7 +535,6 @@ const PageEditor = React.memo((): JSX.Element => {
           value={initialValue}
           isUploadable={isUploadable}
           isUploadableFile={isUploadableFile}
-          isTextlintEnabled={isTextlintEnabled}
           indentSize={currentIndentSize}
           onScroll={editorScrolledHandler}
           onScrollCursorIntoView={editorScrollCursorIntoViewHandler}

+ 0 - 30
apps/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -1,8 +1,6 @@
 import React, { useCallback, memo } from 'react';
 
-import { createValidator } from '@growi/codemirror-textlint';
 import { commands } from 'codemirror';
-import { JSHINT } from 'jshint';
 import * as loadCssSync from 'load-css-file';
 import PropTypes from 'prop-types';
 import { Button } from 'reactstrap';
@@ -36,10 +34,6 @@ import SimpleCheatsheet from './SimpleCheatsheet';
 
 import styles from './CodeMirrorEditor.module.scss';
 
-// Textlint
-window.JSHINT = JSHINT;
-window.kuromojin = { dicPath: '/static/dict' };
-
 require('codemirror/addon/hint/show-hint.css'); // Import from CodeMirrorEditor.module.scss not working
 require('codemirror/addon/display/placeholder');
 require('codemirror/addon/edit/matchbrackets');
@@ -57,7 +51,6 @@ require('codemirror/addon/fold/foldgutter');
 require('codemirror/addon/fold/markdown-fold');
 require('codemirror/addon/fold/brace-fold');
 require('codemirror/addon/display/placeholder');
-require('codemirror/addon/lint/lint');
 require('~/client/util/codemirror/autorefresh.ext');
 require('~/client/util/codemirror/drawio-fold.ext');
 require('~/client/util/codemirror/gfm-growi.mode');
@@ -208,8 +201,6 @@ class CodeMirrorEditor extends AbstractEditor {
   componentWillReceiveProps(nextProps) {
     this.initializeEditorSettings(nextProps.editorSettings);
 
-    this.initializeTextlint(nextProps.isTextlintEnabled, nextProps.editorSettings);
-
     // fold drawio section
     this.foldDrawioSection();
   }
@@ -232,19 +223,6 @@ class CodeMirrorEditor extends AbstractEditor {
     }
   }
 
-  async initializeTextlint(isTextlintEnabled, editorSettings) {
-    if (!isTextlintEnabled || editorSettings == null) {
-      return;
-    }
-
-    const textlintRules = editorSettings.textlintSettings?.textlintRules;
-
-    // If database has empty array, pass null instead to enable all default rules
-    const rulesForValidator = (textlintRules == null || textlintRules.length === 0) ? null : textlintRules;
-    this.textlintValidator = createValidator(rulesForValidator);
-    this.codemirrorLintConfig = { getAnnotations: this.textlintValidator, async: true };
-  }
-
   getCodeMirror() {
     return this.cm.current?.editor;
   }
@@ -1071,9 +1049,6 @@ class CodeMirrorEditor extends AbstractEditor {
 
 
   render() {
-    const { isTextlintEnabled } = this.props;
-
-    const lint = isTextlintEnabled ? this.codemirrorLintConfig : false;
     const additionalClasses = Array.from(this.state.additionalClassSet).join(' ');
     const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plain Text..';
 
@@ -1081,9 +1056,6 @@ class CodeMirrorEditor extends AbstractEditor {
     if (this.props.lineNumbers != null) {
       gutters.push('CodeMirror-linenumbers', 'CodeMirror-foldgutter');
     }
-    if (isTextlintEnabled) {
-      gutters.push('CodeMirror-lint-markers');
-    }
 
     return (
       <div className={`grw-codemirror-editor ${styles['grw-codemirror-editor']}`}>
@@ -1119,7 +1091,6 @@ class CodeMirrorEditor extends AbstractEditor {
               'Shift-Tab': 'indentLess',
               'Ctrl-Q': (cm) => { cm.foldCode(cm.getCursor()) },
             },
-            lint,
           }}
           onCursor={this.cursorHandlerDebounced}
           onScroll={(editor, data) => {
@@ -1166,7 +1137,6 @@ class CodeMirrorEditor extends AbstractEditor {
 }
 
 CodeMirrorEditor.propTypes = Object.assign({
-  isTextlintEnabled: PropTypes.bool,
   lineNumbers: PropTypes.bool,
   editorSettings: PropTypes.object.isRequired,
   onMarkdownHelpButtonClicked: PropTypes.func,

+ 0 - 69
apps/app/src/components/PageEditor/DownloadDictModal.tsx

@@ -1,69 +0,0 @@
-import React, { useState } from 'react';
-
-import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-type DownloadDictModalProps = {
-  isModalOpen: boolean
-  onEnableTextlint?: (isSkipAskingAgainChecked: boolean) => void;
-  onCancel?: () => void;
-};
-
-export const DownloadDictModal = (props: DownloadDictModalProps): JSX.Element => {
-  const { t } = useTranslation('');
-  const [isSkipAskingAgainChecked, setIsSkipAskingAgainChecked] = useState(false);
-
-  const onCancel = () => {
-    if (props.onCancel != null) {
-      props.onCancel();
-    }
-  };
-
-  const onConfirmEnableTextlint = () => {
-    if (props.onEnableTextlint != null) {
-      props.onEnableTextlint(isSkipAskingAgainChecked);
-    }
-  };
-
-  return (
-    <Modal isOpen={props.isModalOpen} toggle={onCancel} className="">
-      <ModalHeader tag="h4" toggle={onCancel} className="bg-warning">
-        <i className="icon-fw icon-question" />
-        Warning
-      </ModalHeader>
-      <ModalBody>
-        {t('modal_enable_textlint.confirm_download_dict_and_enable_textlint')}
-      </ModalBody>
-      <ModalFooter>
-        <div className="mr-3 custom-control custom-checkbox custom-checkbox-info">
-          <input
-            type="checkbox"
-            className="custom-control-input"
-            id="dont-ask-again"
-            checked={isSkipAskingAgainChecked}
-            onChange={e => setIsSkipAskingAgainChecked(e.target.checked)}
-          />
-          <label className="custom-control-label align-center" htmlFor="dont-ask-again">
-            {t('modal_enable_textlint.dont_ask_again')}
-          </label>
-        </div>
-        <button
-          type="button"
-          className="btn btn-outline-secondary"
-          onClick={onCancel}
-        >
-          {t('Cancel')}
-        </button>
-        <button
-          type="button"
-          className="btn btn-outline-primary ml-3"
-          onClick={onConfirmEnableTextlint}
-        >
-          {t('modal_enable_textlint.enable_textlint')}
-        </button>
-      </ModalFooter>
-    </Modal>
-  );
-};

+ 8 - 8
apps/app/src/components/PageEditor/DrawioModal.tsx

@@ -10,7 +10,7 @@ import {
 } from 'reactstrap';
 
 import { getDiagramsNetLangCode } from '~/client/util/locale-utils';
-import { useDrawioUri } from '~/stores/context';
+import { useRendererConfig } from '~/stores/context';
 import { useDrawioModal } from '~/stores/modal';
 import { usePersonalSettings } from '~/stores/personal-settings';
 import loggerFactory from '~/utils/logger';
@@ -38,7 +38,7 @@ const drawioConfig: DrawioConfig = {
 
 
 export const DrawioModal = (): JSX.Element => {
-  const { data: drawioUri } = useDrawioUri();
+  const { data: rendererConfig } = useRendererConfig();
   const { data: personalSettingsInfo } = usePersonalSettings({
     // make immutable
     revalidateIfStale: false,
@@ -50,13 +50,13 @@ export const DrawioModal = (): JSX.Element => {
   const isOpened = drawioModalData?.isOpened ?? false;
 
   const drawioUriWithParams = useMemo(() => {
-    if (drawioUri == null) {
+    if (rendererConfig == null) {
       return undefined;
     }
 
     let url;
     try {
-      url = new URL(drawioUri);
+      url = new URL(rendererConfig.drawioUri);
     }
     catch (err) {
       logger.debug(err);
@@ -71,19 +71,19 @@ export const DrawioModal = (): JSX.Element => {
     url.searchParams.append('configure', '1');
 
     return url;
-  }, [drawioUri, personalSettingsInfo?.lang]);
+  }, [rendererConfig, personalSettingsInfo?.lang]);
 
   const drawioCommunicationHelper = useMemo(() => {
-    if (drawioUri == null) {
+    if (rendererConfig == null) {
       return undefined;
     }
 
     return new DrawioCommunicationHelper(
-      drawioUri,
+      rendererConfig.drawioUri,
       drawioConfig,
       { onClose: closeDrawioModal, onSave: drawioModalData?.onSave },
     );
-  }, [closeDrawioModal, drawioModalData?.onSave, drawioUri]);
+  }, [closeDrawioModal, drawioModalData?.onSave, rendererConfig]);
 
   const receiveMessageHandler = useCallback((event: MessageEvent) => {
     if (drawioModalData == null) {

+ 0 - 1
apps/app/src/components/PageEditor/Editor.tsx

@@ -32,7 +32,6 @@ export type EditorPropsType = {
   noCdn?: boolean,
   isUploadable?: boolean,
   isUploadableFile?: boolean,
-  isTextlintEnabled?: boolean,
   onChange?: (newValue: string, isClean?: boolean) => void,
   onUpload?: (file) => void,
   editorSettings?: IEditorSettings,

+ 4 - 72
apps/app/src/components/PageEditor/OptionsSelector.tsx

@@ -8,12 +8,10 @@ import {
 } from 'reactstrap';
 
 import { useIsIndentSizeForced } from '~/stores/context';
-import { useEditorSettings, useIsTextlintEnabled, useCurrentIndentSize } from '~/stores/editor';
+import { useEditorSettings, useCurrentIndentSize } from '~/stores/editor';
 
 import { DEFAULT_THEME, KeyMapMode } from '../../interfaces/editor-settings';
 
-import { DownloadDictModal } from './DownloadDictModal';
-
 
 const AVAILABLE_THEMES = [
   'eclipse', 'elegant', 'neo', 'mdn-like', 'material', 'dracula', 'monokai', 'twilight',
@@ -162,19 +160,13 @@ const IndentSizeSelector = memo(({ isIndentSizeForced, selectedIndentSize, onCha
 IndentSizeSelector.displayName = 'IndentSizeSelector';
 
 
-type ConfigurationDropdownProps = {
-  onConfirmEnableTextlint?: () => void,
-}
-
-const ConfigurationDropdown = memo(({ onConfirmEnableTextlint }: ConfigurationDropdownProps): JSX.Element => {
+const ConfigurationDropdown = memo((): JSX.Element => {
   const { t } = useTranslation();
 
   const [isCddMenuOpened, setCddMenuOpened] = useState(false);
 
   const { data: editorSettings, update } = useEditorSettings();
 
-  const { data: isTextlintEnabled, mutate: mutateTextlintEnabled } = useIsTextlintEnabled();
-
   const renderActiveLineMenuItem = useCallback(() => {
     if (editorSettings == null) {
       return <></>;
@@ -223,44 +215,6 @@ const ConfigurationDropdown = memo(({ onConfirmEnableTextlint }: ConfigurationDr
     );
   }, [editorSettings, t, update]);
 
-  const renderIsTextlintEnabledMenuItem = useCallback(() => {
-    if (editorSettings == null) {
-      return <></>;
-    }
-
-    const clickHandler = () => {
-      if (isTextlintEnabled) {
-        mutateTextlintEnabled(false);
-        return;
-      }
-
-      if (editorSettings.textlintSettings?.neverAskBeforeDownloadLargeFiles) {
-        mutateTextlintEnabled(true);
-        return;
-      }
-
-      if (onConfirmEnableTextlint != null) {
-        onConfirmEnableTextlint();
-      }
-    };
-
-    const iconClasses = ['text-info'];
-    if (isTextlintEnabled) {
-      iconClasses.push('ti ti-check');
-    }
-    const iconClassName = iconClasses.join(' ');
-
-    return (
-      <DropdownItem toggle={false} onClick={clickHandler}>
-        <div className="d-flex justify-content-between">
-          <span className="icon-container"></span>
-          <span className="menuitem-label">Textlint</span>
-          <span className="icon-container"><i className={iconClassName}></i></span>
-        </div>
-      </DropdownItem>
-    );
-  }, [editorSettings, isTextlintEnabled, mutateTextlintEnabled, onConfirmEnableTextlint]);
-
   return (
     <div className="my-0 form-group">
       <Dropdown
@@ -277,7 +231,6 @@ const ConfigurationDropdown = memo(({ onConfirmEnableTextlint }: ConfigurationDr
         <DropdownMenu>
           {renderActiveLineMenuItem()}
           {renderMarkdownTableAutoFormattingMenuItem()}
-          {renderIsTextlintEnabledMenuItem()}
           {/* <DropdownItem divider /> */}
         </DropdownMenu>
 
@@ -291,10 +244,7 @@ ConfigurationDropdown.displayName = 'ConfigurationDropdown';
 
 
 export const OptionsSelector = (): JSX.Element => {
-  const [isDownloadDictModalShown, setDownloadDictModalShown] = useState(false);
-
-  const { data: editorSettings, turnOffAskingBeforeDownloadLargeFiles } = useEditorSettings();
-  const { mutate: mutateTextlintEnabled } = useIsTextlintEnabled();
+  const { data: editorSettings } = useEditorSettings();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
 
@@ -319,27 +269,9 @@ export const OptionsSelector = (): JSX.Element => {
           />
         </span>
         <span className="ml-2 ml-sm-4">
-          <ConfigurationDropdown
-            onConfirmEnableTextlint={() => setDownloadDictModalShown(true)}
-          />
+          <ConfigurationDropdown />
         </span>
       </div>
-
-      { editorSettings != null && !editorSettings.textlintSettings?.neverAskBeforeDownloadLargeFiles && (
-        <DownloadDictModal
-          isModalOpen={isDownloadDictModalShown}
-          onEnableTextlint={(isSkipAskingAgainChecked) => {
-            mutateTextlintEnabled(true);
-
-            if (isSkipAskingAgainChecked) {
-              turnOffAskingBeforeDownloadLargeFiles();
-            }
-
-            setDownloadDictModalShown(false);
-          }}
-          onCancel={() => setDownloadDictModalShown(false)}
-        />
-      )}
     </>
   );
 

+ 24 - 3
apps/app/src/components/PageEditorByHackmd.tsx

@@ -19,7 +19,7 @@ import {
   useCurrentPathname, useHackmdUri,
 } from '~/stores/context';
 import {
-  useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
+  useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning, useWaitingSaveProcessing,
 } from '~/stores/editor';
 import {
   usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useIsHackmdDraftUpdatingInRealtime,
@@ -56,6 +56,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const router = useRouter();
 
   const { data: isNotFound } = useIsNotFound();
+  const { mutate: mutateWaitingSaveProcessing } = useWaitingSaveProcessing();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
@@ -116,6 +117,8 @@ export const PageEditorByHackmd = (): JSX.Element => {
         throw new Error('Some materials to save are invalid');
       }
 
+      mutateWaitingSaveProcessing(true);
+
       const options = Object.assign(optionsToSave, opts, { isSyncRevisionToHackmd: true });
 
       const markdown = await hackmdEditorRef.current.getValue();
@@ -142,8 +145,16 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error('failed to save', error);
       toastError(error.message);
     }
+    finally {
+      mutateWaitingSaveProcessing(false);
+    }
+
   // eslint-disable-next-line max-len
-  }, [editorMode, currentPathname, revision, revisionIdHackmdSynced, optionsToSave, saveOrUpdate, pageId, currentPagePath, isNotFound, mutateEditorMode, router, updateStateAfterSave, mutateIsHackmdDraftUpdatingInRealtime]);
+  }, [
+    pageId, currentPagePath, isNotFound, router,
+    editorMode, currentPathname, revision, revisionIdHackmdSynced, optionsToSave,
+    saveOrUpdate, mutateEditorMode, updateStateAfterSave, mutateIsHackmdDraftUpdatingInRealtime, mutateWaitingSaveProcessing,
+  ]);
 
   // set handler to save and reload Page
   useEffect(() => {
@@ -249,6 +260,8 @@ export const PageEditorByHackmd = (): JSX.Element => {
    */
   const onSaveWithShortcut = useCallback(async(markdown) => {
     try {
+      mutateWaitingSaveProcessing(true);
+
       const currentPagePathOrPathname = currentPagePath || currentPathname;
       if (
         pageId == null || revisionIdHackmdSynced == null || currentPagePathOrPathname == null || optionsToSave == null
@@ -278,8 +291,16 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error('failed to save', error);
       toastError(error.message);
     }
+    finally {
+      mutateWaitingSaveProcessing(false);
+    }
+
   // eslint-disable-next-line max-len
-  }, [currentPagePath, currentPathname, pageId, revisionIdHackmdSynced, optionsToSave, saveOrUpdate, mutatePageData, updateStateAfterSave, mutateTagsInfo, mutateIsEnabledUnsavedWarning, t]);
+  }, [
+    currentPagePath, currentPathname, pageId, revisionIdHackmdSynced, optionsToSave,
+    saveOrUpdate,
+    mutateWaitingSaveProcessing, mutatePageData, updateStateAfterSave, mutateTagsInfo, mutateIsEnabledUnsavedWarning, t,
+  ]);
 
   /**
    * onChange event of HackmdEditor handler

+ 33 - 14
apps/app/src/components/PasswordResetRequestForm.tsx

@@ -5,10 +5,11 @@ import Link from 'next/link';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-
+import { useIsMailerSetup } from '~/stores/context';
 
 const PasswordResetRequestForm: FC = () => {
   const { t } = useTranslation();
+  const { data: isMailerSetup } = useIsMailerSetup();
   const [email, setEmail] = useState('');
 
   const changeEmail = useCallback((inputValue) => {
@@ -33,20 +34,38 @@ const PasswordResetRequestForm: FC = () => {
 
   return (
     <form onSubmit={sendPasswordResetRequestMail}>
-      <h3>{ t('forgot_password.password_reset_request_desc') }</h3>
-      <div className="form-group">
-        <div className="input-group">
-          <input name="email" placeholder="E-mail Address" className="form-control" type="email" onChange={e => changeEmail(e.target.value)} />
+      {!isMailerSetup ? (
+        <div className="alert alert-danger">
+          {t('forgot_password.please_enable_mailer_alert')}
         </div>
-      </div>
-      <div className="form-group">
-        <button
-          className="btn btn-lg btn-primary btn-block"
-          type="submit"
-        >
-          {t('forgot_password.send')}
-        </button>
-      </div>
+      ) : (
+        <>
+          <h1><i className="icon-lock large"></i></h1>
+          <h1 className="text-center">{ t('forgot_password.forgot_password') }</h1>
+          <h3>{t('forgot_password.password_reset_request_desc')}</h3>
+          <div className="form-group">
+            <div className="input-group">
+              <input
+                name="email"
+                placeholder="E-mail Address"
+                className="form-control"
+                type="email"
+                disabled={!isMailerSetup}
+                onChange={e => changeEmail(e.target.value)}
+              />
+            </div>
+          </div>
+          <div className="form-group">
+            <button
+              className="btn btn-lg btn-primary btn-block"
+              type="submit"
+              disabled={!isMailerSetup}
+            >
+              {t('forgot_password.send')}
+            </button>
+          </div>
+        </>
+      )}
       <Link href='/login' prefetch={false}>
         <i className="icon-login mr-1" />{t('forgot_password.return_to_login')}
       </Link>

+ 14 - 2
apps/app/src/components/SavePageControls.tsx

@@ -13,6 +13,7 @@ import { IPageGrantData } from '~/interfaces/page';
 import {
   useIsEditable, useIsAclEnabled,
 } from '~/stores/context';
+import { useWaitingSaveProcessing } from '~/stores/editor';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
 import { useSelectedGrant } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
@@ -42,7 +43,9 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: pageId } = useCurrentPageId();
+  const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
 
+  const isWaitingSaveProcessing = _isWaitingSaveProcessing === true; // ignore undefined
 
   const updateGrantHandler = useCallback((grantData: IPageGrantData): void => {
     mutateGrant(grantData);
@@ -91,10 +94,19 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
       }
 
       <UncontrolledButtonDropdown direction="up">
-        <Button data-testid="save-page-btn" id="caret" color="primary" className="btn-submit" onClick={save}>
+        <Button
+          id="caret" data-testid="save-page-btn"
+          color="primary"
+          className="btn-submit"
+          onClick={save}
+          disabled={isWaitingSaveProcessing}
+        >
+          { isWaitingSaveProcessing && (
+            <i className="fa fa-spinner fa-pulse mr-1"></i>
+          ) }
           {labelSubmitButton}
         </Button>
-        <DropdownToggle caret color="primary" />
+        <DropdownToggle caret color="primary" disabled={isWaitingSaveProcessing} />
         <DropdownMenu right>
           <DropdownItem onClick={saveAndOverwriteScopesOfDescendants}>
             {labelOverwriteScopes}

+ 14 - 8
apps/app/src/components/Script/DrawioViewerScript.tsx

@@ -1,9 +1,9 @@
 import { useCallback } from 'react';
 
 import type { IGraphViewerGlobal } from '@growi/remark-drawio';
-import Script from 'next/script';
+import Head from 'next/head';
 
-import { useDrawioUri } from '~/stores/context';
+import { useRendererConfig } from '~/stores/context';
 
 declare global {
   // eslint-disable-next-line vars-on-top, no-var
@@ -11,7 +11,7 @@ declare global {
 }
 
 export const DrawioViewerScript = (): JSX.Element => {
-  const { data: drawioUri } = useDrawioUri();
+  const { data: rendererConfig } = useRendererConfig();
 
   const loadedHandler = useCallback(() => {
     // disable useResizeSensor and checkVisibleState
@@ -32,11 +32,17 @@ export const DrawioViewerScript = (): JSX.Element => {
     GraphViewer.processElements();
   }, []);
 
+  if (rendererConfig == null) {
+    return <></>;
+  }
+
   return (
-    <Script
-      type="text/javascript"
-      src={(new URL('/js/viewer.min.js', drawioUri)).toString()}
-      onLoad={loadedHandler}
-    />
+    <Head>
+      <script
+        type="text/javascript" async
+        src={(new URL('/js/viewer.min.js', rendererConfig.drawioUri)).toString()}
+        onLoad={loadedHandler}
+      />
+    </Head>
   );
 };

+ 0 - 1
apps/app/src/components/TrashPageList.tsx

@@ -87,7 +87,6 @@ export const TrashPageList = (): JSX.Element => {
         Icon: PageListIcon,
         Content: DescendantsPageListForTrash,
         i18n: t('page_list'),
-        index: 0,
       },
     };
   }, [t]);

+ 8 - 3
apps/app/src/components/UncontrolledCodeMirror.tsx

@@ -2,8 +2,13 @@ import React, {
   useCallback, useRef, MutableRefObject,
 } from 'react';
 
-import { commands, Editor } from 'codemirror';
-import { ICodeMirror, UnControlled as CodeMirror } from 'react-codemirror2';
+import codemirror, { commands, Editor } from 'codemirror';
+import { type ICodeMirror, UnControlled as CodeMirror } from 'react-codemirror2';
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var CodeMirror: ICodeMirror;
+}
 
 // set save handler
 // CommandActions in @types/codemirror does not include 'save' but actualy exists
@@ -14,7 +19,7 @@ import { ICodeMirror, UnControlled as CodeMirror } from 'react-codemirror2';
   }
 };
 
-window.CodeMirror = require('codemirror');
+window.CodeMirror = codemirror;
 require('codemirror/addon/display/placeholder');
 require('~/client/util/codemirror/gfm-growi.mode');
 

+ 6 - 0
apps/app/src/interfaces/activity.ts

@@ -4,10 +4,12 @@ import { IUser } from './user';
 
 // Model
 const MODEL_PAGE = 'Page';
+const MODEL_USER = 'User';
 const MODEL_COMMENT = 'Comment';
 
 // Action
 const ACTION_UNSETTLED = 'UNSETTLED';
+const ACTION_USER_REGISTRATION_APPROVAL_REQUEST = 'USER_REGISTRATION_APPROVAL_REQUEST';
 const ACTION_USER_REGISTRATION_SUCCESS = 'USER_REGISTRATION_SUCCESS';
 const ACTION_USER_LOGIN_WITH_LOCAL = 'USER_LOGIN_WITH_LOCAL';
 const ACTION_USER_LOGIN_WITH_LDAP = 'USER_LOGIN_WITH_LDAP';
@@ -163,6 +165,7 @@ const ACTION_ADMIN_SEARCH_INDICES_REBUILD = 'ADMIN_SEARCH_INDICES_REBUILD';
 
 export const SupportedTargetModel = {
   MODEL_PAGE,
+  MODEL_USER,
 } as const;
 
 export const SupportedEventModel = {
@@ -183,6 +186,7 @@ export const SupportedActionCategory = {
 
 export const SupportedAction = {
   ACTION_UNSETTLED,
+  ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
   ACTION_USER_REGISTRATION_SUCCESS,
   ACTION_USER_LOGIN_WITH_LOCAL,
   ACTION_USER_LOGIN_WITH_LDAP,
@@ -351,6 +355,7 @@ export const EssentialActionGroup = {
   ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY,
   ACTION_PAGE_RECURSIVELY_REVERT,
   ACTION_COMMENT_CREATE,
+  ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
 } as const;
 
 export const ActionGroupSize = {
@@ -377,6 +382,7 @@ export const SmallActionGroup = {
 // SmallActionGroup + Action by all General Users - PAGE_VIEW
 export const MediumActionGroup = {
   ...SmallActionGroup,
+  ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
   ACTION_USER_REGISTRATION_SUCCESS,
   ACTION_USER_FOGOT_PASSWORD,
   ACTION_USER_RESET_PASSWORD,

+ 0 - 12
apps/app/src/interfaces/editor-settings.ts

@@ -1,14 +1,3 @@
-export interface ILintRule {
-  name: string;
-  options?: unknown;
-  isEnabled?: boolean;
-}
-
-export interface ITextlintSettings {
-  neverAskBeforeDownloadLargeFiles?: boolean;
-  textlintRules: ILintRule[];
-}
-
 export const DEFAULT_THEME = 'elegant';
 
 const KeyMapMode = {
@@ -25,7 +14,6 @@ export interface IEditorSettings {
   keymapMode: undefined | KeyMapMode,
   styleActiveLine: boolean,
   autoFormatMarkdownTable: boolean,
-  textlintSettings: undefined | ITextlintSettings;
 }
 
 export type EditorConfig = {

+ 8 - 4
apps/app/src/interfaces/in-app-notification.ts

@@ -1,5 +1,7 @@
 import type { IPageSnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
+import type { IUserSnapshot } from '~/models/serializers/in-app-notification-snapshot/user';
 
+import { SupportedTargetModelType, SupportedActionType } from './activity';
 import { IPage } from './page';
 import { IUser } from './user';
 
@@ -9,16 +11,18 @@ export enum InAppNotificationStatuses {
   STATUS_OPENED = 'OPENED',
 }
 
+// TODO: do not use any type
+// https://redmine.weseek.co.jp/issues/120632
 export interface IInAppNotification {
   user: IUser
-  targetModel: 'Page'
-  target: IPage
-  action: 'COMMENT' | 'LIKE'
+  targetModel: SupportedTargetModelType
+  target: any
+  action: SupportedActionType
   status: InAppNotificationStatuses
   actionUsers: IUser[]
   createdAt: Date
   snapshot: string
-  parsedSnapshot?: IPageSnapshot
+  parsedSnapshot?: any
 }
 
 /*

+ 2 - 2
apps/app/src/interfaces/services/renderer.ts

@@ -8,6 +8,6 @@ export type RendererConfig = {
   isIndentSizeForced: boolean,
   highlightJsStyleBorder: boolean,
 
-  plantumlUri: string | null,
-  blockdiagUri: string | null,
+  drawioUri: string,
+  plantumlUri: string,
 } & XssOptionConfig;

+ 1 - 2
apps/app/src/interfaces/ui.ts

@@ -11,10 +11,9 @@ export type SidebarContentsType = typeof SidebarContentsType[keyof typeof Sideba
 
 
 export type ICustomTabContent = {
-  Content: () => JSX.Element,
+  Content?: () => JSX.Element,
   i18n?: string,
   Icon?: () => JSX.Element,
-  index?: number,
   isLinkEnabled?: boolean | ((content: ICustomTabContent) => boolean),
 };
 

+ 15 - 0
apps/app/src/models/serializers/in-app-notification-snapshot/user.ts

@@ -0,0 +1,15 @@
+import type { IUser } from '~/interfaces/user';
+
+export interface IUserSnapshot {
+  username: string
+}
+
+export const stringifySnapshot = (user: IUser): string => {
+  return JSON.stringify({
+    username: user.username,
+  });
+};
+
+export const parseSnapshot = (snapshot: string): IUserSnapshot => {
+  return JSON.parse(snapshot);
+};

+ 3 - 4
apps/app/src/pages/[[...path]].page.tsx

@@ -34,7 +34,7 @@ import {
   useIsForbidden, useIsSharedUser,
   useIsEnabledStaleNotification, useIsIdenticalPath,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
-  useDrawioUri, useHackmdUri, useDefaultIndentSize, useIsIndentSizeForced,
+  useHackmdUri, useDefaultIndentSize, useIsIndentSizeForced,
   useIsAclEnabled, useIsSearchPage, useIsEnabledAttachTitleHeader,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig,
@@ -212,7 +212,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   // useIsMailerSetup(props.isMailerSetup);
   useIsAclEnabled(props.isAclEnabled);
   // useHasSlackConfig(props.hasSlackConfig);
-  useDrawioUri(props.drawioUri);
   useHackmdUri(props.hackmdUri);
   // useNoCdn(props.noCdn);
   useDefaultIndentSize(props.adminPreferredIndentSize);
@@ -579,8 +578,8 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
     isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
 
-    plantumlUri: process.env.PLANTUML_URI ?? null,
-    blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
+    drawioUri: configManager.getConfig('crowi', 'app:drawioUri'),
+    plantumlUri: configManager.getConfig('crowi', 'app:plantumlUri'),
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),

+ 3 - 9
apps/app/src/pages/_private-legacy-pages.page.tsx

@@ -11,7 +11,7 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IUser, IUserHasId } from '~/interfaces/user';
 import {
-  useCsrfToken, useCurrentUser, useDrawioUri, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
+  useCsrfToken, useCurrentUser, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig,
 } from '~/stores/context';
 
@@ -29,8 +29,6 @@ type Props = CommonProps & {
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
 
-  drawioUri: string | null,
-
   // Render config
   rendererConfig: RendererConfig,
 
@@ -52,8 +50,6 @@ const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
 
-  useDrawioUri(props.drawioUri);
-
   // init sidebar config with UserUISettings and sidebarConfig
   useInitSidebarConfig(props.sidebarConfig, props.userUISettings);
 
@@ -88,8 +84,6 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
 
-  props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
-
   props.sidebarConfig = {
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
@@ -101,8 +95,8 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
     adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
     isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
 
-    plantumlUri: process.env.PLANTUML_URI ?? null,
-    blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
+    drawioUri: configManager.getConfig('crowi', 'app:drawioUri'),
+    plantumlUri: configManager.getConfig('crowi', 'app:plantumlUri'),
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),

+ 3 - 9
apps/app/src/pages/_search.page.tsx

@@ -12,7 +12,7 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IUser, IUserHasId } from '~/interfaces/user';
 import {
-  useCsrfToken, useCurrentUser, useDrawioUri, useIsContainerFluid, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
+  useCsrfToken, useCurrentUser, useIsContainerFluid, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useShowPageLimitationL,
 } from '~/stores/context';
 
@@ -32,8 +32,6 @@ type Props = CommonProps & {
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
 
-  drawioUri: string | null,
-
   // Render config
   rendererConfig: RendererConfig,
 
@@ -58,8 +56,6 @@ const SearchResultPage: NextPageWithLayout<Props> = (props: Props) => {
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
 
-  useDrawioUri(props.drawioUri);
-
   // init sidebar config with UserUISettings and sidebarConfig
   useInitSidebarConfig(props.sidebarConfig, props.userUISettings);
 
@@ -125,8 +121,6 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
   props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
 
-  props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
-
   props.sidebarConfig = {
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
@@ -138,8 +132,8 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
     isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
 
-    plantumlUri: process.env.PLANTUML_URI ?? null,
-    blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
+    drawioUri: configManager.getConfig('crowi', 'app:drawioUri'),
+    plantumlUri: configManager.getConfig('crowi', 'app:plantumlUri'),
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),

+ 20 - 7
apps/app/src/pages/forgot-password.page.tsx

@@ -1,18 +1,24 @@
 import React from 'react';
 
 import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
-import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 
+import { CrowiRequest } from '~/interfaces/crowi-request';
+import { useIsMailerSetup } from '~/stores/context';
+
 import {
   CommonProps, getNextI18NextConfig, getServerSideCommonProps,
 } from './utils/commons';
 
 const PasswordResetRequestForm = dynamic(() => import('~/components/PasswordResetRequestForm'), { ssr: false });
 
-const ForgotPasswordPage: NextPage = () => {
-  const { t } = useTranslation();
+type Props = CommonProps & {
+  isMailerSetup: boolean,
+};
+
+const ForgotPasswordPage: NextPage<Props> = (props: Props) => {
+  useIsMailerSetup(props.isMailerSetup);
 
   return (
     <div id="main" className="main">
@@ -21,8 +27,6 @@ const ForgotPasswordPage: NextPage = () => {
           <div className="row justify-content-md-center">
             <div className="col-md-6 mt-5">
               <div className="text-center">
-                <h1><i className="icon-lock large"></i></h1>
-                <h1 className="text-center">{ t('forgot_password.forgot_password') }</h1>
                 <PasswordResetRequestForm />
               </div>
             </div>
@@ -34,11 +38,19 @@ const ForgotPasswordPage: NextPage = () => {
 };
 
 // eslint-disable-next-line max-len
-async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: CommonProps, namespacesRequired?: string[] | undefined): Promise<void> {
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
   const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
   props._nextI18Next = nextI18NextConfig._nextI18Next;
 }
 
+const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const { mailService } = crowi;
+
+  props.isMailerSetup = mailService.isMailerSetup;
+};
+
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
   const result = await getServerSideCommonProps(context);
 
@@ -48,8 +60,9 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
     throw new Error('invalid getSSP result');
   }
 
-  const props: CommonProps = result.props as CommonProps;
+  const props: Props = result.props as Props;
 
+  injectServerConfigurations(context, props);
   await injectNextI18NextConfigurations(context, props, ['translation', 'commons']);
 
   return {

+ 0 - 2
apps/app/src/pages/installer.page.tsx

@@ -46,13 +46,11 @@ const InstallerPage: NextPage<Props> = (props: Props) => {
         Icon: () => <i className="icon-fw icon-user"></i>,
         Content: InstallerForm,
         i18n: t('installer.tab'),
-        index: 0,
       },
       external_accounts: {
         Icon: () => <i className="icon-fw icon-share-alt"></i>,
         Content: DataTransferForm,
         i18n: tCommons('g2g_data_transfer.tab'),
-        index: 1,
       },
     };
   }, [t, tCommons]);

+ 2 - 2
apps/app/src/pages/me/[[...path]].page.tsx

@@ -158,8 +158,8 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
     adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
     isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
 
-    plantumlUri: process.env.PLANTUML_URI ?? null,
-    blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
+    drawioUri: configManager.getConfig('crowi', 'app:drawioUri'),
+    plantumlUri: configManager.getConfig('crowi', 'app:plantumlUri'),
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),

+ 3 - 4
apps/app/src/pages/share/[[...path]].page.tsx

@@ -20,7 +20,7 @@ import type { IShareLinkHasId } from '~/interfaces/share-link';
 import type { PageDocument } from '~/server/models/page';
 import {
   useCurrentUser, useRendererConfig, useIsSearchPage, useCurrentPathname,
-  useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useDrawioUri, useIsContainerFluid,
+  useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useIsContainerFluid,
 } from '~/stores/context';
 import { useCurrentPageId, useIsNotFound } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
@@ -90,7 +90,6 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
-  useDrawioUri(props.drawioUri);
   useIsContainerFluid(props.isContainerFluid);
 
 
@@ -156,8 +155,8 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
     isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
 
-    plantumlUri: process.env.PLANTUML_URI ?? null,
-    blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
+    drawioUri: configManager.getConfig('crowi', 'app:drawioUri'),
+    plantumlUri: configManager.getConfig('crowi', 'app:plantumlUri'),
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),

+ 1 - 19
apps/app/src/pages/tags.page.tsx

@@ -16,7 +16,7 @@ import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
   useCurrentUser, useIsSearchPage,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
-  useIsSearchScopeChildrenAsDefault, useRendererConfig,
+  useIsSearchScopeChildrenAsDefault,
 } from '../stores/context';
 
 import { NextPageWithLayout } from './_app.page';
@@ -64,8 +64,6 @@ const TagPage: NextPageWithLayout<CommonProps> = (props: Props) => {
   // init sidebar config with UserUISettings and sidebarConfig
   useInitSidebarConfig(props.sidebarConfig, props.userUISettings);
 
-  useRendererConfig(props.rendererConfig);
-
   const title = generateCustomTitle(props, t('Tags'));
 
   return (
@@ -139,22 +137,6 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
   };
 
-  props.rendererConfig = {
-    isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
-    isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
-    adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
-    isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
-
-    plantumlUri: process.env.PLANTUML_URI ?? null,
-    blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
-
-    // XSS Options
-    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
-    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
-    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
-  };
 }
 
 /**

+ 1 - 19
apps/app/src/pages/trash.page.tsx

@@ -16,7 +16,7 @@ import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
   useCurrentUser, useCurrentPathname,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
-  useIsSearchScopeChildrenAsDefault, useIsSearchPage, useShowPageLimitationXL, useIsGuestUser, useRendererConfig,
+  useIsSearchScopeChildrenAsDefault, useIsSearchPage, useShowPageLimitationXL, useIsGuestUser,
 } from '../stores/context';
 
 import type { NextPageWithLayout } from './_app.page';
@@ -55,8 +55,6 @@ const TrashPage: NextPageWithLayout<CommonProps> = (props: Props) => {
 
   useShowPageLimitationXL(props.showPageLimitationXL);
 
-  useRendererConfig(props.rendererConfig);
-
   const { data: isDrawerMode } = useDrawerMode();
   const { data: isGuestUser } = useIsGuestUser();
 
@@ -128,22 +126,6 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
   };
 
-  props.rendererConfig = {
-    isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
-    isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
-    adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
-    isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
-
-    plantumlUri: process.env.PLANTUML_URI ?? null,
-    blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
-
-    // XSS Options
-    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
-    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
-    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
-  };
 }
 
 /**

+ 1 - 11
apps/app/src/server/models/editor-settings.ts

@@ -2,7 +2,7 @@ import {
   Schema, Model, Document,
 } from 'mongoose';
 
-import { IEditorSettings, ITextlintSettings } from '~/interfaces/editor-settings';
+import { IEditorSettings } from '~/interfaces/editor-settings';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 
@@ -12,22 +12,12 @@ export interface EditorSettingsDocument extends IEditorSettings, Document {
 }
 export type EditorSettingsModel = Model<EditorSettingsDocument>
 
-const textlintSettingsSchema = new Schema<ITextlintSettings>({
-  neverAskBeforeDownloadLargeFiles: { type: Boolean, default: false },
-  textlintRules: {
-    type: [
-      { name: { type: String }, options: { type: Object }, isEnabled: { type: Boolean } },
-    ],
-  },
-});
-
 const editorSettingsSchema = new Schema<EditorSettingsDocument, EditorSettingsModel>({
   userId: { type: Schema.Types.ObjectId },
   theme: { type: String },
   keymapMode: { type: String },
   styleActiveLine: { type: Boolean, default: false },
   autoFormatMarkdownTable: { type: Boolean, default: true },
-  textlintSettings: textlintSettingsSchema,
 });
 
 

+ 0 - 14
apps/app/src/server/routes/apiv3/personal-setting.js

@@ -118,10 +118,6 @@ module.exports = (crowi) => {
       body('keymapMode').optional().isString(),
       body('styleActiveLine').optional().isBoolean(),
       body('autoFormatMarkdownTable').optional().isBoolean(),
-      body('textlintSettings.neverAskBeforeDownloadLargeFiles').optional().isBoolean(),
-      body('textlintSettings.textlintRules.*.name').optional().isString(),
-      body('textlintSettings.textlintRules.*.options').optional(),
-      body('textlintSettings.textlintRules.*.isEnabled').optional().isBoolean(),
     ],
     inAppNotificationSettings: [
       body('defaultSubscribeRules.*.name').isString(),
@@ -542,22 +538,12 @@ module.exports = (crowi) => {
 
     const {
       theme, keymapMode, styleActiveLine, autoFormatMarkdownTable,
-      textlintSettings,
     } = body;
 
     const document = {
       theme, keymapMode, styleActiveLine, autoFormatMarkdownTable,
     };
 
-    if (textlintSettings != null) {
-      if (textlintSettings.neverAskBeforeDownloadLargeFiles != null) {
-        Object.assign(document, { 'textlintSettings.neverAskBeforeDownloadLargeFiles': textlintSettings.neverAskBeforeDownloadLargeFiles });
-      }
-      if (textlintSettings.textlintRules != null) {
-        Object.assign(document, { 'textlintSettings.textlintRules': textlintSettings.textlintRules });
-      }
-    }
-
     // Insert if document does not exist, and return new values
     // See: https://mongoosejs.com/docs/api.html#model_Model.findOneAndUpdate
     const options = { upsert: true, new: true };

+ 2 - 2
apps/app/src/server/routes/attachment.js

@@ -245,7 +245,7 @@ module.exports = function(crowi, app) {
       'Last-Modified': attachment.createdAt.toUTCString(),
     });
 
-    if (!attachment.fileSize) {
+    if (attachment.fileSize) {
       res.set({
         'Content-Length': attachment.fileSize,
       });
@@ -261,7 +261,7 @@ module.exports = function(crowi, app) {
     else {
       res.set({
         'Content-Type': attachment.fileFormat,
-        'Content-Security-Policy': "script-src 'unsafe-hashes'; object-src 'none'; require-trusted-types-for 'script'; default-src 'none';",
+        'Content-Security-Policy': "script-src 'unsafe-hashes'; object-src 'none'; require-trusted-types-for 'script'; media-src 'self'; default-src 'none';",
       });
     }
   }

+ 19 - 8
apps/app/src/server/routes/login.js

@@ -1,4 +1,4 @@
-import { SupportedAction } from '~/interfaces/activity';
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
 // disable all of linting
@@ -10,7 +10,7 @@ module.exports = function(crowi, app) {
   const path = require('path');
   const User = crowi.model('User');
   const {
-    configManager, appService, aclService, mailService,
+    configManager, appService, aclService, mailService, activityService,
   } = crowi;
   const activityEvent = crowi.event('activity');
 
@@ -42,12 +42,28 @@ module.exports = function(crowi, app) {
       .forEach(result => logger.error(result.reason));
   }
 
+  async function sendNotificationToAllAdmins(user) {
+    const adminUsers = await User.findAdmins();
+    const activity = await activityService.createActivity({
+      action: SupportedAction.ACTION_USER_REGISTRATION_APPROVAL_REQUEST,
+      target: user,
+      targetModel: SupportedTargetModel.MODEL_USER,
+    });
+    await activityEvent.emit('updated', activity, user, adminUsers);
+    return;
+  }
+
   const registerSuccessHandler = async function(req, res, userData, registrationMode) {
     const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
     activityEvent.emit('update', res.locals.activity._id, parameters);
 
+    const isMailerSetup = mailService.isMailerSetup ?? false;
+
     if (registrationMode === aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
-      await sendEmailToAllAdmins(userData);
+      sendNotificationToAllAdmins(userData);
+      if (isMailerSetup) {
+        await sendEmailToAllAdmins(userData);
+      }
       return res.apiv3({});
     }
 
@@ -142,11 +158,6 @@ module.exports = function(crowi, app) {
       }
 
       const registrationMode = configManager.getConfig('crowi', 'security:registrationMode');
-      const isMailerSetup = mailService.isMailerSetup ?? false;
-
-      if (!isMailerSetup && registrationMode === aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
-        return res.apiv3Err(['message.email_settings_is_not_setup'], 403);
-      }
 
       User.createUserByEmailAndPassword(name, username, email, password, undefined, async(err, userData) => {
         if (err) {

+ 7 - 7
apps/app/src/server/service/config-loader.ts

@@ -73,12 +73,6 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.STRING,
     default: null,
   },
-  // PLANTUML_URI: {
-  //   ns:      ,
-  //   key:     ,
-  //   type:    ,
-  //   default:
-  // },
   // BLOCKDIAG_URI: {
   //   ns:      ,
   //   key:     ,
@@ -121,11 +115,17 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
   //   type:    ,
   //   default:
   // },
+  PLANTUML_URI: {
+    ns:      'crowi',
+    key:     'app:plantumlUri',
+    type:    ValueType.STRING,
+    default: 'https://www.plantuml.com/plantuml',
+  },
   DRAWIO_URI: {
     ns:      'crowi',
     key:     'app:drawioUri',
     type:    ValueType.STRING,
-    default: null,
+    default: 'https://embed.diagrams.net/',
   },
   NCHAN_URI: {
     ns:      'crowi',

+ 3 - 1
apps/app/src/server/service/file-uploader/gcs.js

@@ -201,7 +201,9 @@ module.exports = function(crowi) {
 
     const gcs = getGcsInstance();
     const bucket = gcs.bucket(getGcsBucket());
-    const [files] = await bucket.getFiles();
+    const [files] = await bucket.getFiles({
+      prefix: configManager.getConfig('crowi', 'gcs:uploadNamespace'),
+    });
 
     return files.map(({ name, metadata: { size } }) => {
       return { name, size };

+ 19 - 8
apps/app/src/server/service/in-app-notification.ts

@@ -6,7 +6,8 @@ import { Types } from 'mongoose';
 
 import { AllEssentialActions, SupportedAction } from '~/interfaces/activity';
 import { InAppNotificationStatuses, PaginateResult } from '~/interfaces/in-app-notification';
-import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
+import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
+import * as userSerializers from '~/models/serializers/in-app-notification-snapshot/user';
 import { ActivityDocument } from '~/server/models/activity';
 import {
   InAppNotification,
@@ -17,7 +18,6 @@ import Subscription from '~/server/models/subscription';
 import loggerFactory from '~/utils/logger';
 
 import Crowi from '../crowi';
-import { PageDocument } from '../models/page';
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 
 
@@ -51,11 +51,13 @@ export default class InAppNotificationService {
   }
 
   initActivityEventListeners(): void {
-    this.activityEvent.on('updated', async(activity: ActivityDocument, target: IPage, descendantsSubscribedUsers?: Ref<IUser>[]) => {
+    // TODO: do not use any type
+    // https://redmine.weseek.co.jp/issues/120632
+    this.activityEvent.on('updated', async(activity: ActivityDocument, target: any, users?: Ref<IUser>[]) => {
       try {
         const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
         if (shouldNotification) {
-          await this.createInAppNotification(activity, target, descendantsSubscribedUsers);
+          await this.createInAppNotification(activity, target, users);
         }
       }
       catch (err) {
@@ -199,9 +201,18 @@ export default class InAppNotificationService {
     return;
   };
 
-  createInAppNotification = async function(activity: ActivityDocument, target: IPage, descendantsSubscribedUsers?: Ref<IUser>[]): Promise<void> {
+  // TODO: do not use any type
+  // https://redmine.weseek.co.jp/issues/120632
+  createInAppNotification = async function(activity: ActivityDocument, target, users?: Ref<IUser>[]): Promise<void> {
+    if (activity.action === SupportedAction.ACTION_USER_REGISTRATION_APPROVAL_REQUEST) {
+      const snapshot = userSerializers.stringifySnapshot(target);
+      await this.upsertByActivity(users, activity, snapshot);
+      await this.emitSocketIo(users);
+      return;
+    }
+
     const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
-    const snapshot = stringifySnapshot(target);
+    const snapshot = pageSerializers.stringifySnapshot(target);
     if (shouldNotification) {
       let mentionedUsers: IUser[] = [];
       if (activity.action === SupportedAction.ACTION_COMMENT_CREATE) {
@@ -209,9 +220,9 @@ export default class InAppNotificationService {
       }
       const notificationTargetUsers = await activity?.getNotificationTargetUsers();
       let notificationDescendantsUsers = [];
-      if (descendantsSubscribedUsers != null) {
+      if (users != null) {
         const User = this.crowi.model('User');
-        const descendantsUsers = descendantsSubscribedUsers.filter(item => (item.toString() !== activity.user._id.toString()));
+        const descendantsUsers = users.filter(item => (item.toString() !== activity.user._id.toString()));
         notificationDescendantsUsers = await User.find({
           _id: { $in: descendantsUsers },
           status: User.STATUS_ACTIVE,

+ 8 - 6
apps/app/src/server/service/page.ts

@@ -1468,6 +1468,9 @@ class PageService {
 
           throw err;
         }
+        finally {
+          this.pageEvent.emit('syncDescendantsUpdate', deletedPage, user);
+        }
       })();
     }
     else {
@@ -1498,8 +1501,7 @@ class PageService {
         throw err;
       }
     }
-    this.pageEvent.emit('delete', page, user);
-    this.pageEvent.emit('create', deletedPage, user);
+    this.pageEvent.emit('delete', page, deletedPage, user);
 
     return deletedPage;
   }
@@ -1555,8 +1557,7 @@ class PageService {
       }
     }
 
-    this.pageEvent.emit('delete', page, user);
-    this.pageEvent.emit('create', deletedPage, user);
+    this.pageEvent.emit('delete', page, deletedPage, user);
 
     return deletedPage;
   }
@@ -2060,7 +2061,7 @@ class PageService {
 
     await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
 
-    this.pageEvent.emit('revert', page, user);
+    this.pageEvent.emit('revert', page, updatedPage, user);
 
     if (!isRecursively) {
       await this.updateDescendantCountOfAncestors(parent._id, 1, true);
@@ -2089,6 +2090,7 @@ class PageService {
       (async() => {
         try {
           await this.revertRecursivelyMainOperation(page, user, options, pageOp._id, activity);
+          this.pageEvent.emit('syncDescendantsUpdate', updatedPage, user);
         }
         catch (err) {
           logger.error('Error occurred while running revertRecursivelyMainOperation.', err);
@@ -2177,7 +2179,7 @@ class PageService {
     }, { new: true });
     await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
 
-    this.pageEvent.emit('revert', page, user);
+    this.pageEvent.emit('revert', page, updatedPage, user);
 
     return updatedPage;
   }

+ 8 - 2
apps/app/src/server/service/search.ts

@@ -140,8 +140,14 @@ class SearchService implements SearchQueryParser, SearchResolver {
     const pageEvent = this.crowi.event('page');
     pageEvent.on('create', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
     pageEvent.on('update', this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator));
-    pageEvent.on('delete', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
-    pageEvent.on('revert', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
+    pageEvent.on('delete', (targetPage, deletedPage, user) => {
+      this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator)(targetPage, user);
+      this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator)(deletedPage, user);
+    });
+    pageEvent.on('revert', (targetPage, revertedPage, user) => {
+      this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator)(targetPage, user);
+      this.fullTextSearchDelegator.syncPageUpdated.bind(this.fullTextSearchDelegator)(revertedPage, user);
+    });
     pageEvent.on('deleteCompletely', this.fullTextSearchDelegator.syncPageDeleted.bind(this.fullTextSearchDelegator));
     pageEvent.on('syncDescendantsDelete', this.fullTextSearchDelegator.syncDescendantsPagesDeleted.bind(this.fullTextSearchDelegator));
     pageEvent.on('updateMany', this.fullTextSearchDelegator.syncPagesUpdated.bind(this.fullTextSearchDelegator));

+ 0 - 18
apps/app/src/services/renderer/markdown-it/blockdiag.ts

@@ -1,18 +0,0 @@
-import { RendererConfig } from '~/interfaces/services/renderer';
-
-export default class BlockdiagConfigurer {
-
-  generateSourceUrl: string;
-
-  constructor(config: RendererConfig) {
-    this.generateSourceUrl = config.blockdiagUri || 'https://blockdiag-api.com/';
-  }
-
-  configure(md) {
-    // md.use(require('markdown-it-blockdiag'), {
-    //   generateSourceUrl: this.generateSourceUrl,
-    //   marker: ':::',
-    // });
-  }
-
-}

+ 2 - 2
apps/app/src/services/renderer/remark-plugins/plantuml.ts

@@ -3,11 +3,11 @@ import { Plugin } from 'unified';
 import urljoin from 'url-join';
 
 type PlantUMLPluginParams = {
-  plantumlUri?: string,
+  plantumlUri: string,
 }
 
 export const remarkPlugin: Plugin<[PlantUMLPluginParams]> = (options) => {
-  const plantumlUri = options.plantumlUri ?? 'https://www.plantuml.com/plantuml';
+  const plantumlUri = options.plantumlUri;
 
   const baseUrl = urljoin(plantumlUri, '/svg');
 

+ 2 - 2
apps/app/src/services/renderer/renderer.tsx

@@ -27,7 +27,6 @@ import { pukiwikiLikeLinker } from './remark-plugins/pukiwiki-like-linker';
 import * as xsvToTable from './remark-plugins/xsv-to-table';
 
 // import EasyGrid from './PreProcessor/EasyGrid';
-// import BlockdiagConfigurer from './markdown-it/blockdiag';
 
 
 const logger = loggerFactory('growi:services:renderer');
@@ -36,9 +35,10 @@ const logger = loggerFactory('growi:services:renderer');
 type SanitizePlugin = PluginTuple<[SanitizeOption]>;
 
 const baseSanitizeSchema = {
-  tagNames: ['iframe', 'section'],
+  tagNames: ['iframe', 'section', 'video'],
   attributes: {
     iframe: ['allow', 'referrerpolicy', 'sandbox', 'src', 'srcdoc'],
+    video: ['controls', 'src', 'muted', 'preload', 'width', 'height', 'autoplay'],
     // The special value 'data*' as a property name can be used to allow all data properties.
     // see: https://github.com/syntax-tree/hast-util-sanitize/
     '*': ['key', 'class', 'className', 'style', 'data*'],

+ 0 - 4
apps/app/src/stores/context.tsx

@@ -72,10 +72,6 @@ export const useRegistrationWhiteList = (initialData?: Nullable<string[]>): SWRR
   return useContextSWR<Nullable<string[]>, Error>('registrationWhiteList', initialData);
 };
 
-export const useDrawioUri = (initialData?: Nullable<string>): SWRResponse<string, Error> => {
-  return useContextSWR('drawioUri', initialData ?? undefined, { fallbackData: 'https://embed.diagrams.net/' });
-};
-
 export const useHackmdUri = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
   return useContextSWR<Nullable<string>, Error>('hackmdUri', initialData);
 };

+ 5 - 17
apps/app/src/stores/editor.tsx

@@ -17,6 +17,11 @@ import { useSWRxTagsInfo } from './page';
 import { useStaticSWR } from './use-static-swr';
 
 
+export const useWaitingSaveProcessing = (): SWRResponse<boolean, Error> => {
+  return useStaticSWR('waitingSaveProcessing', undefined, { fallbackData: false });
+};
+
+
 export const useEditingMarkdown = (initialData?: string): SWRResponse<string, Error> => {
   return useStaticSWR('editingMarkdown', initialData);
 };
@@ -24,7 +29,6 @@ export const useEditingMarkdown = (initialData?: string): SWRResponse<string, Er
 
 type EditorSettingsOperation = {
   update: (updateData: Partial<IEditorSettings>) => Promise<void>,
-  turnOffAskingBeforeDownloadLargeFiles: () => void,
 }
 
 // TODO: Enable localStorageMiddleware
@@ -56,25 +60,9 @@ export const useEditorSettings = (): SWRResponseWithUtils<EditorSettingsOperatio
       // invoke API
       await apiv3Put('/personal-setting/editor-settings', updateData);
     },
-    turnOffAskingBeforeDownloadLargeFiles: async() => {
-      const { data, mutate } = swrResult;
-
-      if (data == null) {
-        return;
-      }
-
-      // invoke API
-      await apiv3Put('/personal-setting/editor-settings', { textlintSettings: { neverAskBeforeDownloadLargeFiles: true } });
-      // revalidate
-      mutate();
-    },
   });
 };
 
-export const useIsTextlintEnabled = (): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isTextlintEnabled', undefined, { fallbackData: false });
-};
-
 export const useCurrentIndentSize = (): SWRResponse<number, Error> => {
   const { data: defaultIndentSize } = useDefaultIndentSize();
   return useStaticSWR<number, Error>(

+ 13 - 2
apps/app/src/stores/in-app-notification.ts

@@ -1,7 +1,9 @@
 import useSWR, { SWRConfiguration, SWRResponse } from 'swr';
 
+import { SupportedTargetModel } from '~/interfaces/activity';
 import type { InAppNotificationStatuses, IInAppNotification, PaginateResult } from '~/interfaces/in-app-notification';
-import { parseSnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
+import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
+import * as userSerializers from '~/models/serializers/in-app-notification-snapshot/user';
 import loggerFactory from '~/utils/logger';
 
 import { apiv3Get } from '../client/util/apiv3-client';
@@ -23,7 +25,16 @@ export const useSWRxInAppNotifications = <Data, Error>(
       const inAppNotificationPaginateResult = response.data as inAppNotificationPaginateResult;
       inAppNotificationPaginateResult.docs.forEach((doc) => {
         try {
-          doc.parsedSnapshot = parseSnapshot(doc.snapshot as string);
+          switch (doc.targetModel) {
+            case SupportedTargetModel.MODEL_PAGE:
+              doc.parsedSnapshot = pageSerializers.parseSnapshot(doc.snapshot);
+              break;
+            case SupportedTargetModel.MODEL_USER:
+              doc.parsedSnapshot = userSerializers.parseSnapshot(doc.snapshot);
+              break;
+            default:
+              throw new Error(`No serializer found for targetModel: ${doc.targetModel}`);
+          }
         }
         catch (err) {
           logger.warn('Failed to parse snapshot', err);

+ 137 - 0
apps/app/test/cypress/integration/20-basic-features/20-basic-features--sticky-features.spec.ts

@@ -0,0 +1,137 @@
+context('Access to any page', () => {
+  const ssPrefix = 'subnav-and-fab-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+
+    cy.visit('/');
+
+    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true, true);
+  });
+
+  it('Subnavigation and fab displays changes on scroll down and up', () => {
+    cy.waitUntil(() => {
+      // do
+      // Scroll the window 250px down is enough to trigger sticky effect
+       cy.scrollTo(0, 250);
+      // wait until
+      return cy.getByTestid('grw-subnav-switcher').then($elem => !$elem.hasClass('grw-subnav-switcher-hidden'));
+    });
+    // wait until fab visible
+    cy.waitUntil(() => cy.getByTestid('grw-fab-page-create-button').then($elem => $elem.hasClass('visible')));
+
+    cy.waitUntilSkeletonDisappear();
+    cy.screenshot(`${ssPrefix}visible-on-scroll-down`);
+
+    cy.waitUntil(() => {
+      // do
+      // Scroll the window back to top
+      cy.scrollTo(0, 0);
+      // wait until
+      return cy.waitUntil(() => cy.getByTestid('grw-subnav-switcher').then($elem => $elem.hasClass('grw-subnav-switcher-hidden')));
+    });
+    // wait until fab invisible
+    cy.waitUntil(() => cy.getByTestid('grw-fab-page-create-button').then($elem => $elem.hasClass('invisible')));
+
+    cy.screenshot(`${ssPrefix}invisible-on-scroll-top`);
+  });
+
+  it('Subnavigation and fab are not displayed when move to other pages', () => {
+    cy.waitUntil(() => {
+      // do
+      // Scroll the window 250px down is enough to trigger sticky effect
+      cy.scrollTo(0, 250);
+      // wait until
+      return () => cy.getByTestid('grw-subnav-switcher').then($elem => !$elem.hasClass('grw-subnav-switcher-hidden'));
+    });
+    cy.waitUntil(() => cy.getByTestid('grw-fab-page-create-button').then($elem => $elem.hasClass('visible')));
+
+    // Move to /Sandbox page
+    cy.visit('/Sandbox');
+
+    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
+
+    cy.waitUntil(() => cy.getByTestid('grw-fab-page-create-button').then($elem => $elem.hasClass('invisible')));
+    cy.waitUntil(() => cy.getByTestid('grw-subnav-switcher').then($elem => $elem.hasClass('grw-subnav-switcher-hidden')));
+    cy.screenshot(`${ssPrefix}not-visible-on-move-to-other-pages`);
+  });
+
+  it('Able to open create page modal from fab', () => {
+    cy.waitUntil(() => {
+      // do
+      // Scroll the window back to top
+      cy.scrollTo(0, 250);
+      // wait until
+      return cy.getByTestid('grw-fab-page-create-button')
+      .should('have.class', 'visible')
+      .within(() => {
+        cy.get('.btn-create-page').click();
+        return true;
+      });
+    });
+
+    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
+      cy.screenshot(`${ssPrefix}new-page-modal-opened-from-fab`);
+      cy.get('button.close').click();
+    });
+  });
+
+  it('Able to scroll page to top from fab', () => {
+    // Initial scroll down
+    cy.waitUntil(() => {
+      // do
+      // Scroll the window 250px down is enough to trigger sticky effect
+      cy.scrollTo(0, 250);
+      // wait until
+      return cy.getByTestid('grw-fab-return-to-top')
+        .should('have.class', 'visible')
+        .then(() => {
+          cy.waitUntil(() => {
+            cy.get('.btn-scroll-to-top').click();
+            return cy.getByTestid('grw-fab-return-to-top').should('have.class', 'invisible');
+          });
+        });
+    });
+    cy.waitUntilSkeletonDisappear();
+    cy.screenshot(`${ssPrefix}scroll-page-to-top`);
+  });
+
+  it('Able to click buttons on subnavigation switcher when sticky', () => {
+    cy.waitUntil(() => {
+      // do
+      // Scroll the window 250px down is enough to trigger sticky effect
+      cy.scrollTo(0, 250);
+      // wait until
+      return cy.getByTestid('grw-subnav-switcher').then($elem => !$elem.hasClass('grw-subnav-switcher-hidden'));
+    });
+    cy.waitUntil(() => {
+      cy.getByTestid('grw-subnav-switcher').within(() => {
+        cy.getByTestid('editor-button').should('be.visible').click();
+      });
+      return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
+    });
+    cy.get('.grw-editor-navbar-bottom').should('be.visible');
+    cy.screenshot(`${ssPrefix}open-editor-when-sticky`);
+  });
+
+  it('Subnavigation is sticky when on small window', () => {
+    cy.waitUntil(() => {
+      // do
+      // Scroll the window 500px down
+      cy.scrollTo(0, 500);
+      // wait until
+      return cy.getByTestid('grw-subnav-switcher').then($elem => !$elem.hasClass('grw-subnav-switcher-hidden'));
+    });
+    cy.waitUntilSkeletonDisappear();
+    cy.viewport(600, 1024);
+    cy.getByTestid('grw-subnav-switcher').within(() => {
+      cy.get('#grw-page-editor-mode-manager').should('be.visible');
+    })
+    cy.screenshot(`${ssPrefix}sticky-on-small-window`);
+  });
+});

+ 13 - 6
apps/app/test/cypress/integration/20-basic-features/20-basic-features--use-tools.spec.ts

@@ -44,16 +44,16 @@ context('Modal for page operation', () => {
       cy.screenshot(`${ssPrefix}today-add-page-name`);
       cy.getByTestid('btn-create-memo').click();
     });
-    cy.getByTestid('page-editor').should('be.visible');
 
+    cy.getByTestid('page-editor').should('be.visible');
+    cy.getByTestid('save-page-btn').as('save-page-btn').should('be.visible');
     cy.waitUntil(() => {
       // do
-      cy.getByTestid('save-page-btn').should('be.visible').click();
+      cy.get('@save-page-btn').click();
       // wait until
-      return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
+      return cy.get('@save-page-btn').then($elem => $elem.is(':disabled'));
     });
-
-    cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
+    cy.get('.layout-root').should('not.have.class', 'editing');
 
     cy.collapseSidebar(true);
     cy.waitUntilSkeletonDisappear();
@@ -80,8 +80,15 @@ context('Modal for page operation', () => {
       cy.screenshot(`${ssPrefix}under-path-add-page-name`);
       cy.getByTestid('btn-create-page-under-below').click();
     });
+
     cy.getByTestid('page-editor').should('be.visible');
-    cy.getByTestid('save-page-btn').click();
+    cy.getByTestid('save-page-btn').as('save-page-btn').should('be.visible');
+    cy.waitUntil(() => {
+      // do
+      cy.get('@save-page-btn').click();
+      // wait until
+      return cy.get('@save-page-btn').then($elem => $elem.is(':disabled'));
+    });
     cy.get('.layout-root').should('not.have.class', 'editing');
 
     cy.getByTestid('grw-contextual-sub-nav').should('be.visible');

+ 58 - 0
apps/app/test/cypress/integration/21-basic-features-for-guest/21-basic-features-for-guest--sticky-for-guest.spec.ts

@@ -0,0 +1,58 @@
+context('Access sticky sub navigation switcher and Fab for guest', () => {
+  const ssPrefix = 'access-sticky-by-guest-';
+  it('Sub navigation sticky changes when scrolling down and up', () => {
+    cy.visit('/Sandbox');
+    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true, true);
+
+    // Sticky
+    cy.waitUntil(() => {
+      // do
+      // Scroll page down 250px
+      cy.scrollTo(0, 250);
+      // wait until
+      return cy.getByTestid('grw-subnav-switcher').then($elem => !$elem.hasClass('grw-subnav-switcher-hidden'));
+    });
+    cy.screenshot(`${ssPrefix}subnav-switcher-is-sticky-on-scroll-down`);
+
+    // Not sticky
+    cy.waitUntil(() => {
+      // do
+      // Scroll page to top
+      cy.scrollTo(0, 0);
+      // wait until
+      return cy.getByTestid('grw-subnav-switcher').then($elem => $elem.hasClass('grw-subnav-switcher-hidden'));
+    });
+    cy.screenshot(`${ssPrefix}subnav-switcher-is-not-sticky-on-scroll-top`);
+  });
+
+  it('Fab display changes when scrolling down and up', () => {
+    cy.visit('/Sandbox');
+    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true, true);
+
+    // Visible
+    cy.waitUntil(() => {
+      // do
+      // Scroll the window 250px down is enough to trigger sticky effect
+       cy.scrollTo(0, 250);
+
+      // wait until
+      return cy.getByTestid('grw-fab-return-to-top').then($elem => $elem.hasClass('visible'));
+
+    });
+    cy.screenshot(`${ssPrefix}fab-is-visible-on-scroll-down`);
+
+    // Invisible
+    cy.waitUntil(() => {
+      // do
+      // Scroll page to top
+       cy.scrollTo(0, 0);
+
+       // wait until
+      return cy.getByTestid('grw-fab-return-to-top').then($elem => $elem.hasClass('invisible'));
+    });
+    cy.screenshot(`${ssPrefix}fab-is-invisible-on-scroll-top`);
+
+  });
+});

+ 1 - 16
apps/app/test/cypress/integration/60-home/60-home--home.spec.ts

@@ -115,23 +115,8 @@ context('Access User settings', () => {
     });
   });
 
-  it('Access Editor setting', () => {
-    cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(4) a').click();
-    cy.scrollTo('top');
-    cy.getByTestid('grw-editor-settings').should('be.visible');
-    cy.screenshot(`${ssPrefix}-editor-setting-1`);
-    cy.getByTestid('grw-editor-settings-update-button').click();
-    cy.get('.Toastify__toast').should('be.visible');
-    cy.screenshot(`${ssPrefix}-editor-setting-2`);
-
-    cy.get('.Toastify__toast').should('be.visible').within(() => {
-      cy.get('.Toastify__close-button').should('be.visible').click();
-      cy.get('.Toastify__progress-bar').invoke('attr', 'style', 'display: none')
-    });
-  });
-
   it('Access In-app notification setting', () => {
-    cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(5) a').click();
+    cy.getByTestid('grw-personal-settings').find('.nav-title.nav li:eq(4) a').click();
     cy.scrollTo('top');
     cy.screenshot(`${ssPrefix}-in-app-notification-setting-1`);
     cy.getByTestid('grw-in-app-notification-settings-update-button').click();

+ 2 - 4
apps/app/test/integration/service/page.test.js

@@ -666,8 +666,7 @@ describe('PageService', () => {
       expect(resultPage.updatedAt).toEqual(parentForDelete1.updatedAt);
       expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
 
-      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete1, testUser2);
-      expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete1, resultPage, testUser2);
     });
 
     test('delete page with isRecursively', async() => {
@@ -686,8 +685,7 @@ describe('PageService', () => {
       expect(resultPage.updatedAt).toEqual(parentForDelete2.updatedAt);
       expect(resultPage.lastUpdateUser).toEqual(testUser1._id);
 
-      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete2, testUser2);
-      expect(pageEventSpy).toHaveBeenCalledWith('create', resultPage, testUser2);
+      expect(pageEventSpy).toHaveBeenCalledWith('delete', parentForDelete2, resultPage, testUser2);
     });
 
 

+ 0 - 2
apps/app/tsconfig.build.client.json

@@ -13,8 +13,6 @@
       "~/*": ["./src/*"],
       "^/*": ["./*"],
 
-      "@growi/codemirror-textlint": ["../../packages/codemirror-textlint/src"],
-
       "debug": ["./src/server/utils/logger/alias-for-debug"]
     }
   }

+ 0 - 2
apps/app/tsconfig.json

@@ -12,8 +12,6 @@
       "~/*": ["./src/*"],
       "^/*": ["./*"],
 
-      "@growi/codemirror-textlint": ["../../packages/codemirror-textlint/src"],
-
       "debug": ["./src/server/utils/logger/alias-for-debug"]
     }
   },

+ 42 - 44
apps/slackbot-proxy/docker/Dockerfile

@@ -1,38 +1,40 @@
 # syntax = docker/dockerfile:1.4
 
 ##
-## packages-json-picker
+## base
 ##
-FROM node:16-slim AS packages-json-picker
+FROM node:18-slim AS base
 
 ENV optDir /opt
 
 WORKDIR ${optDir}
-COPY ["package.json", "yarn.lock", "lerna.json", "./"]
-COPY packages packages
-# Find and remove non-package.json files
-RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
+
+RUN yarn global add turbo
+COPY . .
+RUN turbo prune --scope=@growi/slackbot-proxy --docker
 
 
 ##
-## deps-resolver-dev
+## deps-resolver
 ##
-FROM node:16-slim AS deps-resolver-dev
+FROM node:18-slim AS deps-resolver
 
 ENV optDir /opt
 
 WORKDIR ${optDir}
 
 # copy files
-COPY --from=packages-json-picker ${optDir} .
+COPY --from=base ${optDir}/out/json/ .
+COPY --from=base ${optDir}/out/yarn.lock ./yarn.lock
 
-# setup
-RUN yarn config set network-timeout 300000
-RUN npx -y lerna bootstrap -- --frozen-lockfile
+# setup (with network-timeout = 1 hour)
+RUN yarn config set network-timeout 3600000
+RUN yarn --frozen-lockfile
 
 # make artifacts
 RUN tar -cf node_modules.tar \
   node_modules \
+  apps/*/node_modules \
   packages/*/node_modules
 
 
@@ -40,19 +42,13 @@ RUN tar -cf node_modules.tar \
 ##
 ## deps-resolver-prod
 ##
-FROM node:16-slim AS deps-resolver-prod
-
-ENV optDir /opt
-
-WORKDIR ${optDir}
-COPY ["package.json", "yarn.lock", "lerna.json", "./"]
-COPY ./packages/slack/package.json ./packages/slack/package.json
-COPY ./apps/slackbot-proxy/package.json ./apps/slackbot-proxy/package.json
+FROM deps-resolver AS deps-resolver-prod
 
-RUN npx -y lerna bootstrap -- --production
+RUN yarn --production
 # make artifacts
-RUN tar -cf dependencies.tar \
+RUN tar -cf node_modules.tar \
   node_modules \
+  apps/*/node_modules \
   packages/*/node_modules
 
 
@@ -60,62 +56,64 @@ RUN tar -cf dependencies.tar \
 ##
 ## builder
 ##
-FROM node:16-slim AS builder
+FROM node:18-slim AS builder
 
 ENV optDir /opt
 
 WORKDIR ${optDir}
 
+RUN yarn global add turbo
+
+# copy files
+COPY --from=base ${optDir}/out/full/ .
+COPY --from=base ${optDir}/out/yarn.lock ./yarn.lock
+COPY ["tsconfig.base.json", "./"]
+
 # copy dependent packages
-COPY --from=deps-resolver-dev \
+COPY --from=deps-resolver \
   ${optDir}/node_modules.tar ${optDir}/
 
 # extract node_modules.tar
 RUN tar -xf node_modules.tar
 RUN rm node_modules.tar
 
-COPY ["package.json", "lerna.json", "tsconfig.base.json", "./"]
-# copy all related packages
-COPY packages/slack packages/slack
-COPY apps/slackbot-proxy apps/slackbot-proxy
-
 # build
-RUN yarn lerna run build
+RUN turbo run build
 
 # make artifacts
 RUN tar -cf packages.tar \
-  packages/slack/package.json \
-  packages/slack/dist \
-  apps/slackbot-proxy/package.json \
-  apps/slackbot-proxy/.env \
-  apps/slackbot-proxy/dist
+  package.json \
+  apps/*/package.json \
+  apps/*/dist \
+  apps/*/.env \
+  packages/*/package.json \
+  packages/*/dist
 
 
 
 ##
 ## release
 ##
-FROM node:16-slim
+FROM node:18-slim
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV NODE_ENV production
 
 ENV optDir /opt
-ENV appDir ${optDir}
-
+ENV appDir ${optDir}/slackbot-proxy
 USER node
-
-WORKDIR ${appDir}
 # copy artifacts
 COPY --from=deps-resolver-prod --chown=node:node \
-  ${optDir}/dependencies.tar ./
+  ${optDir}/node_modules.tar ${appDir}/
 COPY --from=builder --chown=node:node \
-  ${optDir}/packages.tar ./
+  ${optDir}/packages.tar ${appDir}/
+
+WORKDIR ${appDir}
 
 # extract artifacts
-RUN tar -xf dependencies.tar
+RUN tar -xf node_modules.tar
 RUN tar -xf packages.tar
-RUN rm dependencies.tar packages.tar
+RUN rm node_modules.tar packages.tar
 
 WORKDIR ${appDir}/apps/slackbot-proxy
 

+ 3 - 0
apps/slackbot-proxy/docker/Dockerfile.dockerignore

@@ -3,3 +3,6 @@
 **/coverage
 **/Dockerfile
 **/*.dockerignore
+**/.turbo
+out
+apps/app

Некоторые файлы не были показаны из-за большого количества измененных файлов