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

Merge pull request #7666 from weseek/master

Release v6.1.2
Yuki Takei 2 лет назад
Родитель
Сommit
d6fb02726c
96 измененных файлов с 1664 добавлено и 1161 удалено
  1. 2 2
      .devcontainer/docker-compose.yml
  2. 5 0
      .eslintrc.js
  3. 4 0
      .github/dependabot.yml
  4. 16 16
      .github/release-drafter.yml
  5. 4 3
      .github/workflows/auto-approve.yml
  6. 2 2
      .github/workflows/pr-to-master.yml
  7. 1 1
      .github/workflows/release-slackbot-proxy.yml
  8. 3 3
      .github/workflows/release.yml
  9. 1 1
      .github/workflows/reusable-app-build-image.yml
  10. 1 2
      .mergify.yml
  11. 52 46
      CHANGELOG.md
  12. 14 12
      apps/app/package.json
  13. 8 8
      apps/app/public/static/locales/en_US/admin.json
  14. 1 7
      apps/app/public/static/locales/en_US/translation.json
  15. 7 7
      apps/app/public/static/locales/ja_JP/admin.json
  16. 1 7
      apps/app/public/static/locales/ja_JP/translation.json
  17. 7 7
      apps/app/public/static/locales/zh_CN/admin.json
  18. 1 7
      apps/app/public/static/locales/zh_CN/translation.json
  19. 8 8
      apps/app/src/client/services/AdminLocalSecurityContainer.js
  20. 10 10
      apps/app/src/client/services/AdminMarkDownContainer.js
  21. 6 6
      apps/app/src/client/services/AdminUsersContainer.js
  22. 1 1
      apps/app/src/components/Admin/AdminHome/AdminHome.jsx
  23. 6 10
      apps/app/src/components/Admin/AdminHome/EnvVarsTable.tsx
  24. 17 17
      apps/app/src/components/Admin/MarkdownSetting/WhitelistInput.jsx
  25. 2 2
      apps/app/src/components/Admin/MarkdownSetting/XssForm.jsx
  26. 3 3
      apps/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  27. 9 9
      apps/app/src/components/Admin/Users/GrantAdminButton.tsx
  28. 15 15
      apps/app/src/components/Admin/Users/RevokeAdminButton.tsx
  29. 13 13
      apps/app/src/components/Admin/Users/RevokeAdminMenuItem.tsx
  30. 3 3
      apps/app/src/components/Admin/Users/UserMenu.tsx
  31. 7 9
      apps/app/src/components/DuplicatedPathsTable.tsx
  32. 5 5
      apps/app/src/components/LoginForm.tsx
  33. 4 4
      apps/app/src/components/Me/BasicInfoSettings.tsx
  34. 21 13
      apps/app/src/components/PageEditor/CodeMirrorEditor.jsx
  35. 0 471
      apps/app/src/components/PageEditor/LinkEditModal.jsx
  36. 372 0
      apps/app/src/components/PageEditor/LinkEditModal.tsx
  37. 1 1
      apps/app/src/components/PageList/PageList.tsx
  38. 21 31
      apps/app/src/components/PageTimeline.tsx
  39. 6 6
      apps/app/src/interfaces/activity.ts
  40. 4 2
      apps/app/src/pages/[[...path]].page.tsx
  41. 2 2
      apps/app/src/pages/_private-legacy-pages.page.tsx
  42. 2 2
      apps/app/src/pages/_search.page.tsx
  43. 3 3
      apps/app/src/pages/login/index.page.tsx
  44. 6 6
      apps/app/src/pages/me/[[...path]].page.tsx
  45. 2 2
      apps/app/src/pages/share/[[...path]].page.tsx
  46. 12 0
      apps/app/src/server/models/.eslintrc.js
  47. 3 3
      apps/app/src/server/models/config.ts
  48. 27 0
      apps/app/src/server/models/eslint-rules-dir/no-populate.js
  49. 25 0
      apps/app/src/server/models/eslint-rules-dir/test/no-populate.spec.ts
  50. 10 10
      apps/app/src/server/models/user.js
  51. 13 13
      apps/app/src/server/routes/apiv3/markdown-setting.js
  52. 6 6
      apps/app/src/server/routes/apiv3/security-setting.js
  53. 20 20
      apps/app/src/server/routes/apiv3/users.js
  54. 2 2
      apps/app/src/server/routes/page.js
  55. 1 1
      apps/app/src/server/service/installer.ts
  56. 1 0
      apps/app/src/server/service/search.ts
  57. 6 6
      apps/app/src/server/service/xss.js
  58. 54 0
      apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.spec.ts
  59. 6 7
      apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.ts
  60. 64 0
      apps/app/src/services/renderer/rehype-plugins/relative-links.spec.ts
  61. 12 9
      apps/app/src/services/renderer/rehype-plugins/relative-links.ts
  62. 45 0
      apps/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.spec.ts
  63. 2 2
      apps/app/src/services/renderer/renderer.tsx
  64. 6 6
      apps/app/src/services/xss/index.js
  65. 9 8
      apps/app/src/services/xss/xssOption.ts
  66. 2 2
      apps/app/src/stores/context.tsx
  67. 30 0
      apps/app/src/stores/modal.tsx
  68. 27 0
      apps/app/src/stores/page-timeline.tsx
  69. 0 92
      apps/app/test/unit/services/renderer/pukiwiki-like-linker.test.ts
  70. 7 0
      apps/app/vitest.config.unit.ts
  71. 2 2
      apps/slackbot-proxy/package.json
  72. 0 0
      bin/data-migrations/README.md
  73. 13 17
      bin/data-migrations/src/index.js
  74. 8 0
      bin/data-migrations/src/migrations/custom.js
  75. 8 0
      bin/data-migrations/src/migrations/v60x/bracketlink.js
  76. 6 0
      bin/data-migrations/src/migrations/v60x/csv.js
  77. 6 0
      bin/data-migrations/src/migrations/v60x/drawio.js
  78. 6 0
      bin/data-migrations/src/migrations/v60x/index.js
  79. 6 0
      bin/data-migrations/src/migrations/v60x/plantuml.js
  80. 6 0
      bin/data-migrations/src/migrations/v60x/tsv.js
  81. 3 0
      bin/data-migrations/src/migrations/v61x/index.js
  82. 6 0
      bin/data-migrations/src/migrations/v61x/mdcont.js
  83. 0 83
      bin/data-migrations/v6/src/processor.js
  84. 9 5
      package.json
  85. 1 1
      packages/core/package.json
  86. 1 1
      packages/hackmd/package.json
  87. 2 2
      packages/presentation/package.json
  88. 1 1
      packages/preset-themes/package.json
  89. 4 4
      packages/remark-attachment-refs/package.json
  90. 1 1
      packages/remark-drawio/package.json
  91. 1 1
      packages/remark-growi-directive/package.json
  92. 4 4
      packages/remark-lsx/package.json
  93. 1 1
      packages/slack/package.json
  94. 2 2
      packages/ui/package.json
  95. 4 0
      vitest.workspace.ts
  96. 494 64
      yarn.lock

+ 2 - 2
.devcontainer/docker-compose.yml

@@ -46,7 +46,7 @@ services:
   #   cloned from https://github.com/weseek/growi-docker-compose.git
   elasticsearch:
     build:
-      context: ../../growi-docker-compose/elasticsearch
+      context: ../../growi-docker-compose/elasticsearch/v8
       dockerfile: ./Dockerfile
       args:
         - version=8.7.0
@@ -63,7 +63,7 @@ services:
         hard: -1
     volumes:
       - /usr/share/elasticsearch/data
-      - ../../growi-docker-compose/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
+      - ../../growi-docker-compose/elasticsearch/v8/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
 
   #need to adjust kibana version based on elasticsearch version (use same version as elasticsearch version)
   kibana:

+ 5 - 0
.eslintrc.js

@@ -20,6 +20,11 @@ module.exports = {
       'warn',
       {
         pathGroups: [
+          {
+            pattern: 'vitest',
+            group: 'builtin',
+            position: 'before',
+          },
           {
             pattern: 'react',
             group: 'builtin',

+ 4 - 0
.github/dependabot.yml

@@ -5,6 +5,8 @@ updates:
     open-pull-requests-limit: 3
     schedule:
       interval: monthly
+    labels:
+      - "type/dependencies"
     commit-message:
       prefix: ci
       include: scope
@@ -14,6 +16,8 @@ updates:
     open-pull-requests-limit: 3
     schedule:
       interval: weekly
+    labels:
+      - "type/dependencies"
     commit-message:
       prefix: ci
       include: scope

+ 16 - 16
.github/release-drafter.yml

@@ -1,35 +1,35 @@
 categories:
   - title: 'BREAKING CHANGES'
     labels:
-      - 'breaking'
+      - 'type/reaking'
   - title: '💎 Features'
     labels:
-      - 'feature'
+      - 'type/feature'
   - title: '🚀 Improvement'
     labels:
-      - 'improvement'
+      - 'type/improvement'
   - title: '🐛 Bug Fixes'
     labels:
-      - 'bug'
+      - 'type/bug'
   - title: '🧰 Maintenance'
     labels:
-      - 'support'
-      - 'dependencies'
+      - 'type/support'
+      - 'type/dependencies'
 category-template: '### $TITLE'
 change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
 autolabeler:
   - label: 'feature'
     branch:
       - '/^feat\/.+/'
-  - label: 'improvement'
+  - label: 'type/improvement'
     branch:
       - '/^imprv\/.+/'
-  - label: 'bug'
+  - label: 'type/bug'
     branch:
       - '/^fix\/.+/'
     title:
       - '/^fix/i'
-  - label: 'support'
+  - label: 'type/support'
     branch:
       - '/^support\/.+/'
     title:
@@ -39,13 +39,13 @@ autolabeler:
       - '/^docs/i'
       - '/^test/i'
 include-labels:
-  - breaking
-  - feature
-  - improvement
-  - bug
-  - support
-  - dependencies
+  - type/breaking
+  - type/feature
+  - type/improvement
+  - type/bug
+  - type/support
+  - type/dependencies
 exclude-labels:
-  - 'exclude from changelog'
+  - 'flag/exclude-from-changelog'
 template: |
   $CHANGES

+ 4 - 3
.github/workflows/dependabot-auto-approve.yml → .github/workflows/auto-approve.yml

@@ -1,5 +1,4 @@
-# by https://zenn.dev/nemuki/articles/dependabot-auto-merge
-name: Auto approve on dependabot PR at patch update
+name: Auto approve PR
 
 on:
   pull_request_target:
@@ -9,7 +8,9 @@ permissions:
   pull-requests: write
 
 jobs:
-  dependabot-auto-approve:
+  # Auto approve on dependabot PR at patch update
+  #   by https://zenn.dev/nemuki/articles/dependabot-auto-merge
+  approve-updating-patch-version:
     runs-on: ubuntu-latest
     if: ${{ github.actor == 'dependabot[bot]' }}
     steps:

+ 2 - 2
.github/workflows/pr-to-master.yml

@@ -19,7 +19,7 @@ jobs:
     runs-on: ubuntu-latest
 
     if: |
-      !contains(github.event.pull_request.labels.*.name, 'exclude from changelog')
+      !contains(github.event.pull_request.labels.*.name, 'flag/exclude-from-changelog')
 
     steps:
       - uses: release-drafter/release-drafter@v5
@@ -32,7 +32,7 @@ jobs:
     runs-on: ubuntu-latest
 
     if: |
-      (!contains( github.event.pull_request.labels.*.name, 'exclude from changelog' ) &&
+      (!contains( github.event.pull_request.labels.*.name, 'flag/exclude-from-changelog' ) &&
         !startsWith( github.head_ref, 'dependabot/' ))
 
     steps:

+ 1 - 1
.github/workflows/release-slackbot-proxy.yml

@@ -135,6 +135,6 @@ jobs:
         source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         destination_branch: master
         pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
-        pr_label: exclude from changelog
+        pr_label: flag/exclude-from-changelog
         pr_body: "An automated PR generated by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
         github_token: ${{ secrets.GITHUB_TOKEN }}

+ 3 - 3
.github/workflows/release.yml

@@ -69,7 +69,7 @@ jobs:
         token: ${{ secrets.GITHUB_TOKEN }}
 
     - name: Delete drafts
-      uses: hugo19941994/delete-draft-releases@v1.0.0
+      uses: hugo19941994/delete-draft-releases@v1.0.1
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
@@ -118,8 +118,8 @@ jobs:
         source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         destination_branch: master
         pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
-        pr_label: exclude from changelog,prepare next version
-        pr_body: "An automated PR generated by create-pr-for-next-rc"
+        pr_label: flag/exclude-from-changelog,type/prepare-next-version
+        pr_body: "[skip ci] An automated PR generated by create-pr-for-next-rc"
         github_token: ${{ secrets.GITHUB_TOKEN }}
 
 

+ 1 - 1
.github/workflows/reusable-app-build-image.yml

@@ -36,7 +36,7 @@ jobs:
     - uses: actions/checkout@v3
 
     - name: Configure AWS Credentials
-      uses: aws-actions/configure-aws-credentials@v1
+      uses: aws-actions/configure-aws-credentials@v2
       with:
         aws-region: ap-northeast-1
         role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}

+ 1 - 2
.mergify.yml

@@ -15,8 +15,7 @@ pull_request_rules:
   - name: Automatic merge for Preparing next version
     conditions:
       - author = github-actions[bot]
-      - '#approved-reviews-by >= 1'
-      - label = "prepare next version"
+      - label = "type/prepare-next-version"
     actions:
       merge:
         method: merge

+ 52 - 46
CHANGELOG.md

@@ -17,60 +17,66 @@
 
 ## [v6.1.0](https://github.com/weseek/growi/compare/v6.0.15...v6.1.0) - 2023-05-17
 
+### BREAKING CHANGES
+
+* Node.js v14 is no longer supported.
+* Elasticsearch v6 is no longer supported.
+* imprv: Omit clobber prefix (#7627) @yuki-takei
+* support: Omit textlint (#7578) @yuki-takei
+* support: Remove Blockdiag codes (#7576) @yuki-takei
+
+See the upgrading guide for v6.1.x. => [English](https://docs.growi.org/en/admin-guide/upgrading/61x.html) / [Japanese](https://docs.growi.org/ja/admin-guide/upgrading/61x.html)
+
 ### 💎 Features
 
-- feat: Add read-only user feature (#7648) @jam411
-- feat: Support Mermaid (move into the feature dierctory) (#7647) @miya
-- feat: Fix APP_SITE_URL with an environment variable (#7646) @yuki-takei
-- feat: Support Mermaid (#7645) @miya
-- feat: Support Elasticsearch v8 (#7623) @miya
-- feat: Elasticsearchv8 module (#7623) @miya
-- feat: Bookmarks folder and sidebar menu (#7450) @mudana-grune
-- feat: GROWI Questionnaire (#7316) @hakumizuki
-- feat: Revive attachment-refs with remark (#7597) @arafubeatbox
+* feat: Add read-only user feature (#7648) @jam411
+* feat: Support Mermaid (move into the feature dierctory) (#7647) @miya
+* feat: Fix APP\_SITE\_URL with an environment variable (#7646) @yuki-takei
+* feat: Support Mermaid (#7645) @miya
+* feat: Support Elasticsearch v8 (#7623) @miya
+* feat: Elasticsearchv8 module (#7623) @miya
+* feat: Bookmarks folder and sidebar menu (#7450) @mudana-grune
+* feat: GROWI Questionnaire (#7316) @hakumizuki
+* feat: Revive attachment-refs with remark (#7597) @arafubeatbox
 
 ### 🚀 Improvement
 
-- imprv: Font size (#7663) @yuki-takei
-- imprv: Admin user can use `reset-password` without email settings (#7650) @jam411
-- imprv: Optimize fonts with next/font (#7633) @yuki-takei
-- imprv: GFM table performance 2 (#7640) @yuki-takei
-- imprv: GFM footnote styles (#7628) @yuki-takei
-- imprv: Omit clobber prefix (#7627) @yuki-takei
-- imprv: GFM table performance (#7619) @yuki-takei
-- imprv: Show unsaved warning when comment not posted (#7603) @arafubeatbox
-- imprv: Suppress UI Flickering for dropdowns (#7608) @jam411
-- imprv: Allow registering without GROWI email settings for ID/Password authentication's restricted registration (#7591) @jam411
-- imprv: Enable browsing video (for v6.1.0) (#7589) @yuki-takei
-- imprv: Show a spinner into the save button while the saving process (#7579) @yuki-takei
-- imprv: Inject PlantUML URI with config-loader (#7577) @yuki-takei
-- imprv: Loading draw.io (diagrams.net) resources (#7575) @yuki-takei
-
-### 🐛 Bug Fixes
-
-- fix: The environment variable for disabling link sharing (#7652) @yuki-takei
-- fix: Cursor resetting occurs after updating with the built-in editor (#7644) @yuki-takei
-- fix: Revision schema migration for v5 to v6 (#7637) @yuki-takei
-- fix: Editor not resetting when the same markdown (#7625) @arafubeatbox
-- support: Replaced by IAttachmentHasId (#7629) @reiji-h
-- fix: AlignRight DropdownMenu flickering (#7606) @mudana-grune
-- fix: Not display page list count badge in trash page (#7600) @yukendev
-- fix: Reverted descendant pages do not appear in search results (#7587) @miya
-- fix: Deleted descendant pages do not appear in search results (#7583) @miya
-- fix: Show lsx page list in trash page correctly (#7582) @yukendev
-- fix: Uncaught type error by `sticky-event` (#7566) @mudana-grune
+* imprv: Font size (#7663) @yuki-takei
+* imprv: Admin user can use `reset-password` without email settings (#7650) @jam411
+* imprv: Optimize fonts with next/font (#7633) @yuki-takei
+* imprv: GFM table performance 2 (#7640) @yuki-takei
+* imprv: GFM footnote styles (#7628) @yuki-takei
+* imprv: GFM table performance (#7619) @yuki-takei
+* imprv: Show unsaved warning when comment not posted (#7603) @arafubeatbox
+* imprv: Suppress UI Flickering for dropdowns (#7608) @jam411
+* imprv: Allow registering without GROWI email settings for ID/Password authentication's restricted registration (#7591) @jam411
+* imprv: Enable browsing video (for v6.1.0) (#7589) @yuki-takei
+* imprv: Show a spinner into the save button while the saving process (#7579) @yuki-takei
+* imprv: Inject PlantUML URI with config-loader (#7577) @yuki-takei
+* imprv: Loading draw.io (diagrams.net) resources (#7575) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: The environment variable for disabling link sharing (#7652) @yuki-takei
+* fix: Cursor resetting occurs after updating with the built-in editor (#7644) @yuki-takei
+* fix: Revision schema migration for v5 to v6 (#7637) @yuki-takei
+* fix: Editor not resetting when the same markdown (#7625) @arafubeatbox
+* fix: AlignRight DropdownMenu flickering (#7606) @mudana-grune
+* fix: Not display page list count badge in trash page (#7600) @yukendev
+* fix: Reverted descendant pages do not appear in search results (#7587) @miya
+* fix: Deleted descendant pages do not appear in search results (#7583) @miya
+* fix: Show lsx page list in trash page correctly (#7582) @yukendev
+* fix: Uncaught type error by `sticky-event` (#7566) @mudana-grune
 
 ### 🧰 Maintenance
 
-- support: mongoose update (#7659) @jam411
-- support: Elasticsearch8 (#7592) @miya
-- support: Replaced by IAttachmentHasId (#7629) @reiji-h
-- support: Dedupe packages (#7590) @yuki-takei
-- support: Omit textlint (#7578) @yuki-takei
-- support: Typescriptize CustomNav (#7584) @yuki-takei
-- support: Replaced by IAttachmentHasId (#7629) @reiji-h
-- support: Remove Blockdiag codes (#7576) @yuki-takei
-- support: Migrate to Turborepo (#7417) @yuki-takei
+* support: mongoose update (#7659) @jam411
+* support: Elasticsearch8 (#7592) @miya
+* support: Replaced by IAttachmentHasId (#7629) @reiji-h
+* support: Dedupe packages (#7590) @yuki-takei
+* support: Typescriptize CustomNav (#7584) @yuki-takei
+* support: Replaced by IAttachmentHasId (#7629) @reiji-h
+* support: Migrate to Turborepo (#7417) @yuki-takei
 
 ## [v6.0.15](https://github.com/weseek/growi/compare/v6.0.14...v6.0.15) - 2023-04-10
 

+ 14 - 12
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "6.1.1",
+  "version": "6.1.2-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -33,7 +33,9 @@
     "lint:swagger2openapi": "node node_modules/.bin/oas-validate tmp/swagger.json",
     "lint": "run-p lint:*",
     "prelint:swagger2openapi": "yarn openapi:v3",
-    "test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
+    "test": "run-p test:*",
+    "test:jest": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
+    "test:vitest": "cross-env NODE_ENV=test vitest run src",
     "jest:run": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
     "reg:run": "reg-suit run",
     "//// misc": "",
@@ -59,14 +61,14 @@
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/core": "^6.1.1",
-    "@growi/hackmd": "^6.1.1",
-    "@growi/preset-themes": "^6.1.1",
-    "@growi/remark-attachment-refs": "^6.1.1",
-    "@growi/remark-drawio": "^6.1.1",
-    "@growi/remark-growi-directive": "^6.1.1",
-    "@growi/remark-lsx": "^6.1.1",
-    "@growi/slack": "^6.1.1",
+    "@growi/core": "^6.1.2-RC.0",
+    "@growi/hackmd": "^6.1.2-RC.0",
+    "@growi/preset-themes": "^6.1.2-RC.0",
+    "@growi/remark-attachment-refs": "^6.1.2-RC.0",
+    "@growi/remark-drawio": "^6.1.2-RC.0",
+    "@growi/remark-growi-directive": "^6.1.2-RC.0",
+    "@growi/remark-lsx": "^6.1.2-RC.0",
+    "@growi/slack": "^6.1.2-RC.0",
     "@promster/express": "^7.0.6",
     "@promster/server": "^7.0.8",
     "@slack/web-api": "^6.2.4",
@@ -202,8 +204,8 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   "devDependencies": {
-    "@growi/presentation": "^6.1.1",
-    "@growi/ui": "^6.1.1",
+    "@growi/presentation": "^6.1.2-RC.0",
+    "@growi/ui": "^6.1.2-RC.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^13.2.3",

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

@@ -290,7 +290,7 @@
     "management_wiki": "Management Wiki",
     "system_information": "System information",
     "wiki_administrator": "Only wiki administrator can access this page",
-    "assign_administrator": "You can assign the selected user to be a wiki administrator on the User Management page using the 'Give admin access' button",
+    "assign_administrator": "You can assign the selected user to be a wiki administrator on the User Management page using the 'Grant admin access' button",
     "package_name": "Package name",
     "specified_version": "Specified version",
     "installed_version": "Installed version",
@@ -743,9 +743,9 @@
       "accept": "Accept",
       "deactivate_account": "Deactivate account",
       "your_own": "You cannot deactivate your own account",
-      "remove_admin_access": "Remove admin access",
-      "cannot_remove": "You cannot remove yourself from administrator",
-      "give_admin_access": "Give admin access",
+      "revoke_admin_access": "Revoke admin access",
+      "cannot_revoke": "You cannot revoke yourself from administrator",
+      "grant_admin_access": "Grant admin access",
       "revoke_read_only_access": "Revoke read only access",
       "grant_read_only_access": "Grant read only access",
       "send_invitation_email": "Send invitation email",
@@ -1018,8 +1018,8 @@
     "ADMIN_USERS_PASSWORD_RESET": "Reset user password",
     "ADMIN_USERS_ACTIVATE": "Activate user",
     "ADMIN_USERS_DEACTIVATE": "Deactivate user",
-    "ADMIN_USERS_GIVE_ADMIN": "Give admin access",
-    "ADMIN_USERS_REMOVE_ADMIN": "Remove admin access",
+    "ADMIN_USERS_GRANT_ADMIN": "Grant admin access",
+    "ADMIN_USERS_REVOKE_ADMIN": "Revoke admin access",
     "ADMIN_USERS_GRANT_READ_ONLY": "Grant read only access",
     "ADMIN_USERS_REVOKE_READ_ONLY": "Revoke read only access",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "Resend invitation email",
@@ -1038,8 +1038,8 @@
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
   "toaster": {
-    "give_user_admin": "Succeeded to give {{username}} admin",
-    "remove_user_admin": "Succeeded to remove {{username}} admin",
+    "grant_user_admin": "Succeeded to grant {{username}} admin",
+    "revoke_user_admin": "Succeeded to revoke {{username}} admin",
     "grant_user_read_only": "Succeeded to grant {{username}} read only",
     "revoke_user_read_only": "Succeeded to revoke {{username}} read only",
     "activate_user_success": "Succeeded to activating {{username}}",

+ 1 - 7
apps/app/public/static/locales/en_US/translation.json

@@ -168,7 +168,7 @@
     "could_not_creata_path": "Couldn't create path."
   },
   "custom_navigation": {
-    "no_page_list": "There are no pages under this page."
+    "no_pages_under_this_page": "There are no pages under this page."
   },
   "installer": {
     "tab": "Create account",
@@ -445,12 +445,6 @@
     "file_upload_succeeded": "File upload succeeded.",
     "file_upload_failed": "File upload failed.",
     "initialize_successed": "Succeeded to initialize {{target}}",
-    "give_user_admin": "Succeeded to give {{username}} admin",
-    "remove_user_admin": "Succeeded to remove {{username}} admin",
-    "activate_user_success": "Succeeded to activating {{username}}",
-    "deactivate_user_success": "Succeeded to deactivate {{username}}",
-    "remove_user_success": "Succeeded to removing {{username}}",
-    "remove_external_user_success": "Succeeded to remove {{accountId}}",
     "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",
     "issue_share_link": "Succeeded to issue new share link",
     "remove_share_link": "Succeeded to remove {{count}} share links",

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

@@ -751,9 +751,9 @@
       "accept": "承認する",
       "deactivate_account": "アカウント停止",
       "your_own": "自分自身のアカウントを停止することはできません",
-      "remove_admin_access": "管理者から外す",
-      "cannot_remove": "自分自身を管理者から外すことはできません",
-      "give_admin_access": "管理者にする",
+      "revoke_admin_access": "管理者から外す",
+      "cannot_revoke": "自分自身を管理者から外すことはできません",
+      "grant_admin_access": "管理者にする",
       "revoke_read_only_access": "閲覧のみアクセス権を外す",
       "grant_read_only_access": "閲覧のみアクセス権を付与する",
       "send_invitation_email": "招待メールの送信",
@@ -1026,8 +1026,8 @@
     "ADMIN_USERS_PASSWORD_RESET": "ユーザーのパスワードをリセット",
     "ADMIN_USERS_ACTIVATE": "ユーザーを承認する",
     "ADMIN_USERS_DEACTIVATE": "ユーザーを停止する",
-    "ADMIN_USERS_GIVE_ADMIN": "管理者にする",
-    "ADMIN_USERS_REMOVE_ADMIN": "管理者から外す",
+    "ADMIN_USERS_GRANT_ADMIN": "管理者にする",
+    "ADMIN_USERS_REVOKE_ADMIN": "管理者から外す",
     "ADMIN_USERS_GRANT_READ_ONLY": "閲覧のみアクセス権を付与する",
     "ADMIN_USERS_REVOKE_READ_ONLY": "閲覧のみアクセス権を外す",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "招待メールの再送信",
@@ -1046,8 +1046,8 @@
     "error_send_growi_archive": "GROWI アーカイブファイルの送信に失敗しました"
   },
   "toaster": {
-    "give_user_admin": "{{username}}を管理者に設定しました",
-    "remove_user_admin": "{{username}}を管理者から外しました",
+    "grant_user_admin": "{{username}}を管理者に設定しました",
+    "revoke_user_admin": "{{username}}を管理者から外しました",
     "grant_user_read_only": "{{username}}に閲覧のみアクセス権を付与しました",
     "revoke_user_read_only": "{{username}}から閲覧のみアクセス権を外しました",
     "activate_user_success": "{{username}}を有効化しました",

+ 1 - 7
apps/app/public/static/locales/ja_JP/translation.json

@@ -169,7 +169,7 @@
     "could_not_creata_path": "パスを作成できませんでした。"
   },
   "custom_navigation": {
-    "no_page_list": "このページの配下にはページが存在しません。"
+    "no_pages_under_this_page": "このページの配下にはページが存在しません。"
   },
   "installer": {
     "tab": "アカウント作成",
@@ -478,12 +478,6 @@
     "file_upload_succeeded": "ファイルをアップロードしました",
     "file_upload_failed": "ファイルのアップロードに失敗しました",
     "initialize_successed": "{{target}}を初期化しました",
-    "give_user_admin": "{{username}}を管理者に設定しました",
-    "remove_user_admin": "{{username}}を管理者から外しました",
-    "activate_user_success": "{{username}}を有効化しました",
-    "deactivate_user_success": "{{username}}を無効化しました",
-    "remove_user_success": "{{username}}を削除しました",
-    "remove_external_user_success": "{{accountId}}を削除しました",
     "remove_share_link_success": "{{shareLinkId}}を削除しました",
     "issue_share_link": "共有リンクを作成しました",
     "remove_share_link": "共有リンクを{{count}}件削除しました",

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

@@ -751,9 +751,9 @@
       "accept": "接受",
       "deactivate_account": "停用帐户",
       "your_own": "您不能停用自己的帐户",
-      "remove_admin_access": "删除管理员访问权限",
-      "cannot_remove": "您不能从管理员中删除自己",
-      "give_admin_access": "授予管理员访问权限",
+      "revoke_admin_access": "删除管理员访问权限",
+      "cannot_revoke": "您不能从管理员中删除自己",
+      "grant_admin_access": "授予管理员访问权限",
       "revoke_read_only_access": "取消只读访问",
       "grant_read_only_access": "给予只读权限",
       "send_invitation_email": "发送邀请邮件",
@@ -1026,8 +1026,8 @@
     "ADMIN_USERS_PASSWORD_RESET": "重置用户密码",
     "ADMIN_USERS_ACTIVATE": "激活用户",
     "ADMIN_USERS_DEACTIVATE": "停用用户",
-    "ADMIN_USERS_GIVE_ADMIN": "授予管理员访问权限",
-    "ADMIN_USERS_REMOVE_ADMIN": "删除管理员访问权限",
+    "ADMIN_USERS_GRANT_ADMIN": "授予管理员访问权限",
+    "ADMIN_USERS_REVOKE_ADMIN": "删除管理员访问权限",
     "ADMIN_USERS_GRANT_READ_ONLY": "给予只读权限",
     "ADMIN_USERS_REVOKE_READ_ONLY": "取消只读访问",
     "ADMIN_USERS_SEND_INVITATION_EMAIL": "重发邀请函",
@@ -1046,8 +1046,8 @@
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
   "toaster": {
-    "give_user_admin": "Succeeded to give {{username}} admin",
-    "remove_user_admin": "Succeeded to remove {{username}} admin",
+    "grant_user_admin": "Succeeded to grant {{username}} admin",
+    "revoke_user_admin": "Succeeded to revoke {{username}} admin",
     "grant_user_read_only": "Succeeded to grant {{username}} read only",
     "revoke_user_read_only": "Succeeded to revoke {{username}} read only",
 		"activate_user_success": "Succeeded to activating {{username}}",

+ 1 - 7
apps/app/public/static/locales/zh_CN/translation.json

@@ -175,7 +175,7 @@
     "could_not_creata_path": "无法创建路径"
   },
   "custom_navigation": {
-    "no_page_list": "There are no pages under this page."
+    "no_pages_under_this_page": "There are no pages under this page."
   },
 	"installer": {
     "tab": "创建账户",
@@ -434,12 +434,6 @@
     "file_upload_succeeded": "文件上传成功",
     "file_upload_failed": "文件上传失败",
     "initialize_successed": "Succeeded to initialize {{target}}",
-		"give_user_admin": "Succeeded to give {{username}} admin",
-    "remove_user_admin": "Succeeded to remove {{username}} admin ",
-		"activate_user_success": "Succeeded to activating {{username}}",
-		"deactivate_user_success": "Succeeded to deactivate {{username}}",
-		"remove_user_success": "Succeeded to removing {{username}} ",
-    "remove_external_user_success": "Succeeded to remove {{accountId}} ",
     "switch_disable_link_sharing_success": "成功更新分享链接设置",
     "failed_to_reset_password":"Failed to reset password",
     "save_succeeded": "已成功保存",

+ 8 - 8
apps/app/src/client/services/AdminLocalSecurityContainer.js

@@ -28,7 +28,7 @@ export default class AdminLocalSecurityContainer extends Container {
       retrieveError: null,
       // set dummy value tile for using suspense
       registrationMode: this.dummyRegistrationMode,
-      registrationWhiteList: [],
+      registrationWhitelist: [],
       useOnlyEnvVars: false,
       isPasswordResetEnabled: false,
       isEmailAuthenticationEnabled: false,
@@ -43,7 +43,7 @@ export default class AdminLocalSecurityContainer extends Container {
       this.setState({
         useOnlyEnvVars: localSetting.useOnlyEnvVarsForSomeOptions,
         registrationMode: localSetting.registrationMode,
-        registrationWhiteList: localSetting.registrationWhiteList,
+        registrationWhitelist: localSetting.registrationWhitelist,
         isPasswordResetEnabled: localSetting.isPasswordResetEnabled,
         isEmailAuthenticationEnabled: localSetting.isEmailAuthenticationEnabled,
       });
@@ -72,10 +72,10 @@ export default class AdminLocalSecurityContainer extends Container {
   }
 
   /**
-   * Change registration white list
+   * Change registration whitelist
    */
-  changeRegistrationWhiteList(value) {
-    this.setState({ registrationWhiteList: value.split('\n') });
+  changeRegistrationWhitelist(value) {
+    this.setState({ registrationWhitelist: value.split('\n') });
   }
 
   /**
@@ -96,10 +96,10 @@ export default class AdminLocalSecurityContainer extends Container {
    * update local security setting
    */
   async updateLocalSecuritySetting() {
-    const { registrationWhiteList, isPasswordResetEnabled, isEmailAuthenticationEnabled } = this.state;
+    const { registrationWhitelist, isPasswordResetEnabled, isEmailAuthenticationEnabled } = this.state;
     const response = await apiv3Put('/security-setting/local-setting', {
       registrationMode: this.state.registrationMode,
-      registrationWhiteList,
+      registrationWhitelist,
       isPasswordResetEnabled,
       isEmailAuthenticationEnabled,
     });
@@ -108,7 +108,7 @@ export default class AdminLocalSecurityContainer extends Container {
 
     this.setState({
       registrationMode: localSettingParams.registrationMode,
-      registrationWhiteList: localSettingParams.registrationWhiteList,
+      registrationWhitelist: localSettingParams.registrationWhitelist,
       isPasswordResetEnabled: localSettingParams.isPasswordResetEnabled,
       isEmailAuthenticationEnabled: localSettingParams.isEmailAuthenticationEnabled,
     });

+ 10 - 10
apps/app/src/client/services/AdminMarkDownContainer.js

@@ -26,8 +26,8 @@ export default class AdminMarkDownContainer extends Container {
       isIndentSizeForced: false,
       isEnabledXss: false,
       xssOption: '',
-      tagWhiteList: '',
-      attrWhiteList: '{}',
+      tagWhitelist: '',
+      attrWhitelist: '{}',
     };
 
     this.switchEnableXss = this.switchEnableXss.bind(this);
@@ -55,8 +55,8 @@ export default class AdminMarkDownContainer extends Container {
       isIndentSizeForced: markdownParams.isIndentSizeForced,
       isEnabledXss: markdownParams.isEnabledXss,
       xssOption: markdownParams.xssOption,
-      tagWhiteList: markdownParams.tagWhiteList || '',
-      attrWhiteList: markdownParams.attrWhiteList || '',
+      tagWhitelist: markdownParams.tagWhitelist || '',
+      attrWhitelist: markdownParams.attrWhitelist || '',
     });
   }
 
@@ -101,14 +101,14 @@ export default class AdminMarkDownContainer extends Container {
    * Update Xss Setting
    */
   async updateXssSetting() {
-    let { tagWhiteList } = this.state;
-    const { attrWhiteList } = this.state;
+    let { tagWhitelist } = this.state;
+    const { attrWhitelist } = this.state;
 
-    tagWhiteList = Array.isArray(tagWhiteList) ? tagWhiteList : tagWhiteList.split(',');
+    tagWhitelist = Array.isArray(tagWhitelist) ? tagWhitelist : tagWhitelist.split(',');
 
     try {
       // Check if parsing is possible
-      JSON.parse(attrWhiteList);
+      JSON.parse(attrWhitelist);
     }
     catch (err) {
       throw Error(err);
@@ -117,8 +117,8 @@ export default class AdminMarkDownContainer extends Container {
     await apiv3Put('/markdown-setting/xss', {
       isEnabledXss: this.state.isEnabledXss,
       xssOption: this.state.xssOption,
-      tagWhiteList,
-      attrWhiteList: attrWhiteList ?? '{}',
+      tagWhitelist,
+      attrWhitelist: attrWhitelist ?? '{}',
     });
   }
 

+ 6 - 6
apps/app/src/client/services/AdminUsersContainer.js

@@ -205,26 +205,26 @@ export default class AdminUsersContainer extends Container {
   }
 
   /**
-   * Give user admin
+   * Grant user admin
    * @memberOf AdminUsersContainer
    * @param {string} userId
    * @return {string} username
    */
-  async giveUserAdmin(userId) {
-    const response = await apiv3Put(`/users/${userId}/giveAdmin`);
+  async grantUserAdmin(userId) {
+    const response = await apiv3Put(`/users/${userId}/grant-admin`);
     const { username } = response.data.userData;
     await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;
   }
 
   /**
-   * Remove user admin
+   * Revoke user admin
    * @memberOf AdminUsersContainer
    * @param {string} userId
    * @return {string} username
    */
-  async removeUserAdmin(userId) {
-    const response = await apiv3Put(`/users/${userId}/removeAdmin`);
+  async revokeUserAdmin(userId) {
+    const response = await apiv3Put(`/users/${userId}/revoke-admin`);
     const { username } = response.data.userData;
     await this.retrieveUsersByPagingNum(this.state.activePage);
     return username;

+ 1 - 1
apps/app/src/components/Admin/AdminHome/AdminHome.jsx

@@ -14,7 +14,7 @@ import loggerFactory from '~/utils/logger';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
-import EnvVarsTable from './EnvVarsTable';
+import { EnvVarsTable } from './EnvVarsTable';
 import SystemInfomationTable from './SystemInfomationTable';
 
 const logger = loggerFactory('growi:admin');

+ 6 - 10
apps/app/src/components/Admin/AdminHome/EnvVarsTable.jsx → apps/app/src/components/Admin/AdminHome/EnvVarsTable.tsx

@@ -1,8 +1,11 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 
-const EnvVarsTable = (props) => {
-  const envVarRows = [];
+type EnvVarsTableProps = {
+  envVars: Record<string, string | number | boolean>,
+}
+
+export const EnvVarsTable: React.FC<EnvVarsTableProps> = (props: EnvVarsTableProps) => {
+  const envVarRows: JSX.Element[] = [];
 
   for (const [key, value] of Object.entries(props.envVars)) {
     if (value != null) {
@@ -22,11 +25,4 @@ const EnvVarsTable = (props) => {
       </tbody>
     </table>
   );
-
 };
-
-EnvVarsTable.propTypes = {
-  envVars: PropTypes.object.isRequired,
-};
-
-export default EnvVarsTable;

+ 17 - 17
apps/app/src/components/Admin/MarkdownSetting/WhiteListInput.jsx → apps/app/src/components/Admin/MarkdownSetting/WhitelistInput.jsx

@@ -8,13 +8,13 @@ import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-class WhiteListInput extends React.Component {
+class WhitelistInput extends React.Component {
 
   constructor(props) {
     super(props);
 
-    this.tagWhiteList = React.createRef();
-    this.attrWhiteList = React.createRef();
+    this.tagWhitelist = React.createRef();
+    this.attrWhitelist = React.createRef();
 
     this.tags = sanitizeDefaultSchema.tagNames;
     this.attrs = JSON.stringify(sanitizeDefaultSchema.attributes);
@@ -24,13 +24,13 @@ class WhiteListInput extends React.Component {
   }
 
   onClickRecommendTagButton() {
-    this.tagWhiteList.current.value = this.tags;
-    this.props.adminMarkDownContainer.setState({ tagWhiteList: this.tags });
+    this.tagWhitelist.current.value = this.tags;
+    this.props.adminMarkDownContainer.setState({ tagWhitelist: this.tags });
   }
 
   onClickRecommendAttrButton() {
-    this.attrWhiteList.current.value = this.attrs;
-    this.props.adminMarkDownContainer.setState({ attrWhiteList: this.attrs });
+    this.attrWhitelist.current.value = this.attrs;
+    this.props.adminMarkDownContainer.setState({ attrWhitelist: this.attrs });
   }
 
   render() {
@@ -50,9 +50,9 @@ class WhiteListInput extends React.Component {
             name="recommendedTags"
             rows="6"
             cols="40"
-            ref={this.tagWhiteList}
-            defaultValue={adminMarkDownContainer.state.tagWhiteList}
-            onChange={(e) => { adminMarkDownContainer.setState({ tagWhiteList: e.target.value }) }}
+            ref={this.tagWhitelist}
+            defaultValue={adminMarkDownContainer.state.tagWhitelist}
+            onChange={(e) => { adminMarkDownContainer.setState({ tagWhitelist: e.target.value }) }}
           />
         </div>
         <div className="mt-4">
@@ -67,9 +67,9 @@ class WhiteListInput extends React.Component {
             name="recommendedAttrs"
             rows="6"
             cols="40"
-            ref={this.attrWhiteList}
-            defaultValue={adminMarkDownContainer.state.attrWhiteList}
-            onChange={(e) => { adminMarkDownContainer.setState({ attrWhiteList: e.target.value }) }}
+            ref={this.attrWhitelist}
+            defaultValue={adminMarkDownContainer.state.attrWhitelist}
+            onChange={(e) => { adminMarkDownContainer.setState({ attrWhitelist: e.target.value }) }}
           />
         </div>
       </>
@@ -79,7 +79,7 @@ class WhiteListInput extends React.Component {
 }
 
 
-WhiteListInput.propTypes = {
+WhitelistInput.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   adminMarkDownContainer: PropTypes.instanceOf(AdminMarkDownContainer).isRequired,
 
@@ -88,9 +88,9 @@ WhiteListInput.propTypes = {
 const PresentationFormWrapperFC = (props) => {
   const { t } = useTranslation('admin');
 
-  return <WhiteListInput t={t} {...props} />;
+  return <WhitelistInput t={t} {...props} />;
 };
 
-const WhiteListWrapper = withUnstatedContainers(PresentationFormWrapperFC, [AdminMarkDownContainer]);
+const WhitelistWrapper = withUnstatedContainers(PresentationFormWrapperFC, [AdminMarkDownContainer]);
 
-export default WhiteListWrapper;
+export default WhitelistWrapper;

+ 2 - 2
apps/app/src/components/Admin/MarkdownSetting/XssForm.jsx

@@ -12,7 +12,7 @@ import loggerFactory from '~/utils/logger';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
-import WhiteListInput from './WhiteListInput';
+import WhitelistInput from './WhitelistInput';
 
 const logger = loggerFactory('growi:importer');
 
@@ -102,7 +102,7 @@ class XssForm extends React.Component {
               />
               <label className="custom-control-label w-100" htmlFor="xssOption2">
                 <p className="font-weight-bold">{t('markdown_settings.xss_options.custom_whitelist')}</p>
-                <WhiteListInput customizable />
+                <WhitelistInput customizable />
               </label>
             </div>
           </div>

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

@@ -146,9 +146,9 @@ class LocalSecuritySettingContents extends React.Component {
                 <textarea
                   className="form-control"
                   type="textarea"
-                  name="registrationWhiteList"
-                  defaultValue={adminLocalSecurityContainer.state.registrationWhiteList.join('\n')}
-                  onChange={e => adminLocalSecurityContainer.changeRegistrationWhiteList(e.target.value)}
+                  name="registrationWhitelist"
+                  defaultValue={adminLocalSecurityContainer.state.registrationWhitelist.join('\n')}
+                  onChange={e => adminLocalSecurityContainer.changeRegistrationWhitelist(e.target.value)}
                 />
                 <p className="form-text text-muted small">
                   {t('security_settings.restrict_emails')}

+ 9 - 9
apps/app/src/components/Admin/Users/GiveAdminButton.tsx → apps/app/src/components/Admin/Users/GrantAdminButton.tsx

@@ -8,20 +8,20 @@ import { toastSuccess, toastError } from '~/client/util/toastr';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-type GiveAdminButtonProps = {
+type GrantAdminButtonProps = {
   adminUsersContainer: AdminUsersContainer,
   user: IUserHasId,
 }
 
-const GiveAdminButton = (props: GiveAdminButtonProps): JSX.Element => {
+const GrantAdminButton = (props: GrantAdminButtonProps): JSX.Element => {
 
   const { t } = useTranslation('admin');
   const { adminUsersContainer, user } = props;
 
-  const onClickGiveAdminBtnHandler = useCallback(async() => {
+  const onClickGrantAdminBtnHandler = useCallback(async() => {
     try {
-      const username = await adminUsersContainer.giveUserAdmin(user._id);
-      toastSuccess(t('toaster.give_user_admin', { username }));
+      const username = await adminUsersContainer.grantUserAdmin(user._id);
+      toastSuccess(t('toaster.grant_user_admin', { username }));
     }
     catch (err) {
       toastError(err);
@@ -29,8 +29,8 @@ const GiveAdminButton = (props: GiveAdminButtonProps): JSX.Element => {
   }, [adminUsersContainer, t, user._id]);
 
   return (
-    <button className="dropdown-item" type="button" onClick={() => onClickGiveAdminBtnHandler()}>
-      <i className="icon-fw icon-user-following"></i> {t('user_management.user_table.give_admin_access')}
+    <button className="dropdown-item" type="button" onClick={() => onClickGrantAdminBtnHandler()}>
+      <i className="icon-fw icon-user-following"></i> {t('user_management.user_table.grant_admin_access')}
     </button>
   );
 
@@ -40,6 +40,6 @@ const GiveAdminButton = (props: GiveAdminButtonProps): JSX.Element => {
  * Wrapper component for using unstated
  */
 // eslint-disable-next-line max-len
-const GiveAdminButtonWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(GiveAdminButton, [AdminUsersContainer]);
+const GrantAdminButtonWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(GrantAdminButton, [AdminUsersContainer]);
 
-export default GiveAdminButtonWrapper;
+export default GrantAdminButtonWrapper;

+ 15 - 15
apps/app/src/components/Admin/Users/RemoveAdminButton.tsx → apps/app/src/components/Admin/Users/RevokeAdminButton.tsx

@@ -9,40 +9,40 @@ import { useCurrentUser } from '~/stores/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-type RemoveAdminButtonProps = {
+type RevokeAdminButtonProps = {
   adminUsersContainer: AdminUsersContainer,
   user: IUserHasId,
 }
 
-const RemoveAdminButton = (props: RemoveAdminButtonProps): JSX.Element => {
+const RevokeAdminButton = (props: RevokeAdminButtonProps): JSX.Element => {
 
   const { t } = useTranslation('admin');
   const { data: currentUser } = useCurrentUser();
   const { adminUsersContainer, user } = props;
 
-  const onClickRemoveAdminBtnHandler = useCallback(async() => {
+  const onClickRevokeAdminBtnHandler = useCallback(async() => {
     try {
-      const username = await adminUsersContainer.removeUserAdmin(user._id);
-      toastSuccess(t('toaster.remove_user_admin', { username }));
+      const username = await adminUsersContainer.revokeUserAdmin(user._id);
+      toastSuccess(t('toaster.revoke_user_admin', { username }));
     }
     catch (err) {
       toastError(err);
     }
   }, [adminUsersContainer, t, user._id]);
 
-  const renderRemoveAdminBtn = () => {
+  const renderRevokeAdminBtn = () => {
     return (
-      <button className="dropdown-item" type="button" onClick={() => onClickRemoveAdminBtnHandler()}>
-        <i className="icon-fw icon-user-unfollow"></i>{t('user_management.user_table.remove_admin_access')}
+      <button className="dropdown-item" type="button" onClick={() => onClickRevokeAdminBtnHandler()}>
+        <i className="icon-fw icon-user-unfollow"></i>{t('user_management.user_table.revoke_admin_access')}
       </button>
     );
   };
 
-  const renderRemoveAdminAlert = () => {
+  const renderRevokeAdminAlert = () => {
     return (
       <div className="px-4">
-        <i className="icon-fw icon-user-unfollow mb-2"></i>{t('user_management.user_table.remove_admin_access')}
-        <p className="alert alert-danger">{t('user_management.user_table.cannot_remove')}</p>
+        <i className="icon-fw icon-user-unfollow mb-2"></i>{t('user_management.user_table.revoke_admin_access')}
+        <p className="alert alert-danger">{t('user_management.user_table.cannot_revoke')}</p>
       </div>
     );
   };
@@ -53,8 +53,8 @@ const RemoveAdminButton = (props: RemoveAdminButtonProps): JSX.Element => {
 
   return (
     <>
-      {user.username !== currentUser.username ? renderRemoveAdminBtn()
-        : renderRemoveAdminAlert()}
+      {user.username !== currentUser.username ? renderRevokeAdminBtn()
+        : renderRevokeAdminAlert()}
     </>
   );
 };
@@ -62,6 +62,6 @@ const RemoveAdminButton = (props: RemoveAdminButtonProps): JSX.Element => {
 /**
 * Wrapper component for using unstated
 */
-const RemoveAdminButtonWrapper = withUnstatedContainers(RemoveAdminButton, [AdminUsersContainer]);
+const RevokeAdminButtonWrapper = withUnstatedContainers(RevokeAdminButton, [AdminUsersContainer]);
 
-export default RemoveAdminButtonWrapper;
+export default RevokeAdminButtonWrapper;

+ 13 - 13
apps/app/src/components/Admin/Users/RemoveAdminMenuItem.tsx → apps/app/src/components/Admin/Users/RevokeAdminMenuItem.tsx

@@ -10,17 +10,17 @@ import { useCurrentUser } from '~/stores/context';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
-const RemoveAdminAlert = React.memo((): JSX.Element => {
+const RevokeAdminAlert = React.memo((): JSX.Element => {
   const { t } = useTranslation();
 
   return (
     <div className="px-4">
-      <i className="icon-fw icon-user-unfollow mb-2"></i>{t('admin:user_management.user_table.remove_admin_access')}
-      <p className="alert alert-danger">{t('admin:user_management.user_table.cannot_remove')}</p>
+      <i className="icon-fw icon-user-unfollow mb-2"></i>{t('admin:user_management.user_table.revoke_admin_access')}
+      <p className="alert alert-danger">{t('admin:user_management.user_table.cannot_revoke')}</p>
     </div>
   );
 });
-RemoveAdminAlert.displayName = 'RemoveAdminAlert';
+RevokeAdminAlert.displayName = 'RevokeAdminAlert';
 
 
 type Props = {
@@ -28,17 +28,17 @@ type Props = {
   user: IUserHasId,
 }
 
-const RemoveAdminMenuItem = (props: Props): JSX.Element => {
+const RevokeAdminMenuItem = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
 
   const { adminUsersContainer, user } = props;
 
   const { data: currentUser } = useCurrentUser();
 
-  const clickRemoveAdminBtnHandler = useCallback(async() => {
+  const clickRevokeAdminBtnHandler = useCallback(async() => {
     try {
-      const username = await adminUsersContainer.removeUserAdmin(user._id);
-      toastSuccess(t('toaster.remove_user_admin', { username }));
+      const username = await adminUsersContainer.revokeUserAdmin(user._id);
+      toastSuccess(t('toaster.revoke_user_admin', { username }));
     }
     catch (err) {
       toastError(err);
@@ -48,17 +48,17 @@ const RemoveAdminMenuItem = (props: Props): JSX.Element => {
 
   return user.username !== currentUser?.username
     ? (
-      <button className="dropdown-item" type="button" onClick={clickRemoveAdminBtnHandler}>
-        <i className="icon-fw icon-user-unfollow"></i> {t('user_management.user_table.remove_admin_access')}
+      <button className="dropdown-item" type="button" onClick={clickRevokeAdminBtnHandler}>
+        <i className="icon-fw icon-user-unfollow"></i> {t('user_management.user_table.revoke_admin_access')}
       </button>
     )
-    : <RemoveAdminAlert />;
+    : <RevokeAdminAlert />;
 };
 
 /**
 * Wrapper component for using unstated
 */
 // eslint-disable-next-line max-len
-const RemoveAdminMenuItemWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(RemoveAdminMenuItem, [AdminUsersContainer]);
+const RevokeAdminMenuItemWrapper: React.ForwardRefExoticComponent<Pick<any, string | number | symbol> & React.RefAttributes<any>> = withUnstatedContainers(RevokeAdminMenuItem, [AdminUsersContainer]);
 
-export default RemoveAdminMenuItemWrapper;
+export default RevokeAdminMenuItemWrapper;

+ 3 - 3
apps/app/src/components/Admin/Users/UserMenu.tsx

@@ -10,9 +10,9 @@ import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-import GiveAdminButton from './GiveAdminButton';
+import GrantAdminButton from './GrantAdminButton';
 import GrantReadOnlyButton from './GrantReadOnlyButton';
-import RemoveAdminMenuItem from './RemoveAdminMenuItem';
+import RevokeAdminMenuItem from './RevokeAdminMenuItem';
 import RevokeReadOnlyMenuItem from './RevokeReadOnlyMenuItem';
 import SendInvitationEmailButton from './SendInvitationEmailButton';
 import StatusActivateButton from './StatusActivateButton';
@@ -83,7 +83,7 @@ const UserMenu = (props: UserMenuProps) => {
         <li className="dropdown-divider pl-0"></li>
         <li className="dropdown-header">{t('user_management.user_table.administrator_menu')}</li>
         <li>
-          {user.admin ? <RemoveAdminMenuItem user={user} /> : <GiveAdminButton user={user} />}
+          {user.admin ? <RevokeAdminMenuItem user={user} /> : <GrantAdminButton user={user} />}
         </li>
         <li>
           {user.readOnly ? <RevokeReadOnlyMenuItem user={user} /> : <GrantReadOnlyButton user={user} />}

+ 7 - 9
apps/app/src/components/DuplicatedPathsTable.jsx → apps/app/src/components/DuplicatedPathsTable.tsx

@@ -2,12 +2,17 @@ import React from 'react';
 
 import { pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
 
 
 const { convertToNewAffiliationPath } = pagePathUtils;
 
-function DuplicatedPathsTable(props) {
+type DuplicatedPathsTableProps = {
+  existingPaths: string[],
+  fromPath: string,
+  toPath: string
+}
+
+const DuplicatedPathsTable: React.FC<DuplicatedPathsTableProps> = (props: DuplicatedPathsTableProps) => {
   const { t } = useTranslation();
 
   const {
@@ -41,13 +46,6 @@ function DuplicatedPathsTable(props) {
       </tbody>
     </table>
   );
-}
-
-
-DuplicatedPathsTable.propTypes = {
-  existingPaths: PropTypes.array.isRequired,
-  fromPath: PropTypes.string.isRequired,
-  toPath: PropTypes.string.isRequired,
 };
 
 

+ 5 - 5
apps/app/src/components/LoginForm.tsx

@@ -25,7 +25,7 @@ type LoginFormProps = {
   email?: string,
   isEmailAuthenticationEnabled: boolean,
   registrationMode: RegistrationMode,
-  registrationWhiteList: string[],
+  registrationWhitelist: string[],
   isPasswordResetEnabled: boolean,
   isLocalStrategySetup: boolean,
   isLdapStrategySetup: boolean,
@@ -41,7 +41,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
   const {
     isLocalStrategySetup, isLdapStrategySetup, isLdapSetupFailed, isPasswordResetEnabled,
-    isEmailAuthenticationEnabled, registrationMode, registrationWhiteList, isMailerSetup, objOfIsExternalAuthEnableds,
+    isEmailAuthenticationEnabled, registrationMode, registrationWhitelist, isMailerSetup, objOfIsExternalAuthEnableds,
   } = props;
   const isLocalOrLdapStrategiesEnabled = isLocalStrategySetup || isLdapStrategySetup;
   const isSomeExternalAuthEnabled = Object.values(objOfIsExternalAuthEnableds).some(elem => elem);
@@ -439,11 +439,11 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
             />
           </div>
 
-          {registrationWhiteList.length > 0 && (
+          {registrationWhitelist.length > 0 && (
             <>
               <p className="form-text">{t('page_register.form_help.email')}</p>
               <ul>
-                {registrationWhiteList.map((elem) => {
+                {registrationWhitelist.map((elem) => {
                   return (
                     <li key={elem}>
                       <code>{elem}</code>
@@ -503,7 +503,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     );
   }, [
     t, isEmailAuthenticationEnabled, registrationMode, isMailerSetup, registerErrors, isSuccessToRagistration,
-    emailForRegistrationOrder, props.username, props.name, props.email, registrationWhiteList, switchForm, handleRegisterFormSubmit,
+    emailForRegistrationOrder, props.username, props.name, props.email, registrationWhitelist, switchForm, handleRegisterFormSubmit,
   ]);
 
   if (registrationMode === RegistrationMode.RESTRICTED && isSuccessToRagistration && !isEmailAuthenticationEnabled) {

+ 4 - 4
apps/app/src/components/Me/BasicInfoSettings.tsx

@@ -5,12 +5,12 @@ import { useTranslation, i18n } from 'next-i18next';
 import { i18n as i18nConfig } from '^/config/next-i18next.config';
 
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useRegistrationWhiteList } from '~/stores/context';
+import { useRegistrationWhitelist } from '~/stores/context';
 import { usePersonalSettings } from '~/stores/personal-settings';
 
 export const BasicInfoSettings = (): JSX.Element => {
   const { t } = useTranslation();
-  const { data: registrationWhiteList } = useRegistrationWhiteList();
+  const { data: registrationWhitelist } = useRegistrationWhitelist();
 
   const {
     data: personalSettingsInfo, mutate: mutatePersonalSettings, sync, updateBasicInfo, error,
@@ -63,11 +63,11 @@ export const BasicInfoSettings = (): JSX.Element => {
             defaultValue={personalSettingsInfo?.email || ''}
             onChange={e => changePersonalSettingsHandler({ email: e.target.value })}
           />
-          {registrationWhiteList != null && registrationWhiteList.length !== 0 && (
+          {registrationWhitelist != null && registrationWhitelist.length !== 0 && (
             <div className="form-text text-muted">
               {t('page_register.form_help.email')}
               <ul>
-                {registrationWhiteList.map(data => <li key={data}><code>{data}</code></li>)}
+                {registrationWhitelist.map(data => <li key={data}><code>{data}</code></li>)}
               </ul>
             </div>
           )}

+ 21 - 13
apps/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -9,7 +9,9 @@ import { throttle, debounce } from 'throttle-debounce';
 import urljoin from 'url-join';
 
 import InterceptorManager from '~/services/interceptor-manager';
-import { useHandsontableModal, useDrawioModal, useTemplateModal } from '~/stores/modal';
+import {
+  useHandsontableModal, useDrawioModal, useTemplateModal, useLinkEditModal,
+} from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
@@ -22,7 +24,6 @@ import EmojiPickerHelper from './EmojiPickerHelper';
 import GridEditModal from './GridEditModal';
 // TODO: re-impl with https://redmine.weseek.co.jp/issues/107248
 // import geu from './GridEditorUtil';
-import LinkEditModal from './LinkEditModal';
 import mdu from './MarkdownDrawioUtil';
 import markdownLinkUtil from './MarkdownLinkUtil';
 import markdownListUtil from './MarkdownListUtil';
@@ -149,13 +150,13 @@ class CodeMirrorEditor extends AbstractEditor {
     this.makeHeaderHandler = this.makeHeaderHandler.bind(this);
     // TODO: re-impl with https://redmine.weseek.co.jp/issues/107248
     // this.showGridEditorHandler = this.showGridEditorHandler.bind(this);
-    this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
 
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
     this.clickDrawioIconHandler = this.clickDrawioIconHandler.bind(this);
     this.clickTableIconHandler = this.clickTableIconHandler.bind(this);
 
     this.showTemplateModal = this.showTemplateModal.bind(this);
+    this.showLinkEditModal = this.showLinkEditModal.bind(this);
 
   }
 
@@ -846,15 +847,21 @@ class CodeMirrorEditor extends AbstractEditor {
   //   this.gridEditModal.current.show(geu.getGridHtml(this.getCodeMirror()));
   // }
 
-  showLinkEditHandler() {
-    this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
-  }
-
   showTemplateModal() {
     const onSubmit = templateText => this.setValue(templateText);
     this.props.onClickTemplateBtn(onSubmit);
   }
 
+  showLinkEditModal() {
+    const onSubmit = (linkText) => {
+      return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText);
+    };
+
+    const defaultMarkdownLink = markdownLinkUtil.getMarkdownLink(this.getCodeMirror());
+
+    this.props.onClickLinkEditBtn(defaultMarkdownLink, onSubmit);
+  }
+
   // fold draw.io section (``` drawio ~ ```)
   foldDrawioSection() {
     const editor = this.getCodeMirror();
@@ -985,7 +992,7 @@ class CodeMirrorEditor extends AbstractEditor {
         color={null}
         size="sm"
         title="Link"
-        onClick={this.showLinkEditHandler}
+        onClick={this.showLinkEditModal}
       >
         <EditorIcon icon="Link" />
       </Button>,
@@ -1125,11 +1132,6 @@ class CodeMirrorEditor extends AbstractEditor {
           onSave={(grid) => { return geu.replaceGridWithHtmlWithEditor(this.getCodeMirror(), grid) }}
         />
          */}
-
-        <LinkEditModal
-          ref={this.linkEditModal}
-          onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
-        />
       </div>
     );
   }
@@ -1154,6 +1156,7 @@ const CodeMirrorEditorFc = React.forwardRef((props, ref) => {
   const { open: openDrawioModal } = useDrawioModal();
   const { open: openHandsontableModal } = useHandsontableModal();
   const { open: openTemplateModal } = useTemplateModal();
+  const { open: openLinkEditModal } = useLinkEditModal();
 
   const openDrawioModalHandler = useCallback((drawioMxFile, onSave) => {
     openDrawioModal(drawioMxFile, onSave);
@@ -1167,12 +1170,17 @@ const CodeMirrorEditorFc = React.forwardRef((props, ref) => {
     openTemplateModal(onSubmit);
   }, [openTemplateModal]);
 
+  const openLinkEditModalHandler = useCallback((defaultMarkdownLink, onSubmit) => {
+    openLinkEditModal(defaultMarkdownLink, onSubmit);
+  }, [openLinkEditModal]);
+
   return (
     <CodeMirrorEditorMemoized
       ref={ref}
       onClickDrawioBtn={openDrawioModalHandler}
       onClickTableBtn={openTableModalHandler}
       onClickTemplateBtn={openTemplateModalHandler}
+      onClickLinkEditBtn={openLinkEditModalHandler}
       {...props}
     />
   );

+ 0 - 471
apps/app/src/components/PageEditor/LinkEditModal.jsx

@@ -1,471 +0,0 @@
-import React from 'react';
-
-import path from 'path';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import {
-  Modal,
-  ModalHeader,
-  ModalBody,
-  ModalFooter,
-  Popover,
-  PopoverBody,
-} from 'reactstrap';
-import validator from 'validator';
-
-
-import Linker from '~/client/models/Linker';
-import { apiv3Get } from '~/client/util/apiv3-client';
-import { useCurrentPagePath } from '~/stores/page';
-import loggerFactory from '~/utils/logger';
-
-import PagePreviewIcon from '../Icons/PagePreviewIcon';
-import SearchTypeahead from '../SearchTypeahead';
-
-import Preview from './Preview';
-
-
-import styles from './LinkEditPreview.module.scss';
-
-
-const logger = loggerFactory('growi:components:LinkEditModal');
-
-class LinkEditModal extends React.PureComponent {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      show: false,
-      isUseRelativePath: false,
-      isUsePermanentLink: false,
-      linkInputValue: '',
-      labelInputValue: '',
-      linkerType: Linker.types.markdownLink,
-      markdown: null,
-      pagePath: null,
-      previewError: '',
-      permalink: '',
-      isPreviewOpen: false,
-    };
-
-    // this.isApplyPukiwikiLikeLinkerPlugin = window.growiRenderer.preProcessors.some(process => process.constructor.name === 'PukiwikiLikeLinker');
-
-    this.show = this.show.bind(this);
-    this.hide = this.hide.bind(this);
-    this.cancel = this.cancel.bind(this);
-    this.handleChangeTypeahead = this.handleChangeTypeahead.bind(this);
-    this.handleChangeLabelInput = this.handleChangeLabelInput.bind(this);
-    this.handleChangeLinkInput = this.handleChangeLinkInput.bind(this);
-    this.handleSelecteLinkerType = this.handleSelecteLinkerType.bind(this);
-    this.toggleIsUseRelativePath = this.toggleIsUseRelativePath.bind(this);
-    this.toggleIsUsePamanentLink = this.toggleIsUsePamanentLink.bind(this);
-    this.save = this.save.bind(this);
-    this.generateLink = this.generateLink.bind(this);
-    this.getRootPath = this.getRootPath.bind(this);
-    this.toggleIsPreviewOpen = this.toggleIsPreviewOpen.bind(this);
-    this.setMarkdown = this.setMarkdown.bind(this);
-  }
-
-  // defaultMarkdownLink is an instance of Linker
-  show(defaultMarkdownLink = null) {
-    // if defaultMarkdownLink is null, set default value in inputs.
-    const { label = '', link = '' } = defaultMarkdownLink;
-    let { type = Linker.types.markdownLink } = defaultMarkdownLink;
-
-    // if type of defaultMarkdownLink is pukiwikiLink when pukiwikiLikeLinker plugin is disable, change type(not change label and link)
-    if (type === Linker.types.pukiwikiLink && !this.isApplyPukiwikiLikeLinkerPlugin) {
-      type = Linker.types.markdownLink;
-    }
-
-    this.parseLinkAndSetState(link, type);
-
-    this.setState({
-      show: true,
-      labelInputValue: label,
-      isUsePermanentLink: false,
-      permalink: '',
-      linkerType: type,
-    });
-  }
-
-  // parse link, link is ...
-  // case-1. url of this growi's page (ex. 'http://localhost:3000/hoge/fuga')
-  // case-2. absolute path of this growi's page (ex. '/hoge/fuga')
-  // case-3. relative path of this growi's page (ex. '../fuga', 'hoge')
-  // case-4. external link (ex. 'https://growi.org')
-  // case-5. the others (ex. '')
-  parseLinkAndSetState(link, type) {
-    // create url from link, add dummy origin if link is not valid url.
-    // ex-1. link = 'https://growi.org/' -> url = 'https://growi.org/' (case-1,4)
-    // ex-2. link = 'hoge' -> url = 'http://example.com/hoge' (case-2,3,5)
-    let isFqcn = false;
-    let isUseRelativePath = false;
-    let url;
-    try {
-      const url = new URL(link, 'http://example.com');
-      isFqcn = url.origin !== 'http://example.com';
-    }
-    catch (err) {
-      logger.debug(err);
-    }
-
-    // case-1: when link is this growi's page url, return pathname only
-    let reshapedLink = url != null && url.origin === window.location.origin
-      ? decodeURIComponent(url.pathname)
-      : link;
-
-    // case-3
-    if (!isFqcn && !reshapedLink.startsWith('/') && reshapedLink !== '') {
-      isUseRelativePath = true;
-      const rootPath = this.getRootPath(type);
-      reshapedLink = path.resolve(rootPath, reshapedLink);
-    }
-
-    this.setState({
-      linkInputValue: reshapedLink,
-      isUseRelativePath,
-    });
-  }
-
-  cancel() {
-    this.hide();
-  }
-
-  hide() {
-    this.setState({
-      show: false,
-    });
-  }
-
-  toggleIsUseRelativePath() {
-    if (!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink) {
-      return;
-    }
-
-    // User can't use both relativePath and permalink at the same time
-    this.setState({ isUseRelativePath: !this.state.isUseRelativePath, isUsePermanentLink: false });
-  }
-
-  toggleIsUsePamanentLink() {
-    if (this.state.permalink === '' || this.state.linkerType === Linker.types.growiLink) {
-      return;
-    }
-
-    // User can't use both relativePath and permalink at the same time
-    this.setState({ isUsePermanentLink: !this.state.isUsePermanentLink, isUseRelativePath: false });
-  }
-
-  async setMarkdown() {
-    const { t } = this.props;
-    const path = this.state.linkInputValue;
-    let markdown = null;
-    let pagePath = null;
-    let permalink = '';
-    let previewError = '';
-
-    if (path.startsWith('/')) {
-      try {
-        const pathWithoutFragment = new URL(path, 'http://dummy').pathname;
-        const isPermanentLink = validator.isMongoId(pathWithoutFragment.slice(1));
-        const pageId = isPermanentLink ? pathWithoutFragment.slice(1) : null;
-
-        const { data } = await apiv3Get('/page', { path: pathWithoutFragment, page_id: pageId });
-        const { page } = data;
-        markdown = page.revision.body;
-        pagePath = page.path;
-        permalink = page.id;
-      }
-      catch (err) {
-        previewError = err.message;
-      }
-    }
-    else {
-      previewError = t('link_edit.page_not_found_in_preview', { path });
-    }
-    this.setState({
-      markdown, pagePath, previewError, permalink,
-    });
-  }
-
-  renderLinkPreview() {
-    const linker = this.generateLink();
-    return (
-      <div className="d-flex justify-content-between mb-3 flex-column flex-sm-row">
-        <div className="card card-disabled w-100 p-1 mb-0">
-          <p className="text-left text-muted mb-1 small">Markdown</p>
-          <p className="text-center text-truncate text-muted">{linker.generateMarkdownText()}</p>
-        </div>
-        <div className="d-flex align-items-center justify-content-center">
-          <span className="lead mx-3">
-            <i className="d-none d-sm-block fa fa-caret-right"></i>
-            <i className="d-sm-none fa fa-caret-down"></i>
-          </span>
-        </div>
-        <div className="card w-100 p-1 mb-0">
-          <p className="text-left text-muted mb-1 small">HTML</p>
-          <p className="text-center text-truncate">
-            <a href={linker.link}>{linker.label}</a>
-          </p>
-        </div>
-      </div>
-    );
-  }
-
-  handleChangeTypeahead(selected) {
-    const pageWithMeta = selected[0];
-    if (pageWithMeta != null) {
-      const page = pageWithMeta.data;
-      const permalink = `${window.location.origin}/${page.id}`;
-      this.setState({ linkInputValue: page.path, permalink });
-    }
-  }
-
-  handleChangeLabelInput(label) {
-    this.setState({ labelInputValue: label });
-  }
-
-  handleChangeLinkInput(link) {
-    let isUseRelativePath = this.state.isUseRelativePath;
-    if (!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink) {
-      isUseRelativePath = false;
-    }
-    this.setState({
-      linkInputValue: link, isUseRelativePath, isUsePermanentLink: false, permalink: '',
-    });
-  }
-
-  handleSelecteLinkerType(linkerType) {
-    let { isUseRelativePath, isUsePermanentLink } = this.state;
-    if (linkerType === Linker.types.growiLink) {
-      isUseRelativePath = false;
-      isUsePermanentLink = false;
-    }
-    this.setState({ linkerType, isUseRelativePath, isUsePermanentLink });
-  }
-
-  save() {
-    const linker = this.generateLink();
-
-    if (this.props.onSave != null) {
-      this.props.onSave(linker.generateMarkdownText());
-    }
-
-    this.hide();
-  }
-
-  generateLink() {
-    const {
-      linkInputValue, labelInputValue, linkerType, isUseRelativePath, isUsePermanentLink, permalink,
-    } = this.state;
-
-    let reshapedLink = linkInputValue;
-    if (isUseRelativePath) {
-      const rootPath = this.getRootPath(linkerType);
-      reshapedLink = rootPath === linkInputValue ? '.' : path.relative(rootPath, linkInputValue);
-    }
-
-    if (isUsePermanentLink && permalink != null) {
-      reshapedLink = permalink;
-    }
-
-    return new Linker(linkerType, labelInputValue, reshapedLink);
-  }
-
-  getRootPath(type) {
-    const { pagePath } = this.props;
-    // rootPaths of md link and pukiwiki link are different
-    return type === Linker.types.markdownLink ? path.dirname(pagePath) : pagePath;
-  }
-
-  async toggleIsPreviewOpen() {
-    // open popover
-    if (this.state.isPreviewOpen === false) {
-      this.setMarkdown();
-    }
-    this.setState({ isPreviewOpen: !this.state.isPreviewOpen });
-  }
-
-  renderLinkAndLabelForm() {
-    const { t } = this.props;
-    const { pagePath } = this.state;
-
-    return (
-      <>
-        <h3 className="grw-modal-head">{t('link_edit.set_link_and_label')}</h3>
-        <form className="form-group">
-          <div className="form-gorup my-3">
-            <div className="input-group flex-nowrap">
-              <div className="input-group-prepend">
-                <span className="input-group-text">{t('link_edit.link')}</span>
-              </div>
-              <SearchTypeahead
-                onChange={this.handleChangeTypeahead}
-                onInputChange={this.handleChangeLinkInput}
-                inputName="link"
-                placeholder={t('link_edit.placeholder_of_link_input')}
-                keywordOnInit={this.state.linkInputValue}
-                autoFocus
-              />
-              <div className="d-none d-sm-block input-group-append">
-                <button type="button" id="preview-btn" className={`btn btn-info btn-page-preview ${styles['btn-page-preview']}`}>
-                  <PagePreviewIcon />
-                </button>
-                <Popover trigger="focus" placement="right" isOpen={this.state.isPreviewOpen} target="preview-btn" toggle={this.toggleIsPreviewOpen}>
-                  <PopoverBody>
-                    {this.state.markdown != null && pagePath != null
-                    && <div className={`linkedit-preview ${styles['linkedit-preview']}`}>
-                      <Preview markdown={this.state.markdown} pagePath={pagePath} />
-                    </div>
-                    }
-                  </PopoverBody>
-                </Popover>
-              </div>
-            </div>
-          </div>
-          <div className="form-gorup my-3">
-            <div className="input-group flex-nowrap">
-              <div className="input-group-prepend">
-                <span className="input-group-text">{t('link_edit.label')}</span>
-              </div>
-              <input
-                type="text"
-                className="form-control"
-                id="label"
-                value={this.state.labelInputValue}
-                onChange={e => this.handleChangeLabelInput(e.target.value)}
-                disabled={this.state.linkerType === Linker.types.growiLink}
-                placeholder={this.state.linkInputValue}
-              />
-            </div>
-          </div>
-        </form>
-      </>
-    );
-  }
-
-  renderPathFormatForm() {
-    const { t } = this.props;
-    return (
-      <div className="card well pt-3">
-        <form className="form-group mb-0">
-          <div className="form-group mb-0 row">
-            <label className="col-sm-3">{t('link_edit.path_format')}</label>
-            <div className="col-sm-9">
-              <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
-                <input
-                  className="custom-control-input"
-                  id="relativePath"
-                  type="checkbox"
-                  checked={this.state.isUseRelativePath}
-                  onChange={this.toggleIsUseRelativePath}
-                  disabled={!this.state.linkInputValue.startsWith('/') || this.state.linkerType === Linker.types.growiLink}
-                />
-                <label className="custom-control-label" htmlFor="relativePath">
-                  {t('link_edit.use_relative_path')}
-                </label>
-              </div>
-              <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
-                <input
-                  className="custom-control-input"
-                  id="permanentLink"
-                  type="checkbox"
-                  checked={this.state.isUsePermanentLink}
-                  onChange={this.toggleIsUsePamanentLink}
-                  disabled={this.state.permalink === '' || this.state.linkerType === Linker.types.growiLink}
-                />
-                <label className="custom-control-label" htmlFor="permanentLink">
-                  {t('link_edit.use_permanent_link')}
-                </label>
-              </div>
-            </div>
-          </div>
-          {this.isApplyPukiwikiLikeLinkerPlugin && (
-            <div className="form-group row mb-0 mt-1">
-              <label className="col-sm-3">{t('link_edit.notation')}</label>
-              <div className="col-sm-9">
-                <div className="custom-control custom-radio custom-control-inline">
-                  <input
-                    type="radio"
-                    className="custom-control-input"
-                    id="markdownType"
-                    value={Linker.types.markdownLink}
-                    checked={this.state.linkerType === Linker.types.markdownLink}
-                    onChange={e => this.handleSelecteLinkerType(e.target.value)}
-                  />
-                  <label className="custom-control-label" htmlFor="markdownType">
-                    {t('link_edit.markdown')}
-                  </label>
-                </div>
-                <div className="custom-control custom-radio custom-control-inline">
-                  <input
-                    type="radio"
-                    className="custom-control-input"
-                    id="pukiwikiType"
-                    value={Linker.types.pukiwikiLink}
-                    checked={this.state.linkerType === Linker.types.pukiwikiLink}
-                    onChange={e => this.handleSelecteLinkerType(e.target.value)}
-                  />
-                  <label className="custom-control-label" htmlFor="pukiwikiType">
-                    {t('link_edit.pukiwiki')}
-                  </label>
-                </div>
-              </div>
-            </div>
-          )}
-        </form>
-      </div>
-    );
-  }
-
-  render() {
-    const { t } = this.props;
-    return (
-      <Modal className="link-edit-modal" isOpen={this.state.show} toggle={this.cancel} size="lg" autoFocus={false}>
-        <ModalHeader tag="h4" toggle={this.cancel} className="bg-primary text-light">
-          {t('link_edit.edit_link')}
-        </ModalHeader>
-
-        <ModalBody className="container">
-          <div className="row">
-            <div className="col-12">
-              {this.renderLinkAndLabelForm()}
-              {this.renderPathFormatForm()}
-            </div>
-          </div>
-          <div className="row">
-            <div className="col-12">
-              <h3 className="grw-modal-head">{t('link_edit.preview')}</h3>
-              {this.renderLinkPreview()}
-            </div>
-          </div>
-        </ModalBody>
-        <ModalFooter>
-          <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={this.hide}>
-            {t('Cancel')}
-          </button>
-          <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={this.save}>
-            {t('Done')}
-          </button>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-}
-
-const LinkEditModalFc = React.memo(React.forwardRef((props, ref) => {
-  const { t } = useTranslation();
-  const { data: currentPath } = useCurrentPagePath();
-  return <LinkEditModal t={t} ref={ref} pagePath={currentPath} {...props} />;
-}));
-
-LinkEditModal.propTypes = {
-  t: PropTypes.func.isRequired,
-  pagePath: PropTypes.string,
-  onSave: PropTypes.func,
-};
-
-
-export default LinkEditModalFc;

+ 372 - 0
apps/app/src/components/PageEditor/LinkEditModal.tsx

@@ -0,0 +1,372 @@
+import React, { useEffect, useState, useCallback } from 'react';
+
+import path from 'path';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+  Popover,
+  PopoverBody,
+} from 'reactstrap';
+import validator from 'validator';
+
+
+import Linker from '~/client/models/Linker';
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { useLinkEditModal } from '~/stores/modal';
+import { useCurrentPagePath } from '~/stores/page';
+import { usePreviewOptions } from '~/stores/renderer';
+import loggerFactory from '~/utils/logger';
+
+import PagePreviewIcon from '../Icons/PagePreviewIcon';
+import SearchTypeahead from '../SearchTypeahead';
+
+import Preview from './Preview';
+
+
+import styles from './LinkEditPreview.module.scss';
+
+
+const logger = loggerFactory('growi:components:LinkEditModal');
+
+export const LinkEditModal = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: currentPath } = useCurrentPagePath();
+  const { data: rendererOptions } = usePreviewOptions();
+  const { data: linkEditModalStatus, close } = useLinkEditModal();
+
+  const [isUseRelativePath, setIsUseRelativePath] = useState<boolean>(false);
+  const [isUsePermanentLink, setIsUsePermanentLink] = useState<boolean>(false);
+  const [linkInputValue, setLinkInputValue] = useState<string>('');
+  const [labelInputValue, setLabelInputValue] = useState<string>('');
+  const [linkerType, setLinkerType] = useState<string>('');
+  const [markdown, setMarkdown] = useState<string>('');
+  const [pagePath, setPagePath] = useState<string>('');
+  const [previewError, setPreviewError] = useState<string>();
+  const [permalink, setPermalink] = useState<string>('');
+  const [isPreviewOpen, setIsPreviewOpen] = useState<boolean>(false);
+
+  const getRootPath = useCallback((type: string) => {
+    // rootPaths of md link and pukiwiki link are different
+    if (currentPath == null) return '';
+    return type === Linker.types.markdownLink ? path.dirname(currentPath) : currentPath;
+  }, [currentPath]);
+
+  // parse link, link is ...
+  // case-1. url of this growi's page (ex. 'http://localhost:3000/hoge/fuga')
+  // case-2. absolute path of this growi's page (ex. '/hoge/fuga')
+  // case-3. relative path of this growi's page (ex. '../fuga', 'hoge')
+  // case-4. external link (ex. 'https://growi.org')
+  // case-5. the others (ex. '')
+  const parseLinkAndSetState = useCallback((link: string, type: string) => {
+    // create url from link, add dummy origin if link is not valid url.
+    // ex-1. link = 'https://growi.org/' -> url = 'https://growi.org/' (case-1,4)
+    // ex-2. link = 'hoge' -> url = 'http://example.com/hoge' (case-2,3,5)
+    let isFqcn = false;
+    let isUseRelativePath = false;
+    let url;
+    try {
+      const url = new URL(link, 'http://example.com');
+      isFqcn = url.origin !== 'http://example.com';
+    }
+    catch (err) {
+      logger.debug(err);
+    }
+
+    // case-1: when link is this growi's page url, return pathname only
+    let reshapedLink = url != null && url.origin === window.location.origin
+      ? decodeURIComponent(url.pathname)
+      : link;
+
+    // case-3
+    if (!isFqcn && !reshapedLink.startsWith('/') && reshapedLink !== '') {
+      isUseRelativePath = true;
+      const rootPath = getRootPath(type);
+      reshapedLink = path.resolve(rootPath, reshapedLink);
+    }
+
+    setLinkInputValue(reshapedLink);
+    setIsUseRelativePath(isUseRelativePath);
+  }, [getRootPath]);
+
+  useEffect(() => {
+    if (linkEditModalStatus == null) { return }
+    const { label = '', link = '' } = linkEditModalStatus.defaultMarkdownLink ?? {};
+    const { type = Linker.types.markdownLink } = linkEditModalStatus.defaultMarkdownLink ?? {};
+
+    parseLinkAndSetState(link, type);
+    setLabelInputValue(label);
+    setIsUsePermanentLink(false);
+    setPermalink('');
+    setLinkerType(type);
+
+  }, [linkEditModalStatus, parseLinkAndSetState]);
+
+  const toggleIsUseRelativePath = () => {
+    if (!linkInputValue.startsWith('/') || linkerType === Linker.types.growiLink) {
+      return;
+    }
+
+    // User can't use both relativePath and permalink at the same time
+    setIsUseRelativePath(!isUseRelativePath);
+    setIsUsePermanentLink(false);
+  };
+
+  const toggleIsUsePamanentLink = () => {
+    if (permalink === '' || linkerType === Linker.types.growiLink) {
+      return;
+    }
+
+    // User can't use both relativePath and permalink at the same time
+    setIsUsePermanentLink(!isUsePermanentLink);
+    setIsUseRelativePath(false);
+  };
+
+  const setMarkdownHandler = async() => {
+    const path = linkInputValue;
+    let markdown = '';
+    let pagePath = '';
+    let permalink = '';
+
+    if (path.startsWith('/')) {
+      try {
+        const pathWithoutFragment = new URL(path, 'http://dummy').pathname;
+        const isPermanentLink = validator.isMongoId(pathWithoutFragment.slice(1));
+        const pageId = isPermanentLink ? pathWithoutFragment.slice(1) : null;
+
+        const { data } = await apiv3Get('/page', { path: pathWithoutFragment, page_id: pageId });
+        const { page } = data;
+        markdown = page.revision.body;
+        pagePath = page.path;
+        permalink = page.id;
+      }
+      catch (err) {
+        setPreviewError(err.message);
+      }
+    }
+    else {
+      setPreviewError(t('link_edit.page_not_found_in_preview', { path }));
+    }
+
+    setMarkdown(markdown);
+    setPagePath(pagePath);
+    setPermalink(permalink);
+  };
+
+  const generateLink = () => {
+
+    let reshapedLink = linkInputValue;
+    if (isUseRelativePath) {
+      const rootPath = getRootPath(linkerType);
+      reshapedLink = rootPath === linkInputValue ? '.' : path.relative(rootPath, linkInputValue);
+    }
+
+    if (isUsePermanentLink && permalink != null) {
+      reshapedLink = permalink;
+    }
+
+    return new Linker(linkerType, labelInputValue, reshapedLink);
+  };
+
+  const renderLinkPreview = (): JSX.Element => {
+    const linker = generateLink();
+    return (
+      <div className="d-flex justify-content-between mb-3 flex-column flex-sm-row">
+        <div className="card card-disabled w-100 p-1 mb-0">
+          <p className="text-left text-muted mb-1 small">Markdown</p>
+          <p className="text-center text-truncate text-muted">{linker.generateMarkdownText()}</p>
+        </div>
+        <div className="d-flex align-items-center justify-content-center">
+          <span className="lead mx-3">
+            <i className="d-none d-sm-block fa fa-caret-right"></i>
+            <i className="d-sm-none fa fa-caret-down"></i>
+          </span>
+        </div>
+        <div className="card w-100 p-1 mb-0">
+          <p className="text-left text-muted mb-1 small">HTML</p>
+          <p className="text-center text-truncate">
+            <a href={linker.link}>{linker.label}</a>
+          </p>
+        </div>
+      </div>
+    );
+  };
+
+  const handleChangeTypeahead = (selected) => {
+    const pageWithMeta = selected[0];
+    if (pageWithMeta != null) {
+      const page = pageWithMeta.data;
+      const permalink = `${window.location.origin}/${page.id}`;
+      setLinkInputValue(page.path);
+      setPermalink(permalink);
+    }
+  };
+
+  const handleChangeLabelInput = (label: string) => {
+    setLabelInputValue(label);
+  };
+
+  const handleChangeLinkInput = (link) => {
+    let useRelativePath = isUseRelativePath;
+    if (!linkInputValue.startsWith('/') || linkerType === Linker.types.growiLink) {
+      useRelativePath = false;
+    }
+    setLinkInputValue(link);
+    setIsUseRelativePath(useRelativePath);
+    setIsUsePermanentLink(false);
+    setPermalink('');
+  };
+
+  const save = () => {
+    const linker = generateLink();
+
+    if (linkEditModalStatus?.onSave != null) {
+      linkEditModalStatus.onSave(linker.generateMarkdownText() ?? '');
+    }
+
+    close();
+  };
+
+  const toggleIsPreviewOpen = async() => {
+    // open popover
+    if (!isPreviewOpen) {
+      setMarkdownHandler();
+    }
+    setIsPreviewOpen(!isPreviewOpen);
+  };
+
+  const renderLinkAndLabelForm = (): JSX.Element => {
+    return (
+      <>
+        <h3 className="grw-modal-head">{t('link_edit.set_link_and_label')}</h3>
+        <form className="form-group">
+          <div className="form-gorup my-3">
+            <div className="input-group flex-nowrap">
+              <div className="input-group-prepend">
+                <span className="input-group-text">{t('link_edit.link')}</span>
+              </div>
+              <SearchTypeahead
+                onChange={handleChangeTypeahead}
+                onInputChange={handleChangeLinkInput}
+                placeholder={t('link_edit.placeholder_of_link_input')}
+                keywordOnInit={linkInputValue}
+                autoFocus
+              />
+              <div className="d-none d-sm-block input-group-append">
+                <button type="button" id="preview-btn" className={`btn btn-info btn-page-preview ${styles['btn-page-preview']}`}>
+                  <PagePreviewIcon />
+                </button>
+                <Popover trigger="focus" placement="right" isOpen={isPreviewOpen} target="preview-btn" toggle={toggleIsPreviewOpen}>
+                  <PopoverBody>
+                    {markdown != null && pagePath != null && rendererOptions != null
+                    && <div className={`linkedit-preview ${styles['linkedit-preview']}`}>
+                      <Preview markdown={markdown} pagePath={pagePath} rendererOptions={rendererOptions} />
+                    </div>
+                    }
+                  </PopoverBody>
+                </Popover>
+              </div>
+            </div>
+          </div>
+          <div className="form-gorup my-3">
+            <div className="input-group flex-nowrap">
+              <div className="input-group-prepend">
+                <span className="input-group-text">{t('link_edit.label')}</span>
+              </div>
+              <input
+                type="text"
+                className="form-control"
+                id="label"
+                value={labelInputValue}
+                onChange={e => handleChangeLabelInput(e.target.value)}
+                disabled={linkerType === Linker.types.growiLink}
+                placeholder={linkInputValue}
+              />
+            </div>
+          </div>
+        </form>
+      </>
+    );
+  };
+
+  const renderPathFormatForm = (): JSX.Element => {
+    return (
+      <div className="card well pt-3">
+        <form className="form-group mb-0">
+          <div className="form-group mb-0 row">
+            <label className="col-sm-3">{t('link_edit.path_format')}</label>
+            <div className="col-sm-9">
+              <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
+                <input
+                  className="custom-control-input"
+                  id="relativePath"
+                  type="checkbox"
+                  checked={isUseRelativePath}
+                  onChange={toggleIsUseRelativePath}
+                  disabled={!linkInputValue.startsWith('/') || linkerType === Linker.types.growiLink}
+                />
+                <label className="custom-control-label" htmlFor="relativePath">
+                  {t('link_edit.use_relative_path')}
+                </label>
+              </div>
+              <div className="custom-control custom-checkbox custom-checkbox-info custom-control-inline">
+                <input
+                  className="custom-control-input"
+                  id="permanentLink"
+                  type="checkbox"
+                  checked={isUsePermanentLink}
+                  onChange={toggleIsUsePamanentLink}
+                  disabled={permalink === '' || linkerType === Linker.types.growiLink}
+                />
+                <label className="custom-control-label" htmlFor="permanentLink">
+                  {t('link_edit.use_permanent_link')}
+                </label>
+              </div>
+            </div>
+          </div>
+        </form>
+      </div>
+    );
+  };
+
+  if (linkEditModalStatus == null) {
+    return <></>;
+  }
+
+  return (
+    <Modal className="link-edit-modal" isOpen={linkEditModalStatus.isOpened} toggle={close} size="lg" autoFocus={false}>
+      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+        {t('link_edit.edit_link')}
+      </ModalHeader>
+
+      <ModalBody className="container">
+        <div className="row">
+          <div className="col-12">
+            {renderLinkAndLabelForm()}
+            {renderPathFormatForm()}
+          </div>
+        </div>
+        <div className="row">
+          <div className="col-12">
+            <h3 className="grw-modal-head">{t('link_edit.preview')}</h3>
+            {renderLinkPreview()}
+          </div>
+        </div>
+      </ModalBody>
+      <ModalFooter>
+        { previewError && <span className='text-danger'>{previewError}</span>}
+        <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={close}>
+          {t('Cancel')}
+        </button>
+        <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={save}>
+          {t('Done')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};
+
+LinkEditModal.displayName = 'LinkEditModal';

+ 1 - 1
apps/app/src/components/PageList/PageList.tsx

@@ -51,7 +51,7 @@ const PageList = (props: Props<IPageInfoForEntity>): JSX.Element => {
   if (pageList.length === 0) {
     return (
       <div className="mt-2">
-        <p>{t('custom_navigation.no_page_list')}</p>
+        <p>{t('custom_navigation.no_pages_under_this_page')}</p>
       </div>
     );
   }

+ 21 - 31
apps/app/src/components/PageTimeline.tsx

@@ -1,15 +1,15 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 
-import { apiv3Get } from '~/client/util/apiv3-client';
 import { IPageHasId } from '~/interfaces/page';
 import { useCurrentPagePath } from '~/stores/page';
+import { useSWRINFxPageTimeline } from '~/stores/page-timeline';
 import { useTimelineOptions } from '~/stores/renderer';
 
+import InfiniteScroll from './InfiniteScroll';
 import { RevisionLoader } from './Page/RevisionLoader';
-import PaginationWrapper from './PaginationWrapper';
 
 import styles from './PageTimeline.module.scss';
 
@@ -42,48 +42,38 @@ const TimelineCard = ({ page }: TimelineCardProps): JSX.Element => {
   );
 };
 
-
 export const PageTimeline = (): JSX.Element => {
-  const [activePage, setActivePage] = useState(1);
-  const [totalPageItems, setTotalPageItems] = useState(0);
-  const [limit, setLimit] = useState(10);
-  const [pages, setPages] = useState<IPageHasId[] | null>(null);
 
-  const { data: currentPagePath } = useCurrentPagePath();
+  const PER_PAGE = 3;
   const { t } = useTranslation();
+  const { data: currentPagePath } = useCurrentPagePath();
 
-  const handlePage = useCallback(async(selectedPage: number) => {
-    if (currentPagePath == null) { return }
-    const res = await apiv3Get('/pages/list', { path: currentPagePath, page: selectedPage });
-    setTotalPageItems(res.data.totalCount);
-    setPages(res.data.pages);
-    setLimit(res.data.limit);
-    setActivePage(selectedPage);
-  }, [currentPagePath]);
+  const swrInfinitexPageTimeline = useSWRINFxPageTimeline(currentPagePath, PER_PAGE);
+  const { data } = swrInfinitexPageTimeline;
 
-  useEffect(() => {
-    handlePage(1);
-  }, [handlePage]);
+  const isEmpty = data?.[0]?.pages.length === 0;
+  const isReachingEnd = isEmpty || (data != null && data[data.length - 1]?.pages.length < PER_PAGE);
 
-  if (pages == null || pages.length === 0) {
+  if (data == null || isEmpty) {
     return (
       <div className="mt-2">
-        {/* eslint-disable-next-line react/no-danger */}
-        <p>{t('custom_navigation.no_page_list')}</p>
+        <p>{t('custom_navigation.no_pages_under_this_page')}</p>
       </div>
     );
   }
 
   return (
     <div>
-      { pages.map(page => <TimelineCard key={page._id} page={page} />) }
-      <PaginationWrapper
-        activePage={activePage}
-        changePage={handlePage}
-        totalItemsCount={totalPageItems}
-        pagingLimit={limit}
-        align="center"
-      />
+      <InfiniteScroll
+        swrInifiniteResponse={swrInfinitexPageTimeline}
+        isReachingEnd={isReachingEnd}
+      >
+        { data != null && data.flatMap(apiResult => apiResult.pages)
+          .map(page => (
+            <TimelineCard key={page._id} page={page} />
+          ))
+        }
+      </InfiniteScroll>
     </div>
   );
 };

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

@@ -149,8 +149,8 @@ const ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE = 'ADMIN_SLACK_CONFIGURATI
 const ACTION_ADMIN_USERS_INVITE = 'ADMIN_USERS_INVITE';
 const ACTION_ADMIN_USERS_PASSWORD_RESET = 'ADMIN_USERS_PASSWORD_RESET';
 const ACTION_ADMIN_USERS_ACTIVATE = 'ADMIN_USERS_ACTIVATE';
-const ACTION_ADMIN_USERS_GIVE_ADMIN = 'ADMIN_USERS_GIVE_ADMIN';
-const ACTION_ADMIN_USERS_REMOVE_ADMIN = 'ADMIN_USERS_REMOVE_ADMIN';
+const ACTION_ADMIN_USERS_GRANT_ADMIN = 'ADMIN_USERS_GRANT_ADMIN';
+const ACTION_ADMIN_USERS_REVOKE_ADMIN = 'ADMIN_USERS_REVOKE_ADMIN';
 const ACTION_ADMIN_USERS_GRANT_READ_ONLY = 'ADMIN_USERS_GRANT_READ_ONLY';
 const ACTION_ADMIN_USERS_REVOKE_READ_ONLY = 'ADMIN_USERS_REVOKE_READ_ONLY';
 const ACTION_ADMIN_USERS_DEACTIVATE = 'ADMIN_USERS_DEACTIVATE';
@@ -329,8 +329,8 @@ export const SupportedAction = {
   ACTION_ADMIN_USERS_PASSWORD_RESET,
   ACTION_ADMIN_USERS_ACTIVATE,
   ACTION_ADMIN_USERS_DEACTIVATE,
-  ACTION_ADMIN_USERS_GIVE_ADMIN,
-  ACTION_ADMIN_USERS_REMOVE_ADMIN,
+  ACTION_ADMIN_USERS_GRANT_ADMIN,
+  ACTION_ADMIN_USERS_REVOKE_ADMIN,
   ACTION_ADMIN_USERS_GRANT_READ_ONLY,
   ACTION_ADMIN_USERS_REVOKE_READ_ONLY,
   ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL,
@@ -516,8 +516,8 @@ export const LargeActionGroup = {
   ACTION_ADMIN_USERS_PASSWORD_RESET,
   ACTION_ADMIN_USERS_ACTIVATE,
   ACTION_ADMIN_USERS_DEACTIVATE,
-  ACTION_ADMIN_USERS_GIVE_ADMIN,
-  ACTION_ADMIN_USERS_REMOVE_ADMIN,
+  ACTION_ADMIN_USERS_GRANT_ADMIN,
+  ACTION_ADMIN_USERS_REVOKE_ADMIN,
   ACTION_ADMIN_USERS_GRANT_READ_ONLY,
   ACTION_ADMIN_USERS_REVOKE_READ_ONLY,
   ACTION_ADMIN_USERS_SEND_INVITATION_EMAIL,

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

@@ -75,6 +75,7 @@ const GrowiSubNavigationSwitcher = dynamic<GrowiSubNavigationSwitcherProps>(() =
 const DrawioModal = dynamic(() => import('../components/PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
 const HandsontableModal = dynamic(() => import('../components/PageEditor/HandsontableModal').then(mod => mod.HandsontableModal), { ssr: false });
 const TemplateModal = dynamic(() => import('../components/TemplateModal').then(mod => mod.TemplateModal), { ssr: false });
+const LinkEditModal = dynamic(() => import('../components/PageEditor/LinkEditModal').then(mod => mod.LinkEditModal), { ssr: false });
 const PageStatusAlert = dynamic(() => import('../components/PageStatusAlert').then(mod => mod.PageStatusAlert), { ssr: false });
 const QuestionnaireModalManager = dynamic(() => import('~/features/questionnaire/client/components/QuestionnaireModalManager'), { ssr: false });
 
@@ -380,6 +381,7 @@ Page.getLayout = function getLayout(page: React.ReactElement<Props>) {
       <HandsontableModal />
       <QuestionnaireModalManager />
       <TemplateModal />
+      <LinkEditModal />
     </>
   );
 };
@@ -583,8 +585,8 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     // 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'),
+    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'),
   };
 

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

@@ -101,8 +101,8 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
     // 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'),
+    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'),
   };
 }

+ 2 - 2
apps/app/src/pages/_search.page.tsx

@@ -138,8 +138,8 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     // 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'),
+    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'),
   };
 

+ 3 - 3
apps/app/src/pages/login/index.page.tsx

@@ -28,7 +28,7 @@ type Props = CommonProps & {
   pageWithMetaStr: string,
   isMailerSetup: boolean,
   enabledStrategies: unknown,
-  registrationWhiteList: string[],
+  registrationWhitelist: string[],
   isLocalStrategySetup: boolean,
   isLdapStrategySetup: boolean,
   isLdapSetupFailed: boolean,
@@ -60,7 +60,7 @@ const LoginPage: NextPage<Props> = (props: Props) => {
         isLdapStrategySetup={props.isLdapStrategySetup}
         isLdapSetupFailed={props.isLdapSetupFailed}
         isEmailAuthenticationEnabled={props.isEmailAuthenticationEnabled}
-        registrationWhiteList={props.registrationWhiteList}
+        registrationWhitelist={props.registrationWhitelist}
         isPasswordResetEnabled={props.isPasswordResetEnabled}
         isMailerSetup={props.isMailerSetup}
         registrationMode={props.registrationMode}
@@ -113,7 +113,7 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   props.isLocalStrategySetup = passportService.isLocalStrategySetup;
   props.isLdapStrategySetup = passportService.isLdapStrategySetup;
   props.isLdapSetupFailed = configManager.getConfig('crowi', 'security:passport-ldap:isEnabled') && !props.isLdapStrategySetup;
-  props.registrationWhiteList = configManager.getConfig('crowi', 'security:registrationWhiteList');
+  props.registrationWhitelist = configManager.getConfig('crowi', 'security:registrationWhitelist');
   props.isEmailAuthenticationEnabled = configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled');
   props.registrationMode = configManager.getConfig('crowi', 'security:registrationMode');
 }

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

@@ -17,7 +17,7 @@ import {
   useCurrentUser, useIsSearchPage,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useCsrfToken, useIsSearchScopeChildrenAsDefault,
-  useRegistrationWhiteList, useShowPageLimitationXL, useRendererConfig,
+  useRegistrationWhitelist, useShowPageLimitationXL, useRendererConfig,
 } from '~/stores/context';
 import loggerFactory from '~/utils/logger';
 
@@ -38,7 +38,7 @@ type Props = CommonProps & {
   showPageLimitationXL: number,
 
   // config
-  registrationWhiteList: string[],
+  registrationWhitelist: string[],
 };
 
 const PersonalSettings = dynamic(() => import('~/components/Me/PersonalSettings'), { ssr: false });
@@ -82,7 +82,7 @@ const MePage: NextPageWithLayout<Props> = (props: Props) => {
 
   useCurrentUser(props.currentUser ?? null);
 
-  useRegistrationWhiteList(props.registrationWhiteList);
+  useRegistrationWhitelist(props.registrationWhitelist);
 
   useShowPageLimitationXL(props.showPageLimitationXL);
 
@@ -143,7 +143,7 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
 
-  props.registrationWhiteList = configManager.getConfig('crowi', 'security:registrationWhiteList');
+  props.registrationWhitelist = configManager.getConfig('crowi', 'security:registrationWhitelist');
 
   props.showPageLimitationXL = crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL');
 
@@ -164,8 +164,8 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
     // 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'),
+    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'),
   };
 }

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

@@ -161,8 +161,8 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     // 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'),
+    attrWhitelist: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhitelist: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 }

+ 12 - 0
apps/app/src/server/models/.eslintrc.js

@@ -0,0 +1,12 @@
+const rulesDirPlugin = require('eslint-plugin-rulesdir');
+
+rulesDirPlugin.RULES_DIR = 'src/server/models/eslint-rules-dir';
+
+module.exports = {
+  plugins: [
+    'rulesdir',
+  ],
+  rules: {
+    'rulesdir/no-populate': 'warn',
+  },
+};

+ 3 - 3
apps/app/src/server/models/config.ts

@@ -57,7 +57,7 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'security:restrictGuestMode'      : 'Deny',
 
   'security:registrationMode'      : 'Open',
-  'security:registrationWhiteList' : [],
+  'security:registrationWhitelist' : [],
 
   'security:list-policy:hideRestrictedByOwner' : false,
   'security:list-policy:hideRestrictedByGroup' : false,
@@ -142,8 +142,8 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
 
 export const defaultMarkdownConfigs: { [key: string]: any } = {
   // don't use it, but won't turn it off
-  'markdown:xss:tagWhiteList': [],
-  'markdown:xss:attrWhiteList': [],
+  'markdown:xss:tagWhitelist': [],
+  'markdown:xss:attrWhitelist': [],
 
   'markdown:rehypeSanitize:isEnabledPrevention': true,
   'markdown:rehypeSanitize:option': RehypeSanitizeOption.RECOMMENDED,

+ 27 - 0
apps/app/src/server/models/eslint-rules-dir/no-populate.js

@@ -0,0 +1,27 @@
+/**
+ * @typedef {import('eslint').Rule} Rule
+ * @typedef {import('./lib/html.js').HtmlOptions} HtmlOptions
+ */
+
+/** @type {Rule.RuleModule} */
+module.exports = {
+  meta: {
+    type: 'problem',
+  },
+  /**
+   * @property {Rule.RuleContext} context
+   * @return {Rule.RuleListener}
+   */
+  create: (context) => {
+    return {
+      CallExpression(node) {
+        if (node.callee.property && node.callee.property.name === 'populate') {
+          context.report({
+            node,
+            message: "The 'populate' method should not be called in model modules.",
+          });
+        }
+      },
+    };
+  },
+};

+ 25 - 0
apps/app/src/server/models/eslint-rules-dir/test/no-populate.spec.ts

@@ -0,0 +1,25 @@
+import { test } from 'vitest';
+
+import { RuleTester } from 'eslint';
+
+import noPopulate from '../no-populate';
+
+const ruleTester = new RuleTester({
+  parserOptions: {
+    ecmaVersion: 2015,
+  },
+});
+
+test('test no-populate', () => {
+  ruleTester.run('no-populate', noPopulate, {
+    valid: [
+      { code: 'Model.find();' },
+    ],
+    invalid: [
+      {
+        code: "Model.find().populate('children');",
+        errors: [{ message: "The 'populate' method should not be called in model modules." }],
+      },
+    ],
+  });
+});

+ 10 - 10
apps/app/src/server/models/user.js

@@ -265,31 +265,31 @@ module.exports = function(crowi) {
     });
   };
 
-  userSchema.methods.removeFromAdmin = async function() {
-    logger.debug('Remove from admin', this);
-    this.admin = 0;
+  userSchema.methods.grantAdmin = async function() {
+    logger.debug('Grant Admin', this);
+    this.admin = 1;
     return this.save();
   };
 
-  userSchema.methods.makeAdmin = async function() {
-    logger.debug('Admin', this);
-    this.admin = 1;
+  userSchema.methods.revokeAdmin = async function() {
+    logger.debug('Revove admin', this);
+    this.admin = 0;
     return this.save();
   };
 
   userSchema.methods.grantReadOnly = async function() {
-    logger.debug('Grant read only flag', this);
+    logger.debug('Grant read only access', this);
     this.readOnly = 1;
     return this.save();
   };
 
   userSchema.methods.revokeReadOnly = async function() {
-    logger.debug('Revoke read only flag', this);
+    logger.debug('Revoke read only access', this);
     this.readOnly = 0;
     return this.save();
   };
 
-  userSchema.methods.asyncMakeAdmin = async function(callback) {
+  userSchema.methods.asyncGrantAdmin = async function(callback) {
     this.admin = 1;
     return this.save();
   };
@@ -347,7 +347,7 @@ module.exports = function(crowi) {
   userSchema.statics.isEmailValid = function(email, callback) {
     validateCrowi();
 
-    const whitelist = crowi.configManager.getConfig('crowi', 'security:registrationWhiteList');
+    const whitelist = crowi.configManager.getConfig('crowi', 'security:registrationWhitelist');
 
     if (Array.isArray(whitelist) && whitelist.length > 0) {
       return whitelist.some((allowedEmail) => {

+ 13 - 13
apps/app/src/server/routes/apiv3/markdown-setting.js

@@ -26,8 +26,8 @@ const validator = {
   ],
   xssSetting: [
     body('isEnabledXss').isBoolean(),
-    body('tagWhiteList').isArray(),
-    body('attrWhiteList').isString(),
+    body('tagWhitelist').isArray(),
+    body('attrWhitelist').isString(),
   ],
 };
 
@@ -73,15 +73,15 @@ const validator = {
  *          xssOption:
  *            type: number
  *            description: number of xss option
- *          tagWhiteList:
+ *          tagWhitelist:
  *            type: array
- *            description: array of tag whiteList
+ *            description: array of tag whitelist
  *            items:
  *              type: string
  *              description: tag whitelist
- *          attrWhiteList:
+ *          attrWhitelist:
  *            type: array
- *            description: array of attr whiteList
+ *            description: array of attr whitelist
  *            items:
  *              type: string
  *              description: attr whitelist
@@ -122,8 +122,8 @@ module.exports = (crowi) => {
       isIndentSizeForced: await crowi.configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
       isEnabledXss: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
       xssOption: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-      tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
-      attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes'),
+      tagWhitelist: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+      attrWhitelist: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes'),
     };
 
     return res.apiv3({ markdownParams });
@@ -235,7 +235,7 @@ module.exports = (crowi) => {
     }
 
     try {
-      JSON.parse(req.body.attrWhiteList);
+      JSON.parse(req.body.attrWhitelist);
     }
     catch (err) {
       const msg = 'Error occurred in updating xss';
@@ -246,8 +246,8 @@ module.exports = (crowi) => {
     const reqestXssParams = {
       'markdown:rehypeSanitize:isEnabledPrevention': req.body.isEnabledXss,
       'markdown:rehypeSanitize:option': req.body.xssOption,
-      'markdown:rehypeSanitize:tagNames': req.body.tagWhiteList,
-      'markdown:rehypeSanitize:attributes': req.body.attrWhiteList,
+      'markdown:rehypeSanitize:tagNames': req.body.tagWhitelist,
+      'markdown:rehypeSanitize:attributes': req.body.attrWhitelist,
     };
 
     try {
@@ -255,8 +255,8 @@ module.exports = (crowi) => {
       const xssParams = {
         isEnabledXss: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
         xssOption: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
-        tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
-        attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes'),
+        tagWhitelist: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+        attrWhitelist: await crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes'),
       };
 
       const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_XSS_UPDATE };

+ 6 - 6
apps/app/src/server/routes/apiv3/security-setting.js

@@ -41,7 +41,7 @@ const validator = {
     body('registrationMode').isString().isIn([
       'Open', 'Restricted', 'Closed',
     ]),
-    body('registrationWhiteList').if(value => value != null).isArray().customSanitizer((value, { req }) => {
+    body('registrationWhitelist').if(value => value != null).isArray().customSanitizer((value, { req }) => {
       return value.filter(email => email !== '');
     }),
   ],
@@ -145,12 +145,12 @@ const validator = {
  *          registrationMode:
  *            type: string
  *            description: type of registrationMode
- *          registrationWhiteList:
+ *          registrationWhitelist:
  *            type: array
  *            description: array of regsitrationList
  *            items:
  *              type: string
- *              description: registration whiteList
+ *              description: registration whitelist
  *      LdapAuthSetting:
  *        type: object
  *        properties:
@@ -363,7 +363,7 @@ module.exports = (crowi) => {
       localSetting: {
         useOnlyEnvVarsForSomeOptions: await crowi.configManager.getConfig('crowi', 'security:passport-local:useOnlyEnvVarsForSomeOptions'),
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
-        registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
+        registrationWhitelist: await crowi.configManager.getConfig('crowi', 'security:registrationWhitelist'),
         isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
         isEmailAuthenticationEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled'),
       },
@@ -791,7 +791,7 @@ module.exports = (crowi) => {
   router.put('/local-setting', loginRequiredStrictly, adminRequired, addActivity, validator.localSetting, apiV3FormValidator, async(req, res) => {
     const requestParams = {
       'security:registrationMode': req.body.registrationMode,
-      'security:registrationWhiteList': req.body.registrationWhiteList,
+      'security:registrationWhitelist': req.body.registrationWhitelist,
       'security:passport-local:isPasswordResetEnabled': req.body.isPasswordResetEnabled,
       'security:passport-local:isEmailAuthenticationEnabled': req.body.isEmailAuthenticationEnabled,
     };
@@ -800,7 +800,7 @@ module.exports = (crowi) => {
 
       const localSettingParams = {
         registrationMode: await crowi.configManager.getConfig('crowi', 'security:registrationMode'),
-        registrationWhiteList: await crowi.configManager.getConfig('crowi', 'security:registrationWhiteList'),
+        registrationWhitelist: await crowi.configManager.getConfig('crowi', 'security:registrationWhitelist'),
         isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
         isEmailAuthenticationEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled'),
       };

+ 20 - 20
apps/app/src/server/routes/apiv3/users.js

@@ -10,12 +10,12 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:users');
 
+const path = require('path');
+
 const express = require('express');
 
 const router = express.Router();
 
-const path = require('path');
-
 const { body, query } = require('express-validator');
 const { isEmail } = require('validator');
 
@@ -453,12 +453,12 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /users/{id}/giveAdmin:
+   *    /users/{id}/grant-admin:
    *      put:
    *        tags: [Users]
-   *        operationId: giveAdminUser
-   *        summary: /users/{id}/giveAdmin
-   *        description: Give user admin
+   *        operationId: grantAdminUser
+   *        summary: /users/{id}/grant-admin
+   *        description: Grant user admin
    *        parameters:
    *          - name: id
    *            in: path
@@ -468,7 +468,7 @@ module.exports = (crowi) => {
    *              type: string
    *        responses:
    *          200:
-   *            description: Give user admin success
+   *            description: Grant user admin success
    *            content:
    *              application/json:
    *                schema:
@@ -477,16 +477,16 @@ module.exports = (crowi) => {
    *                      type: object
    *                      description: data of admin user
    */
-  router.put('/:id/giveAdmin', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
+  router.put('/:id/grant-admin', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const { id } = req.params;
 
     try {
       const userData = await User.findById(id);
-      await userData.makeAdmin();
+      await userData.grantAdmin();
 
       const serializedUserData = serializeUserSecurely(userData);
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_GIVE_ADMIN });
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_GRANT_ADMIN });
 
       return res.apiv3({ userData: serializedUserData });
     }
@@ -500,40 +500,40 @@ module.exports = (crowi) => {
    * @swagger
    *
    *  paths:
-   *    /users/{id}/removeAdmin:
+   *    /users/{id}/revoke-admin:
    *      put:
    *        tags: [Users]
-   *        operationId: removeAdminUser
-   *        summary: /users/{id}/removeAdmin
-   *        description: Remove user admin
+   *        operationId: revokeAdminUser
+   *        summary: /users/{id}/revoke-admin
+   *        description: Revoke user admin
    *        parameters:
    *          - name: id
    *            in: path
    *            required: true
-   *            description: id of user for removing admin
+   *            description: id of user for revoking admin
    *            schema:
    *              type: string
    *        responses:
    *          200:
-   *            description: Remove user admin success
+   *            description: Revoke user admin success
    *            content:
    *              application/json:
    *                schema:
    *                  properties:
    *                    userData:
    *                      type: object
-   *                      description: data of removed admin user
+   *                      description: data of revoked admin user
    */
-  router.put('/:id/removeAdmin', loginRequiredStrictly, adminRequired, certifyUserOperationOtherThenYourOwn, addActivity, async(req, res) => {
+  router.put('/:id/revoke-admin', loginRequiredStrictly, adminRequired, certifyUserOperationOtherThenYourOwn, addActivity, async(req, res) => {
     const { id } = req.params;
 
     try {
       const userData = await User.findById(id);
-      await userData.removeFromAdmin();
+      await userData.revokeAdmin();
 
       const serializedUserData = serializeUserSecurely(userData);
 
-      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REMOVE_ADMIN });
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_USERS_REVOKE_ADMIN });
 
       return res.apiv3({ userData: serializedUserData });
     }

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

@@ -155,8 +155,8 @@ module.exports = function(crowi, app) {
   const Xss = require('~/services/xss/index');
   const initializedConfig = {
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
-    tagWhiteList: xssService.getTagWhiteList(),
-    attrWhiteList: xssService.getAttrWhiteList(),
+    tagWhitelist: xssService.getTagWhitelist(),
+    attrWhitelist: xssService.getAttrWhitelist(),
   };
   const xssOption = new XssOption(initializedConfig);
   const xss = new Xss(xssOption);

+ 1 - 1
apps/app/src/server/service/installer.ts

@@ -146,7 +146,7 @@ export class InstallerService {
         name, username, email, password,
       } = firstAdminUserToSave;
       adminUser = await User.createUser(name, username, email, password, globalLang);
-      await adminUser.asyncMakeAdmin();
+      await adminUser.asyncGrantAdmin();
     }
     catch (err) {
       throw new FailedToCreateAdminUserError(err);

+ 1 - 0
apps/app/src/server/service/search.ts

@@ -25,6 +25,7 @@ const logger = loggerFactory('growi:service:search');
 const nonNullable = <T>(value: T): value is NonNullable<T> => value != null;
 
 // options for filtering xss
+// Do not change the property key name to 'whitelist" because it depends on the 'xss' library
 const filterXssOptions = {
   whiteList: {
     em: ['class'],

+ 6 - 6
apps/app/src/server/service/xss.js

@@ -20,7 +20,7 @@ class XssSerivce {
     return this.xss.process(value);
   }
 
-  getTagWhiteList() {
+  getTagWhitelist() {
     const isEnabledXssPrevention = this.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention');
     const xssOpiton = this.configManager.getConfig('markdown', 'markdown:xss:option');
 
@@ -32,8 +32,8 @@ class XssSerivce {
         case 2: // recommended
           return tags;
 
-        case 3: // custom white list
-          return this.configManager.getConfig('markdown', 'markdown:xss:tagWhiteList');
+        case 3: // custom whitelist
+          return this.configManager.getConfig('markdown', 'markdown:xss:tagWhitelist');
 
         default:
           return [];
@@ -44,7 +44,7 @@ class XssSerivce {
     }
   }
 
-  getAttrWhiteList() {
+  getAttrWhitelist() {
     const isEnabledXssPrevention = this.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention');
     const xssOpiton = this.configManager.getConfig('markdown', 'markdown:xss:option');
 
@@ -56,8 +56,8 @@ class XssSerivce {
         case 2: // recommended
           return attrs;
 
-        case 3: // custom white list
-          return this.configManager.getConfig('markdown', 'markdown:xss:attrWhiteList');
+        case 3: // custom whitelist
+          return this.configManager.getConfig('markdown', 'markdown:xss:attrWhitelist');
 
         default:
           return [];

+ 54 - 0
apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.spec.ts

@@ -0,0 +1,54 @@
+import { describe, test, expect } from 'vitest';
+
+import { type HastNode, select } from 'hast-util-select';
+import parse from 'remark-parse';
+import rehype from 'remark-rehype';
+import { unified } from 'unified';
+
+import { pukiwikiLikeLinker } from '../remark-plugins/pukiwiki-like-linker';
+
+import { relativeLinksByPukiwikiLikeLinker } from './relative-links-by-pukiwiki-like-linker';
+
+describe('relativeLinksByPukiwikiLikeLinker', () => {
+
+  /* eslint-disable indent */
+  describe.each`
+    input                                   | expectedHref                        | expectedValue
+    ${'[[/page]]'}                          | ${'/page'}                          | ${'/page'}
+    ${'[[./page]]'}                         | ${'/user/admin/page'}               | ${'./page'}
+    ${'[[Title>./page]]'}                   | ${'/user/admin/page'}               | ${'Title'}
+    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}            | ${'Title'}
+    ${'[[/page?q=foo#header]]'}             | ${'/page?q=foo#header'}             | ${'/page?q=foo#header'}
+    ${'[[./page?q=foo#header]]'}            | ${'/user/admin/page?q=foo#header'}  | ${'./page?q=foo#header'}
+    ${'[[Title>./page?q=foo#header]]'}      | ${'/user/admin/page?q=foo#header'}  | ${'Title'}
+    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}            | ${'Title'}
+  `('should convert relative links correctly', ({ input, expectedHref, expectedValue }) => {
+  /* eslint-enable indent */
+
+    test(`when the input is '${input}'`, () => {
+      // setup:
+      const processor = unified()
+        .use(parse)
+        .use(pukiwikiLikeLinker)
+        .use(rehype)
+        .use(relativeLinksByPukiwikiLikeLinker, { pagePath: '/user/admin' });
+
+      // when:
+      const mdast = processor.parse(input);
+      const hast = processor.runSync(mdast) as HastNode;
+      const anchorElement = select('a', hast);
+
+      // then
+      expect(anchorElement).not.toBeNull();
+      expect(anchorElement?.properties).not.toBeNull();
+      expect((anchorElement?.properties?.className as string).startsWith('pukiwiki-like-linker')).toBeTruthy();
+      expect(anchorElement?.properties?.href).toEqual(expectedHref);
+
+      expect(anchorElement?.children[0]).not.toBeNull();
+      expect(anchorElement?.children[0].type).toEqual('text');
+      expect(anchorElement?.children[0].value).toEqual(expectedValue);
+
+    });
+  });
+
+});

+ 6 - 7
apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.ts

@@ -1,27 +1,26 @@
 import { pathUtils } from '@growi/core';
 import { selectAll } from 'hast-util-select';
-import { Plugin } from 'unified';
+import type { Plugin } from 'unified';
 
 import {
-  IAnchorsSelector, IHrefResolver, relativeLinks, RelativeLinksPluginParams,
+  relativeLinks,
+  type IAnchorsSelector, type IUrlResolver, type RelativeLinksPluginParams,
 } from './relative-links';
 
 const customAnchorsSelector: IAnchorsSelector = (node) => {
   return selectAll('a[href].pukiwiki-like-linker', node);
 };
 
-const customHrefResolver: IHrefResolver = (relativeHref, basePath) => {
+const customUrlResolver: IUrlResolver = (relativeHref, basePath) => {
   // generate relative pathname
   const baseUrl = new URL(pathUtils.addTrailingSlash(basePath), 'https://example.com');
-  const relativeUrl = new URL(relativeHref, baseUrl);
-
-  return relativeUrl.pathname;
+  return new URL(relativeHref, baseUrl);
 };
 
 export const relativeLinksByPukiwikiLikeLinker: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
   return relativeLinks.bind(this)({
     ...options,
     anchorsSelector: customAnchorsSelector,
-    hrefResolver: customHrefResolver,
+    urlResolver: customUrlResolver,
   });
 };

+ 64 - 0
apps/app/src/services/renderer/rehype-plugins/relative-links.spec.ts

@@ -0,0 +1,64 @@
+
+import { describe, test, expect } from 'vitest';
+
+import { select, type HastNode } from 'hast-util-select';
+import parse from 'remark-parse';
+import remarkRehype from 'remark-rehype';
+import { unified } from 'unified';
+
+import { relativeLinks } from './relative-links';
+
+describe('relativeLinks', () => {
+
+  test.concurrent.each`
+    originalHref
+      ${'http://example.com/Sandbox'}
+      ${'#header'}
+    `('leaves the original href \'$originalHref\' as-is', ({ originalHref }) => {
+
+    // setup
+    const processor = unified()
+      .use(parse)
+      .use(remarkRehype)
+      .use(relativeLinks, {});
+
+    // when
+    const mdastTree = processor.parse(`[link](${originalHref})`);
+    const hastTree = processor.runSync(mdastTree) as HastNode;
+
+    // then
+    const anchorElement = select('a', hastTree);
+    expect(anchorElement?.properties?.href).toBe(originalHref);
+  });
+
+  test.concurrent.each`
+    originalHref                        | expectedHref
+      ${'/Sandbox'}                     | ${'/Sandbox'}
+      ${'/Sandbox?q=foo'}               | ${'/Sandbox?q=foo'}
+      ${'/Sandbox#header'}              | ${'/Sandbox#header'}
+      ${'/Sandbox?q=foo#header'}        | ${'/Sandbox?q=foo#header'}
+      ${'./Sandbox'}                    | ${'/foo/bar/Sandbox'}
+      ${'./Sandbox?q=foo'}              | ${'/foo/bar/Sandbox?q=foo'}
+      ${'./Sandbox#header'}             | ${'/foo/bar/Sandbox#header'}
+      ${'./Sandbox?q=foo#header'}       | ${'/foo/bar/Sandbox?q=foo#header'}
+    `('rewrites the original href \'$originalHref\' to \'$expectedHref\'', ({ originalHref, expectedHref }) => {
+
+    // setup
+    const pagePath = '/foo/bar/baz';
+    const processor = unified()
+      .use(parse)
+      .use(remarkRehype)
+      .use(relativeLinks, { pagePath });
+
+    // when
+    const mdastTree = processor.parse(`[link](${originalHref})`);
+    const hastTree = processor.runSync(mdastTree) as HastNode;
+
+    // then
+    const anchorElement = select('a', hastTree);
+    expect(anchorElement).not.toBeNull();
+    expect(anchorElement?.properties).not.toBeNull();
+    expect(anchorElement?.properties?.href).toBe(expectedHref);
+  });
+
+});

+ 12 - 9
apps/app/src/services/renderer/rehype-plugins/relative-links.ts

@@ -1,20 +1,23 @@
-import { selectAll, HastNode, Element } from 'hast-util-select';
+import { selectAll, type HastNode, type Element } from 'hast-util-select';
 import isAbsolute from 'is-absolute-url';
-import { Plugin } from 'unified';
+import type { Plugin } from 'unified';
 
 export type IAnchorsSelector = (node: HastNode) => Element[];
-export type IHrefResolver = (relativeHref: string, basePath: string) => string;
+export type IUrlResolver = (relativeHref: string, basePath: string) => URL;
 
 const defaultAnchorsSelector: IAnchorsSelector = (node) => {
   return selectAll('a[href]', node);
 };
 
-const defaultHrefResolver: IHrefResolver = (relativeHref, basePath) => {
+const defaultUrlResolver: IUrlResolver = (relativeHref, basePath) => {
   // generate relative pathname
   const baseUrl = new URL(basePath, 'https://example.com');
-  const relativeUrl = new URL(relativeHref, baseUrl);
+  return new URL(relativeHref, baseUrl);
+};
 
-  return relativeUrl.pathname;
+const urlToHref = (url: URL): string => {
+  const { pathname, search, hash } = url;
+  return `${pathname}${search}${hash}`;
 };
 
 const isAnchorLink = (href: string): boolean => {
@@ -24,12 +27,12 @@ const isAnchorLink = (href: string): boolean => {
 export type RelativeLinksPluginParams = {
   pagePath?: string,
   anchorsSelector?: IAnchorsSelector,
-  hrefResolver?: IHrefResolver,
+  urlResolver?: IUrlResolver,
 }
 
 export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
   const anchorsSelector = options.anchorsSelector ?? defaultAnchorsSelector;
-  const hrefResolver = options.hrefResolver ?? defaultHrefResolver;
+  const urlResolver = options.urlResolver ?? defaultUrlResolver;
 
   return (tree) => {
     if (options.pagePath == null) {
@@ -49,7 +52,7 @@ export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {})
         return;
       }
 
-      anchor.properties.href = hrefResolver(href, pagePath);
+      anchor.properties.href = urlToHref(urlResolver(href, pagePath));
     });
   };
 };

+ 45 - 0
apps/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.spec.ts

@@ -0,0 +1,45 @@
+import { describe, test, expect } from 'vitest';
+
+import parse from 'remark-parse';
+import { unified } from 'unified';
+import { visit } from 'unist-util-visit';
+
+import { pukiwikiLikeLinker } from './pukiwiki-like-linker';
+
+describe('pukiwikiLikeLinker', () => {
+
+  describe.each`
+    input                                   | expectedHref                    | expectedValue
+    ${'[[/page]]'}                          | ${'/page'}                      | ${'/page'}
+    ${'[[./page]]'}                         | ${'./page'}                     | ${'./page'}
+    ${'[[Title>./page]]'}                   | ${'./page'}                     | ${'Title'}
+    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}        | ${'Title'}
+    ${'[[/page?q=foo#header]]'}             | ${'/page?q=foo#header'}         | ${'/page?q=foo#header'}
+    ${'[[./page?q=foo#header]]'}            | ${'./page?q=foo#header'}        | ${'./page?q=foo#header'}
+    ${'[[Title>./page?q=foo#header]]'}      | ${'./page?q=foo#header'}        | ${'Title'}
+    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}        | ${'Title'}
+  `('should parse correctly', ({ input, expectedHref, expectedValue }) => {
+
+    test(`when the input is '${input}'`, () => {
+      // setup:
+      const processor = unified()
+        .use(parse)
+        .use(pukiwikiLikeLinker);
+
+      // when:
+      const ast = processor.parse(input);
+
+      expect(ast).not.toBeNull();
+
+      visit(ast, 'wikiLink', (node: any) => {
+        expect(node.data.alias).toEqual(expectedValue);
+        expect(node.data.permalink).toEqual(expectedHref);
+        expect(node.data.hName).toEqual('a');
+        expect(node.data.hProperties.className.startsWith('pukiwiki-like-linker')).toBeTruthy();
+        expect(node.data.hProperties.href).toEqual(expectedHref);
+        expect(node.data.hChildren[0].value).toEqual(expectedValue);
+      });
+    });
+  });
+
+});

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

@@ -57,8 +57,8 @@ let isInjectedCustomSanitaizeOption = false;
 
 export const injectCustomSanitizeOption = (config: RendererConfig): void => {
   if (!isInjectedCustomSanitaizeOption && config.isEnabledXssPrevention && config.xssOption === RehypeSanitizeOption.CUSTOM) {
-    commonSanitizeOption.tagNames = baseSanitizeSchema.tagNames.concat(config.tagWhiteList ?? []);
-    commonSanitizeOption.attributes = deepmerge(baseSanitizeSchema.attributes, config.attrWhiteList ?? {});
+    commonSanitizeOption.tagNames = baseSanitizeSchema.tagNames.concat(config.tagWhitelist ?? []);
+    commonSanitizeOption.attributes = deepmerge(baseSanitizeSchema.attributes, config.attrWhitelist ?? {});
     isInjectedCustomSanitaizeOption = true;
   }
 };

+ 6 - 6
apps/app/src/services/xss/index.js

@@ -10,17 +10,17 @@ class Xss {
 
     xssOption = xssOption || {}; // eslint-disable-line no-param-reassign
 
-    const tagWhiteList = xssOption.tagWhiteList || [];
-    const attrWhiteList = xssOption.attrWhiteList || [];
+    const tagWhitelist = xssOption.tagWhitelist || [];
+    const attrWhitelist = xssOption.attrWhitelist || [];
 
-    const whiteListContent = {};
+    const whitelistContent = {};
 
     // default
     const option = {
       stripIgnoreTag: true,
       stripIgnoreTagBody: false, // see https://github.com/weseek/growi/pull/505
       css: false,
-      whiteList: whiteListContent,
+      whitelist: whitelistContent,
       escapeHtml: (html) => { return html }, // resolve https://github.com/weseek/growi/issues/221
       onTag: (tag, html, options) => {
         // pass autolink
@@ -30,8 +30,8 @@ class Xss {
       },
     };
 
-    tagWhiteList.forEach((tag) => {
-      whiteListContent[tag] = attrWhiteList;
+    tagWhitelist.forEach((tag) => {
+      whitelistContent[tag] = attrWhitelist;
     });
 
     // create the XSS Filter instance

+ 9 - 8
apps/app/src/services/xss/xssOption.ts

@@ -1,31 +1,32 @@
 import { defaultSchema as sanitizeDefaultSchema } from 'rehype-sanitize';
+
 import type { RehypeSanitizeOption } from '~/interfaces/rehype';
 
-type tagWhiteList = typeof sanitizeDefaultSchema.tagNames;
-type attrWhiteList = typeof sanitizeDefaultSchema.attributes;
+type tagWhitelist = typeof sanitizeDefaultSchema.tagNames;
+type attrWhitelist = typeof sanitizeDefaultSchema.attributes;
 
 export type XssOptionConfig = {
   isEnabledXssPrevention: boolean,
   xssOption: RehypeSanitizeOption,
-  tagWhiteList: tagWhiteList,
-  attrWhiteList: attrWhiteList,
+  tagWhitelist: tagWhitelist,
+  attrWhitelist: attrWhitelist,
 }
 
 export default class XssOption {
 
   isEnabledXssPrevention: boolean;
 
-  tagWhiteList: any[];
+  tagWhitelist: any[];
 
-  attrWhiteList: any[];
+  attrWhitelist: any[];
 
   constructor(config: XssOptionConfig) {
     const recommendedWhitelist = require('~/services/xss/recommended-whitelist');
     const initializedConfig: Partial<XssOptionConfig> = (config != null) ? config : {};
 
     this.isEnabledXssPrevention = initializedConfig.isEnabledXssPrevention || true;
-    this.tagWhiteList = initializedConfig.tagWhiteList || recommendedWhitelist.tags;
-    this.attrWhiteList = initializedConfig.attrWhiteList || recommendedWhitelist.attrs;
+    this.tagWhitelist = initializedConfig.tagWhitelist || recommendedWhitelist.tags;
+    this.attrWhitelist = initializedConfig.attrWhitelist || recommendedWhitelist.attrs;
   }
 
 }

+ 2 - 2
apps/app/src/stores/context.tsx

@@ -68,8 +68,8 @@ export const useDisableLinkSharing = (initialData?: Nullable<boolean>): SWRRespo
   return useContextSWR<Nullable<boolean>, Error>('disableLinkSharing', initialData);
 };
 
-export const useRegistrationWhiteList = (initialData?: Nullable<string[]>): SWRResponse<Nullable<string[]>, Error> => {
-  return useContextSWR<Nullable<string[]>, Error>('registrationWhiteList', initialData);
+export const useRegistrationWhitelist = (initialData?: Nullable<string[]>): SWRResponse<Nullable<string[]>, Error> => {
+  return useContextSWR<Nullable<string[]>, Error>('registrationWhitelist', initialData);
 };
 
 export const useHackmdUri = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {

+ 30 - 0
apps/app/src/stores/modal.tsx

@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
 
 import { SWRResponse } from 'swr';
 
+import Linker from '~/client/models/Linker';
 import MarkdownTable from '~/client/models/MarkdownTable';
 import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
@@ -650,3 +651,32 @@ export const useTemplateModal = (): SWRResponse<TemplateModalStatus, Error> & Te
     },
   });
 };
+
+/*
+ * LinkEditModal
+ */
+type LinkEditModalStatus = {
+  isOpened: boolean,
+  defaultMarkdownLink?: Linker,
+  onSave?: (linkText: string) => void
+}
+
+type LinkEditModalUtils = {
+  open(defaultMarkdownLink: Linker, onSave: (linkText: string) => void): void,
+  close(): void,
+}
+
+export const useLinkEditModal = (): SWRResponse<LinkEditModalStatus, Error> & LinkEditModalUtils => {
+
+  const initialStatus: LinkEditModalStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<LinkEditModalStatus, Error>('linkEditModal', undefined, { fallbackData: initialStatus });
+
+  return Object.assign(swrResponse, {
+    open: (defaultMarkdownLink: Linker, onSave: (linkText: string) => void) => {
+      swrResponse.mutate({ isOpened: true, defaultMarkdownLink, onSave });
+    },
+    close: () => {
+      swrResponse.mutate({ isOpened: false });
+    },
+  });
+};

+ 27 - 0
apps/app/src/stores/page-timeline.tsx

@@ -0,0 +1,27 @@
+
+import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { IPageHasId } from '~/interfaces/page';
+
+
+type PageTimelineResult = {
+  pages: IPageHasId[],
+  totalCount: number,
+  offset: number,
+}
+export const useSWRINFxPageTimeline = (path: string | undefined, limit: number) : SWRInfiniteResponse<PageTimelineResult, Error> => {
+  return useSWRInfinite(
+    (pageIndex, previousPageData) => {
+      if (previousPageData != null && previousPageData.pages.length === 0) return null;
+      if (path === undefined) return null;
+
+      return ['/pages/list', path, pageIndex + 1, limit];
+    },
+    ([endpoint, path, page, limit]) => apiv3Get<PageTimelineResult>(endpoint, { path, page, limit }).then(response => response.data),
+    {
+      revalidateFirstPage: false,
+      revalidateAll: false,
+    },
+  );
+};

+ 0 - 92
apps/app/test/unit/services/renderer/pukiwiki-like-linker.test.ts

@@ -1,92 +0,0 @@
-import { HastNode, selectAll } from 'hast-util-select';
-import parse from 'remark-parse';
-import rehype from 'remark-rehype';
-import { unified } from 'unified';
-import { visit } from 'unist-util-visit';
-
-import { relativeLinksByPukiwikiLikeLinker } from '../../../../src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker';
-import { pukiwikiLikeLinker } from '../../../../src/services/renderer/remark-plugins/pukiwiki-like-linker';
-
-describe('pukiwikiLikeLinker', () => {
-
-  /* eslint-disable indent */
-  describe.each`
-    input                                   | expectedHref                | expectedValue
-    ${'[[/page]]'}                          | ${'/page'}                  | ${'/page'}
-    ${'[[./page]]'}                         | ${'./page'}                 | ${'./page'}
-    ${'[[Title>./page]]'}                   | ${'./page'}                 | ${'Title'}
-    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}    | ${'Title'}
-  `('should parse correctly', ({ input, expectedHref, expectedValue }) => {
-  /* eslint-enable indent */
-
-    test(`when the input is '${input}'`, () => {
-      // setup:
-      const processor = unified()
-        .use(parse)
-        .use(pukiwikiLikeLinker);
-
-      // when:
-      const ast = processor.parse(input);
-
-      expect(ast).not.toBeNull();
-
-      visit(ast, 'wikiLink', (node: any) => {
-        expect(node.data.alias).toEqual(expectedValue);
-        expect(node.data.permalink).toEqual(expectedHref);
-        expect(node.data.hName).toEqual('a');
-        expect(node.data.hProperties.className.startsWith('pukiwiki-like-linker')).toBeTruthy();
-        expect(node.data.hProperties.href).toEqual(expectedHref);
-        expect(node.data.hChildren[0].value).toEqual(expectedValue);
-      });
-
-    });
-  });
-
-});
-
-
-describe('relativeLinksByPukiwikiLikeLinker', () => {
-
-  /* eslint-disable indent */
-  describe.each`
-    input                                   | expectedHref                | expectedValue
-    ${'[[/page]]'}                          | ${'/page'}                  | ${'/page'}
-    ${'[[./page]]'}                         | ${'/user/admin/page'}       | ${'./page'}
-    ${'[[Title>./page]]'}                   | ${'/user/admin/page'}       | ${'Title'}
-    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}    | ${'Title'}
-  `('should convert relative links correctly', ({ input, expectedHref, expectedValue }) => {
-  /* eslint-enable indent */
-
-    test(`when the input is '${input}'`, () => {
-      // setup:
-      const processor = unified()
-        .use(parse)
-        .use(pukiwikiLikeLinker)
-        .use(rehype)
-        .use(relativeLinksByPukiwikiLikeLinker, { pagePath: '/user/admin' });
-
-      // when:
-      const mdast = processor.parse(input);
-      const hast = processor.runSync(mdast);
-
-      expect(hast).not.toBeNull();
-      expect((hast as any).children[0].type).toEqual('element');
-
-      const anchors = selectAll('a', hast as HastNode);
-
-      expect(anchors.length).toEqual(1);
-
-      const anchor = anchors[0];
-
-      expect(anchor.tagName).toEqual('a');
-      expect((anchor.properties as any).className.startsWith('pukiwiki-like-linker')).toBeTruthy();
-      expect(anchor.properties?.href).toEqual(expectedHref);
-
-      expect(anchor.children[0]).not.toBeNull();
-      expect(anchor.children[0].type).toEqual('text');
-      expect(anchor.children[0].value).toEqual(expectedValue);
-
-    });
-  });
-
-});

+ 7 - 0
apps/app/vitest.config.unit.ts

@@ -0,0 +1,7 @@
+import { defineProject } from 'vitest/config';
+
+export default defineProject({
+  test: {
+    environment: 'node',
+  },
+});

+ 2 - 2
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "6.1.1",
+  "version": "6.1.2-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^6.1.1",
+    "@growi/slack": "^6.1.2-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 0 - 0
bin/data-migrations/v6/README.md → bin/data-migrations/README.md


+ 13 - 17
bin/data-migrations/v6/src/migration.js → bin/data-migrations/src/index.js

@@ -5,35 +5,30 @@
 var pagesCollection = db.getCollection('pages');
 var revisionsCollection = db.getCollection('revisions');
 
-var getProcessorArray = require('./processor.js');
+var batchSize = process.env.BATCH_SIZE ?? 100; // default 100 revisions in 1 bulkwrite
+var batchSizeInterval = process.env.BATCH_INTERVAL ?? 3000; // default 3 sec
 
-var migrationType = process.env.MIGRATION_TYPE;
-var processors = getProcessorArray(migrationType);
+var migrationModule = process.env.MIGRATION_MODULE;
 
-var operations = [];
+var migrationModules = require(`./migrations/${migrationModule}`);
 
-var batchSize = process.env.BATCH_SIZE ?? 100; // default 100 revisions in 1 bulkwrite
-var batchSizeInterval = process.env.BATCH_INTERVAL ?? 3000; // default 3 sec
+if (migrationModules.length === 0) {
+  throw Error('No valid migrationModules found. Please enter a valid environment variable');
+}
 
-// ===========================================
-// replace method with processors
-// ===========================================
-function replaceLatestRevisions(body, processors) {
+function replaceLatestRevisions(body, migrationModules) {
   var replacedBody = body;
-  processors.forEach((processor) => {
-    replacedBody = processor(replacedBody);
+  migrationModules.forEach((migrationModule) => {
+    replacedBody = migrationModule(replacedBody);
   });
   return replacedBody;
 }
 
-if (processors.length === 0) {
-  throw Error('No valid processors found. Please enter a valid environment variable');
-}
-
+var operations = [];
 pagesCollection.find({}).forEach((doc) => {
   if (doc.revision) {
     var revision = revisionsCollection.findOne({ _id: doc.revision });
-    var replacedBody = replaceLatestRevisions(revision.body, [...processors]);
+    var replacedBody = replaceLatestRevisions(revision.body, [...migrationModules]);
     var operation = {
       updateOne: {
         filter: { _id: revision._id },
@@ -54,4 +49,5 @@ pagesCollection.find({}).forEach((doc) => {
   }
 });
 revisionsCollection.bulkWrite(operations);
+
 print('migration complete!');

+ 8 - 0
bin/data-migrations/src/migrations/custom.js

@@ -0,0 +1,8 @@
+module.exports = [
+  (body) => {
+    // processor for MIGRATION_MODULE=custom
+    // ADD YOUR PROCESS HERE!
+    // https://github.com/weseek/growi/discussions/7180
+    return body;
+  },
+];

+ 8 - 0
bin/data-migrations/src/migrations/v60x/bracketlink.js

@@ -0,0 +1,8 @@
+module.exports = [
+  (body) => {
+    // https://regex101.com/r/btZ4hc/1
+    // eslint-disable-next-line regex/invalid
+    const oldBracketLinkRegExp = /(?<!\[)\[{1}(\/.*?)\]{1}(?!\])/g; // Page Link old format
+    return body.replace(oldBracketLinkRegExp, '[[$1]]');
+  },
+];

+ 6 - 0
bin/data-migrations/src/migrations/v60x/csv.js

@@ -0,0 +1,6 @@
+module.exports = [
+  (body) => {
+    const oldCsvTableRegExp = /::: csv(-h)?\n([\s\S]*?)\n:::/g; // CSV old format
+    return body.replace(oldCsvTableRegExp, '``` csv$1\n$2\n```');
+  },
+];

+ 6 - 0
bin/data-migrations/src/migrations/v60x/drawio.js

@@ -0,0 +1,6 @@
+module.exports = [
+  (body) => {
+    const oldDrawioRegExp = /:::\s?drawio\n(.+?)\n:::/g; // drawio old format
+    return body.replace(oldDrawioRegExp, '``` drawio\n$1\n```');
+  },
+];

+ 6 - 0
bin/data-migrations/src/migrations/v60x/index.js

@@ -0,0 +1,6 @@
+const bracketlink = require('./bracketlink');
+const csv = require('./csv');
+const plantUML = require('./plantuml');
+const tsv = require('./tsv');
+
+module.exports = [...bracketlink, ...csv, ...plantUML, ...tsv];

+ 6 - 0
bin/data-migrations/src/migrations/v60x/plantuml.js

@@ -0,0 +1,6 @@
+module.exports = [
+  (body) => {
+    const oldDrawioRegExp = /:::\s?drawio\n(.+?)\n:::/g; // drawio old format
+    return body.replace(oldDrawioRegExp, '``` drawio\n$1\n```');
+  },
+];

+ 6 - 0
bin/data-migrations/src/migrations/v60x/tsv.js

@@ -0,0 +1,6 @@
+module.exports = [
+  (body) => {
+    const oldTsvTableRegExp = /::: tsv(-h)?\n([\s\S]*?)\n:::/g; // TSV old format
+    return body.replace(oldTsvTableRegExp, '``` tsv$1\n$2\n```');
+  },
+];

+ 3 - 0
bin/data-migrations/src/migrations/v61x/index.js

@@ -0,0 +1,3 @@
+const mdcont = require('./mdcont');
+
+module.exports = [...mdcont];

+ 6 - 0
bin/data-migrations/src/migrations/v61x/mdcont.js

@@ -0,0 +1,6 @@
+module.exports = [
+  (body) => {
+    const oldMdcontPrefixRegExp = /#mdcont-/g;
+    return body.replace(oldMdcontPrefixRegExp, '#');
+  },
+];

+ 0 - 83
bin/data-migrations/v6/src/processor.js

@@ -1,83 +0,0 @@
-
-/* eslint-disable no-undef, no-var, vars-on-top, no-restricted-globals, regex/invalid */
-// ignore lint error because this file is js as mongoshell
-
-// ===========================================
-// processors for old format
-// ===========================================
-function drawioProcessor(body) {
-  var oldDrawioRegExp = /:::\s?drawio\n(.+?)\n:::/g; // drawio old format
-  return body.replace(oldDrawioRegExp, '``` drawio\n$1\n```');
-}
-
-function plantumlProcessor(body) {
-  var oldPlantUmlRegExp = /@startuml\n([\s\S]*?)\n@enduml/g; // plantUML old format
-  return body.replace(oldPlantUmlRegExp, '``` plantuml\n$1\n```');
-}
-
-function tsvProcessor(body) {
-  var oldTsvTableRegExp = /::: tsv(-h)?\n([\s\S]*?)\n:::/g; // TSV old format
-  return body.replace(oldTsvTableRegExp, '``` tsv$1\n$2\n```');
-}
-
-function csvProcessor(body) {
-  var oldCsvTableRegExp = /::: csv(-h)?\n([\s\S]*?)\n:::/g; // CSV old format
-  return body.replace(oldCsvTableRegExp, '``` csv$1\n$2\n```');
-}
-
-function bracketlinkProcessor(body) {
-  // https://regex101.com/r/btZ4hc/1
-  var oldBracketLinkRegExp = /(?<!\[)\[{1}(\/.*?)\]{1}(?!\])/g; // Page Link old format
-  return body.replace(oldBracketLinkRegExp, '[[$1]]');
-}
-
-function mdcontPrefixProcessor(body) {
-  var oldMdcontPrefixRegExp = /#mdcont-/g;
-  return body.replace(oldMdcontPrefixRegExp, '#');
-}
-
-// processor for MIGRATION_TYPE=custom
-function customProcessor(body) {
-  // ADD YOUR PROCESS HERE!
-  // https://github.com/weseek/growi/discussions/7180
-  return body;
-}
-
-// ===========================================
-// define processors
-// ===========================================
-
-function getProcessorArray(migrationType) {
-  var oldFormatProcessors;
-  switch (migrationType) {
-    case 'v6-drawio':
-      oldFormatProcessors = [drawioProcessor];
-      break;
-    case 'v6-plantuml':
-      oldFormatProcessors = [plantumlProcessor];
-      break;
-    case 'v6-tsv':
-      oldFormatProcessors = [tsvProcessor];
-      break;
-    case 'v6-csv':
-      oldFormatProcessors = [csvProcessor];
-      break;
-    case 'v6-bracketlink':
-      oldFormatProcessors = [bracketlinkProcessor];
-      break;
-    case 'mdcont':
-      oldFormatProcessors = [mdcontPrefixProcessor];
-      break;
-    case 'v6':
-      oldFormatProcessors = [drawioProcessor, plantumlProcessor, tsvProcessor, csvProcessor, bracketlinkProcessor];
-      break;
-    case 'custom':
-      oldFormatProcessors = [customProcessor];
-      break;
-    default:
-      oldFormatProcessors = [];
-  }
-  return oldFormatProcessors;
-}
-
-module.exports = getProcessorArray;

+ 9 - 5
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "6.1.1",
+  "version": "6.1.2-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -60,15 +60,17 @@
     "@swc/jest": "^0.2.24",
     "@testing-library/cypress": "^8.0.2",
     "@types/css-modules": "^1.0.2",
+    "@types/eslint": "^8.37.0",
+    "@types/estree": "^1.0.1",
     "@types/jest": "^26.0.22",
     "@types/node": "^17.0.43",
     "@types/rewire": "^2.5.28",
-    "@typescript-eslint/eslint-plugin": "^5.54.0",
-    "@typescript-eslint/parser": "^5.54.0",
+    "@typescript-eslint/eslint-plugin": "^5.59.7",
+    "@typescript-eslint/parser": "^5.59.7",
     "@vitejs/plugin-react": "^3.1.0",
     "cypress": "^12.0.1",
     "cypress-wait-until": "^1.7.2",
-    "eslint": "^8.35.0",
+    "eslint": "^8.41.0",
     "eslint-config-next": "^12.1.6",
     "eslint-config-weseek": "^2.1.1",
     "eslint-import-resolver-typescript": "^3.2.5",
@@ -76,6 +78,7 @@
     "eslint-plugin-jest": "^26.5.3",
     "eslint-plugin-react": "^7.30.1",
     "eslint-plugin-react-hooks": "^4.6.0",
+    "eslint-plugin-rulesdir": "^0.2.2",
     "glob": "^8.1.0",
     "jest": "^28.1.3",
     "jest-date-mock": "^1.0.8",
@@ -95,7 +98,8 @@
     "typescript": "~4.9",
     "unplugin-swc": "^1.3.2",
     "vite": "^4.2.2",
-    "vite-plugin-dts": "^2.0.0-beta.0"
+    "vite-plugin-dts": "^2.0.0-beta.0",
+    "vitest": "^0.31.1"
   },
   "engines": {
     "node": "^16 || ^18",

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "6.1.1",
+  "version": "6.1.2-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/hackmd/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/hackmd",
-  "version": "6.1.1",
+  "version": "6.1.2-RC.0",
   "description": "GROWI js and css files to use hackmd",
   "license": "MIT",
   "type": "module",

+ 2 - 2
packages/presentation/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/presentation",
-  "version": "6.1.1",
+  "version": "6.1.2-RC.0",
   "description": "GROWI plugin for presentation",
   "license": "MIT",
   "keywords": ["growi", "growi-plugin"],
@@ -18,7 +18,7 @@
     "lint": "run-p lint:*"
   },
   "dependencies": {
-    "@growi/core": "^6.1.1"
+    "@growi/core": "^6.1.2-RC.0"
   },
   "devDependencies": {
     "@marp-team/marp-core": "^3.6.0",

+ 1 - 1
packages/preset-themes/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@growi/preset-themes",
   "description": "GROWI preset themes",
-  "version": "6.1.1",
+  "version": "6.1.2-RC.0",
   "license": "MIT",
   "main": "dist/libs/preset-themes.umd.js",
   "module": "dist/libs/preset-themes.mjs",

+ 4 - 4
packages/remark-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-attachment-refs",
-  "version": "6.1.1",
+  "version": "6.1.2-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": ["growi", "growi-plugin"],
@@ -26,9 +26,9 @@
   "dependencies": {
     "bunyan": "^1.8.15",
     "universal-bunyan": "^0.9.2",
-    "@growi/core": "^6.1.1",
-    "@growi/remark-growi-directive": "^6.1.1",
-    "@growi/ui": "^6.1.1"
+    "@growi/core": "^6.1.2-RC.0",
+    "@growi/remark-growi-directive": "^6.1.2-RC.0",
+    "@growi/ui": "^6.1.2-RC.0"
   },
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",

+ 1 - 1
packages/remark-drawio/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-drawio",
-  "version": "6.1.1",
+  "version": "6.1.2-RC.0",
   "description": "remark plugin to draw diagrams with draw.io (diagrams.net)",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/remark-growi-directive/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-growi-directive",
-  "version": "6.1.1",
+  "version": "6.1.2-RC.0",
   "description": "remark plugin to support GROWI plugin (forked from remark-directive@2.0.1)",
   "license": "MIT",
   "keywords": [

+ 4 - 4
packages/remark-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-lsx",
-  "version": "6.1.1",
+  "version": "6.1.2-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": ["growi", "growi-plugin"],
@@ -25,9 +25,9 @@
     "escape-string-regexp": "5.0.0 or above exports only ESM"
   },
   "dependencies": {
-    "@growi/core": "^6.1.1",
-    "@growi/remark-growi-directive": "^6.1.1",
-    "@growi/ui": "^6.1.1",
+    "@growi/core": "^6.1.2-RC.0",
+    "@growi/remark-growi-directive": "^6.1.2-RC.0",
+    "@growi/ui": "^6.1.2-RC.0",
     "escape-string-regexp": "^4.0.0",
     "swr": "^2.0.3"
   },

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "6.1.1",
+  "version": "6.1.2-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "module": "dist/index.mjs",

+ 2 - 2
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "6.1.1",
+  "version": "6.1.2-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": ["growi"],
@@ -16,7 +16,7 @@
     "lint": "npm-run-all -p lint:*"
   },
   "dependencies": {
-    "@growi/core": "^6.1.1"
+    "@growi/core": "^6.1.2-RC.0"
   },
   "devDependencies": {
     "react": "^18.2.0"

+ 4 - 0
vitest.workspace.ts

@@ -0,0 +1,4 @@
+export default [
+  'apps/*/vitest.config.{e2e,unit}.ts',
+  'packages/*/vitest.config.{e2e,unit}.ts',
+];

Разница между файлами не показана из-за своего большого размера
+ 494 - 64
yarn.lock


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