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

Merge pull request #7233 from weseek/master

Release v6.0.3
Yuki Takei 3 лет назад
Родитель
Сommit
d12a7528fe
100 измененных файлов с 2828 добавлено и 1124 удалено
  1. 0 2
      .github/workflows/ci-app.yml
  2. 0 1
      .github/workflows/ci-slackbot-proxy.yml
  3. 53 34
      .github/workflows/release-rc.yml
  4. 64 37
      .github/workflows/release.yml
  5. 56 0
      .github/workflows/reusable-app-build-image.yml
  6. 52 0
      .github/workflows/reusable-app-create-manifests.yml
  7. 1 1
      .github/workflows/reusable-app-prod.yml
  8. 5 0
      .gitignore
  9. 1 3
      .mergify.yml
  10. 1 1
      lerna.json
  11. 1 1
      package.json
  12. 0 1
      packages/app/.env.development
  13. 1 0
      packages/app/config/logger/config.dev.js
  14. 5 5
      packages/app/docker/Dockerfile
  15. 65 0
      packages/app/docker/codebuild/.terraform.lock.hcl
  16. 32 0
      packages/app/docker/codebuild/buildspec.yml
  17. 25 0
      packages/app/docker/codebuild/codebuild.tf
  18. 23 0
      packages/app/docker/codebuild/main.tf
  19. 26 0
      packages/app/docker/codebuild/oidc.tf
  20. 15 0
      packages/app/docker/codebuild/secretsmanager.tf
  21. 0 6
      packages/app/docker/nocdn/.env.production.local
  22. 13 11
      packages/app/package.json
  23. 12 1
      packages/app/public/static/locales/en_US/admin.json
  24. 10 0
      packages/app/public/static/locales/en_US/commons.json
  25. 1 0
      packages/app/public/static/locales/en_US/translation.json
  26. 11 0
      packages/app/public/static/locales/ja_JP/admin.json
  27. 10 0
      packages/app/public/static/locales/ja_JP/commons.json
  28. 1 0
      packages/app/public/static/locales/ja_JP/translation.json
  29. 12 1
      packages/app/public/static/locales/zh_CN/admin.json
  30. 10 0
      packages/app/public/static/locales/zh_CN/commons.json
  31. 2 0
      packages/app/public/static/locales/zh_CN/translation.json
  32. 15 0
      packages/app/src/client/services/g2g-transfer.ts
  33. 31 38
      packages/app/src/components/Admin/App/AwsSetting.tsx
  34. 158 29
      packages/app/src/components/Admin/App/FileUploadSetting.tsx
  35. 37 34
      packages/app/src/components/Admin/App/GcsSetting.tsx
  36. 4 1
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  37. 3 4
      packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx
  38. 0 250
      packages/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.jsx
  39. 232 0
      packages/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx
  40. 0 261
      packages/app/src/components/Admin/ExportArchiveDataPage.jsx
  41. 198 0
      packages/app/src/components/Admin/ExportArchiveDataPage.tsx
  42. 284 0
      packages/app/src/components/Admin/G2GDataTransfer.tsx
  43. 237 0
      packages/app/src/components/Admin/G2GDataTransferExportForm.tsx
  44. 43 0
      packages/app/src/components/Admin/G2GDataTransferStatusIcon.tsx
  45. 4 2
      packages/app/src/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx
  46. 6 8
      packages/app/src/components/Admin/MarkdownSetting/WhiteListInput.jsx
  47. 1 4
      packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx
  48. 1 25
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  49. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx
  50. 38 0
      packages/app/src/components/Common/CustomCopyToClipBoard.tsx
  51. 42 0
      packages/app/src/components/DataTransferForm.tsx
  52. 33 1
      packages/app/src/components/Fab.module.scss
  53. 50 34
      packages/app/src/components/Fab.tsx
  54. 2 0
      packages/app/src/components/Layout/AdminLayout.tsx
  55. 1 1
      packages/app/src/components/Layout/BasicLayout.tsx
  56. 2 1
      packages/app/src/components/Layout/RawLayout.tsx
  57. 1 1
      packages/app/src/components/Layout/ShareLinkLayout.tsx
  58. 3 3
      packages/app/src/components/Navbar/AppearanceModeDropdown.tsx
  59. 33 31
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  60. 0 9
      packages/app/src/components/Navbar/GrowiSubNavigation.module.scss
  61. 0 141
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx
  62. 9 0
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.module.scss
  63. 108 0
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.tsx
  64. 1 1
      packages/app/src/components/Page.tsx
  65. 8 2
      packages/app/src/components/PageComment/Comment.tsx
  66. 5 2
      packages/app/src/components/PageRenameModal.tsx
  67. 6 2
      packages/app/src/components/SearchPage/SearchControl.tsx
  68. 8 0
      packages/app/src/components/SearchPage/SearchResultContent.module.scss
  69. 3 1
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  70. 11 0
      packages/app/src/components/SearchTypeahead.tsx
  71. 1 0
      packages/app/src/components/ShareLink/ShareLink.tsx
  72. 1 1
      packages/app/src/components/ShareLink/ShareLinkForm.tsx
  73. 3 0
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  74. 20 20
      packages/app/src/components/StickyStretchableScroller.tsx
  75. 14 3
      packages/app/src/components/TagCloudBox.tsx
  76. 4 1
      packages/app/src/components/TagList.tsx
  77. 1 1
      packages/app/src/interfaces/customize.ts
  78. 23 0
      packages/app/src/interfaces/g2g-transfer.ts
  79. 6 0
      packages/app/src/interfaces/transfer-key.ts
  80. 51 12
      packages/app/src/pages/[[...path]].page.tsx
  81. 5 2
      packages/app/src/pages/_app.page.tsx
  82. 6 51
      packages/app/src/pages/_document.page.tsx
  83. 3 2
      packages/app/src/pages/_private-legacy-pages.page.tsx
  84. 3 2
      packages/app/src/pages/_search.page.tsx
  85. 54 0
      packages/app/src/pages/admin/data-transfer.page.tsx
  86. 25 4
      packages/app/src/pages/installer.page.tsx
  87. 3 2
      packages/app/src/pages/me/[[...path]].page.tsx
  88. 26 7
      packages/app/src/pages/share/[[...path]].page.tsx
  89. 3 2
      packages/app/src/pages/tags.page.tsx
  90. 3 2
      packages/app/src/pages/trash.page.tsx
  91. 7 3
      packages/app/src/pages/utils/commons.ts
  92. 15 1
      packages/app/src/server/crowi/index.js
  93. 1 1
      packages/app/src/server/models/config.ts
  94. 29 0
      packages/app/src/server/models/transfer-key.ts
  95. 5 1
      packages/app/src/server/models/user.js
  96. 34 0
      packages/app/src/server/models/vo/g2g-transfer-error.ts
  97. 1 0
      packages/app/src/server/routes/apiv3/customize-setting.js
  98. 332 0
      packages/app/src/server/routes/apiv3/g2g-transfer.ts
  99. 16 12
      packages/app/src/server/routes/apiv3/import.js
  100. 6 2
      packages/app/src/server/routes/apiv3/index.js

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

@@ -5,8 +5,6 @@ on:
     branches-ignore:
       - release/**
       - rc/**
-      - chore/**
-      - support/prepare-v**
     paths:
       - .github/workflows/ci-app.yml
       - .eslint*

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

@@ -5,7 +5,6 @@ on:
     branches-ignore:
       - release/**
       - rc/**
-      - chore/**
       - support/prepare-v**
     paths:
       - .github/workflows/ci-slackbot-proxy.yml

+ 53 - 34
.github/workflows/release-rc.yml

@@ -5,60 +5,79 @@ on:
     branches:
       - rc/**
 
-jobs:
 
-  build-rc:
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+
+jobs:
 
+  determine-tags:
     runs-on: ubuntu-latest
 
-    strategy:
-      matrix:
-        platform: [linux/amd64, linux/arm64]
+    outputs:
+      TAGS: ${{ steps.meta.outputs.tags }}
+      TAGS_GHCR: ${{ steps.meta-ghcr.outputs.tags }}
 
     steps:
     - uses: actions/checkout@v3
-      with:
-        lfs: true
 
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@1.2.0
       id: package-json
 
-    - name: Docker meta
+    - name: Docker meta for docker.io
+      uses: docker/metadata-action@v4
       id: meta
+      with:
+        images: docker.io/weseek/growi
+        sep-tags: ','
+        tags: |
+          type=raw,value=${{ steps.package-json.outputs.packageVersion }}
+          type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
+
+    - name: Docker meta for ghcr.io
       uses: docker/metadata-action@v4
+      id: meta-ghcr
       with:
-        images: weseek/growi,ghcr.io/weseek/growi
+        images: ghcr.io/weseek/growi
+        sep-tags: ','
         tags: |
           type=raw,value=${{ steps.package-json.outputs.packageVersion }}
           type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
 
-    - name: Login to docker.io registry
-      run: |
-        echo ${{ secrets. DOCKER_REGISTRY_PASSWORD }} | docker login --username wsmoogle --password-stdin
 
-    - name: Login to GitHub Container Registry
-      uses: docker/login-action@v2
-      with:
-        registry: ghcr.io
-        username: wsmoogle
-        password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
+  build-image-rc:
+    uses: weseek/growi/.github/workflows/reusable-app-build-image.yml@master
+    with:
+      image-name: weseek/growi
+      tag-temporary: latest-rc
+    secrets:
+      AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
 
-    - name: Set up QEMU
-      if: ${{ matrix.platform == 'linux/arm64' }}
-      uses: docker/setup-qemu-action@v1
 
-    - name: Set up Docker Buildx
-      uses: docker/setup-buildx-action@v2
+  publish-image-rc:
+    needs: [determine-tags, build-image-rc]
+
+    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    with:
+      tags: ${{ needs.determine-tags.outputs.TAGS }}
+      registry: docker.io
+      image-name: weseek/growi
+      tag-temporary: latest-rc
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+
+  publish-image-rc-ghcr:
+    needs: [determine-tags, build-image-rc]
+
+    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    with:
+      tags: ${{ needs.determine-tags.outputs.TAGS_GHCR }}
+      registry: ghcr.io
+      image-name: weseek/growi
+      tag-temporary: latest-rc
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
 
-    - name: Build and push
-      uses: docker/build-push-action@v2
-      with:
-        context: .
-        file: ./packages/app/docker/Dockerfile
-        platforms: ${{ matrix.platform }}
-        push: true
-        builder: ${{ steps.buildx.outputs.name }}
-        cache-from: type=gha
-        cache-to: type=gha,mode=max
-        tags: ${{ steps.meta.outputs.tags }}

+ 64 - 37
.github/workflows/release.yml

@@ -121,63 +121,91 @@ jobs:
         github_token: ${{ secrets.GITHUB_TOKEN }}
 
 
-  build-image:
+  determine-tags:
     needs: create-github-release
-
     runs-on: ubuntu-latest
 
-    strategy:
-      matrix:
-        platform: [linux/amd64, linux/arm64]
+    outputs:
+      TAGS: ${{ steps.meta.outputs.tags }}
+      TAGS_GHCR: ${{ steps.meta-ghcr.outputs.tags }}
 
     steps:
     - uses: actions/checkout@v3
-      with:
-        ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
-        lfs: true
 
-    - name: Docker meta
-      id: meta
+    - name: Retrieve information from package.json
+      uses: myrotvorets/info-from-package-json-action@1.2.0
+      id: package-json
+
+    - name: Docker meta for docker.io
       uses: docker/metadata-action@v4
+      id: meta
       with:
-        images: weseek/growi,ghcr.io/weseek/growi
+        images: docker.io/weseek/growi
+        sep-tags: ','
         tags: |
           type=raw,value=latest
           type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}
           type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}
           type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}.{{patch}}
 
-    - name: Login to Docker Hub
-      uses: docker/login-action@v2
+    - name: Docker meta for ghcr.io
+      uses: docker/metadata-action@v4
+      id: meta-ghcr
       with:
-        username: wsmoogle
-        password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+        images: ghcr.io/weseek/growi
+        sep-tags: ','
+        tags: |
+          type=raw,value=latest
+          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}
+          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}
+          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}.{{patch}}
 
-    - name: Login to GitHub Container Registry
-      uses: docker/login-action@v2
-      with:
-        registry: ghcr.io
-        username: wsmoogle
-        password: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
 
-    - name: Set up QEMU
-      if: ${{ matrix.platform == 'linux/arm64' }}
-      uses: docker/setup-qemu-action@v1
+  build-image:
+    needs: create-github-release
 
-    - name: Set up Docker Buildx
-      uses: docker/setup-buildx-action@v2
+    uses: weseek/growi/.github/workflows/reusable-app-build-image.yml@master
+    with:
+      source-version: refs/tags/v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
+      image-name: weseek/growi
+      tag-temporary: latest
+    secrets:
+      AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
+
+
+  publish-image:
+    needs: [determine-tags, build-image]
+
+    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    with:
+      tags: ${{ needs.determine-tags.outputs.TAGS }}
+      registry: docker.io
+      image-name: weseek/growi
+      tag-temporary: latest
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+
+  publish-image-ghcr:
+    needs: [determine-tags, build-image]
+
+    uses: weseek/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    with:
+      tags: ${{ needs.determine-tags.outputs.TAGS_GHCR }}
+      registry: ghcr.io
+      image-name: weseek/growi
+      tag-temporary: latest
+    secrets:
+      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_ON_GITHUB_PASSWORD }}
+
+
+  post-publish:
+    needs: [create-github-release, publish-image, publish-image-ghcr]
+    runs-on: ubuntu-latest
 
-    - name: Build and push
-      uses: docker/build-push-action@v3
+    steps:
+    - uses: actions/checkout@v3
       with:
-        context: .
-        file: ./packages/app/docker/Dockerfile
-        platforms: ${{ matrix.platform }}
-        push: true
-        builder: ${{ steps.buildx.outputs.name }}
-        cache-from: type=gha
-        cache-to: type=gha,mode=max
-        tags: ${{ steps.meta.outputs.tags }}
+        ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
 
     - name: Update Docker Hub Description
       uses: peter-evans/dockerhub-description@v3
@@ -193,7 +221,6 @@ jobs:
         channel: '#release'
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
         created_tag: 'v${{ needs.create-github-release.outputs.RELEASED_VERSION }}'
-        message: '*Release v${{ needs.create-github-release.outputs.RELEASED_VERSION }} (${{ matrix.platform }})* Succeeded'
 
     - name: Check whether workspace is clean
       run: |

+ 56 - 0
.github/workflows/reusable-app-build-image.yml

@@ -0,0 +1,56 @@
+name: Reusable build app container image workflow
+
+on:
+  workflow_call:
+    inputs:
+      source-version:
+        type: string
+        default: ${{ github.sha }}
+      image-name:
+        type: string
+        default: weseek/growi
+      tag-temporary:
+        type: string
+        default: latest
+    secrets:
+      AWS_ROLE_TO_ASSUME_FOR_OIDC:
+        required: true
+
+
+
+jobs:
+
+  build-image:
+    runs-on: ubuntu-latest
+
+    # These permissions are needed to interact with GitHub's OIDC Token endpoint.
+    permissions:
+      id-token: write
+      contents: write
+
+    strategy:
+      matrix:
+        platform: [amd64, arm64]
+
+    steps:
+    - uses: actions/checkout@v3
+
+    - name: Configure AWS Credentials
+      uses: aws-actions/configure-aws-credentials@v1
+      with:
+        aws-region: ap-northeast-1
+        role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
+        role-session-name: SessionForReleaseGROWI-RC
+
+    - name: Run CodeBuild
+      uses: dark-mechanicum/aws-codebuild@v1
+      with:
+        projectName: growi-official-image-builder
+      env:
+        CODEBUILD__sourceVersion: ${{ inputs.source-version }}
+        CODEBUILD__imageOverride: ${{ (matrix.platform == 'amd64' && 'aws/codebuild/standard:6.0') || 'aws/codebuild/amazonlinux2-aarch64-standard:2.0' }}
+        CODEBUILD__environmentTypeOverride: ${{ (matrix.platform == 'amd64' && 'LINUX_CONTAINER') || 'ARM_CONTAINER' }}
+        CODEBUILD__environmentVariablesOverride: '[
+          { "name": "IMAGE_TAG", "type": "PLAINTEXT", "value": "docker.io/${{ inputs.image-name }}:${{ inputs.tag-temporary }}-${{ matrix.platform }}" },
+          { "name": "IMAGE_TAG_GHCR", "type": "PLAINTEXT", "value": "ghcr.io/${{ inputs.image-name }}:${{ inputs.tag-temporary }}-${{ matrix.platform }}" }
+        ]'

+ 52 - 0
.github/workflows/reusable-app-create-manifests.yml

@@ -0,0 +1,52 @@
+name: Reusable create app container image manifests workflow
+
+on:
+  workflow_call:
+    inputs:
+      tags:
+        type: string
+        required: true
+      registry:
+        type: string
+        default: 'docker.io'
+      image-name:
+        type: string
+        default: weseek/growi
+      tag-temporary:
+        type: string
+        default: latest
+    secrets:
+      DOCKER_REGISTRY_PASSWORD:
+        required: true
+
+
+
+jobs:
+
+  create-manifest:
+    runs-on: ubuntu-latest
+
+    steps:
+    - name: Docker meta for extra-images
+      id: meta-extra-images
+      uses: docker/metadata-action@v4
+      with:
+        images: ${{ inputs.registry }}/${{ inputs.image-name }}
+        sep-tags: ','
+        tags: |
+          type=raw,value=${{ inputs.tag-temporary }}-amd64
+          type=raw,value=${{ inputs.tag-temporary }}-arm64
+
+    - name: Login to Container Registry
+      uses: docker/login-action@v2
+      with:
+        registry: ${{ inputs.registry }}
+        username: wsmoogle
+        password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
+
+    - name: Create and push manifest images
+      uses: Noelware/docker-manifest-action@master
+      with:
+        base-image: ${{ inputs.tags }}
+        extra-images: ${{ steps.meta-extra-images.outputs.tags }}
+        push: true

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

@@ -196,7 +196,7 @@ jobs:
       fail-fast: false
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['10', '20', '21', '30', '40', '50', '60']
+        spec-group: ['10', '20', '21', '22', '30', '40', '50', '60']
 
     services:
       mongodb:

+ 5 - 0
.gitignore

@@ -29,6 +29,11 @@ yarn-error.log*
 # typescript
 *.tsbuildinfo
 
+# Terraform
+**/.terraform/*
+# *.tfstate
+*.tfstate.*
+
 # IDE, dev #
 .idea
 *.orig

+ 1 - 3
.mergify.yml

@@ -16,9 +16,7 @@ pull_request_rules:
     conditions:
       - author = github-actions[bot]
       - '#approved-reviews-by >= 1'
-      - check-skipped = "test (16.x)"
-      - check-skipped = "test-prod-node14 / launch-prod"
-      - check-skipped = "test-prod-node16 / launch-prod"
+      - label = "prepare next version"
     actions:
       merge:
         method: merge

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "6.0.2",
+  "version": "6.0.3-RC.0",
   "packages": [
     "packages/*"
   ]

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "6.0.2",
+  "version": "6.0.3-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 0 - 1
packages/app/.env.development

@@ -7,7 +7,6 @@ MIGRATIONS_DIR=src/migrations/
 APP_SITE_URL=http://localhost:3000
 FILE_UPLOAD=mongodb
 # MONGO_GRIDFS_TOTAL_LIMIT=10485760
-# NO_CDN=true
 MONGO_URI="mongodb://mongo:27017/growi"
 # REDIS_URI="http://redis:6379"
 # NCHAN_URI="http://nchan"

+ 1 - 0
packages/app/config/logger/config.dev.js

@@ -27,6 +27,7 @@ module.exports = {
   'growi-plugin:*': 'debug',
   // 'growi:InterceptorManager': 'debug',
   'growi:service:search-delegator:elasticsearch': 'debug',
+  'growi:service:g2g-transfer': 'debug',
 
   /*
    * configure level for client

+ 5 - 5
packages/app/docker/Dockerfile

@@ -95,16 +95,16 @@ WORKDIR ${optDir}
 # ignore eslint and stylelint
 COPY ["package.json", "lerna.json", "tsconfig.base.json", "./"]
 # copy all related packages
-COPY packages/app packages/app
-COPY packages/core packages/core
 COPY packages/codemirror-textlint packages/codemirror-textlint
+COPY packages/preset-themes packages/preset-themes
 COPY packages/slack packages/slack
-COPY packages/ui packages/ui
+COPY packages/hackmd packages/hackmd
 COPY packages/remark-drawio packages/remark-drawio
 COPY packages/remark-growi-directive packages/remark-growi-directive
 COPY packages/remark-lsx packages/remark-lsx
-COPY packages/hackmd packages/hackmd
-COPY packages/preset-themes packages/preset-themes
+COPY packages/ui packages/ui
+COPY packages/core packages/core
+COPY packages/app packages/app
 
 # build
 RUN yarn lerna run build

+ 65 - 0
packages/app/docker/codebuild/.terraform.lock.hcl

@@ -0,0 +1,65 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/hashicorp/aws" {
+  version     = "4.49.0"
+  constraints = "~> 4.16"
+  hashes = [
+    "h1:oOwWQpvQWd1uVP1axBz/TO6xzzLWoL982AY/MQfeF7I=",
+    "zh:09803937f00fdf2873eccf685eec7854408925cbf183c9b683df7c5825be463f",
+    "zh:2af1575e538fb0b669266f8d1385b17bfdaf17c521b6b6329baa1f2971fc4a4d",
+    "zh:3f71882b438cde3108fe68cfe2637839d3eed08157a9721bd97babf3912247a8",
+    "zh:577af1b38f5da8a9f29eebe5eebec9279d26e757cd03b0c8c59311f9ce8a859b",
+    "zh:60160d39094973beefb9b10cfd6aaa5b63a2e68c32445ecffcd1b101356e6f9b",
+    "zh:762656454722548baeccf35cbaa23b887976337e1ed321682df7390419fdf22d",
+    "zh:7f6d7887821659bf3bef815949077dc91ffcdb0d911644a887b6683b264a5ca6",
+    "zh:8f16a352cc903f8951fa4619c36233b3e66e27d724817b131f2035dd8896f524",
+    "zh:8f768f65e370366c8b91c00d01c9a6264fe26ea9ae1819f14bdcd12c066272bc",
+    "zh:95ad78c689a83c08ef7c3e544c3c9aca93ed528054aa77cc968ddd9efa3a1023",
+    "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
+    "zh:a47097ab6a4ca8302da82964303ffdd2310ed65e8f8524bfe4058816cf1addb7",
+    "zh:b66d820c70cd5fd628ffe882d2b97e76b969dca4e6827ac2ba0f8d3bc5d6e9c6",
+    "zh:b80f713a4f3e1355c3dd1600e9d08b9f15ed2370054ec792ad2c01f2541abe02",
+    "zh:ce065bc3962cb71fa7652562226b9d486f3d7fcb88285c1020ebe2f550e28641",
+  ]
+}
+
+provider "registry.terraform.io/hashicorp/random" {
+  version     = "3.4.3"
+  constraints = ">= 2.1.0"
+  hashes = [
+    "h1:xZGZf18JjMS06pFa4NErzANI98qi59SEcBsOcS2P2yQ=",
+    "zh:41c53ba47085d8261590990f8633c8906696fa0a3c4b384ff6a7ecbf84339752",
+    "zh:59d98081c4475f2ad77d881c4412c5129c56214892f490adf11c7e7a5a47de9b",
+    "zh:686ad1ee40b812b9e016317e7f34c0d63ef837e084dea4a1f578f64a6314ad53",
+    "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+    "zh:84103eae7251384c0d995f5a257c72b0096605048f757b749b7b62107a5dccb3",
+    "zh:8ee974b110adb78c7cd18aae82b2729e5124d8f115d484215fd5199451053de5",
+    "zh:9dd4561e3c847e45de603f17fa0c01ae14cae8c4b7b4e6423c9ef3904b308dda",
+    "zh:bb07bb3c2c0296beba0beec629ebc6474c70732387477a65966483b5efabdbc6",
+    "zh:e891339e96c9e5a888727b45b2e1bb3fcbdfe0fd7c5b4396e4695459b38c8cb1",
+    "zh:ea4739860c24dfeaac6c100b2a2e357106a89d18751f7693f3c31ecf6a996f8d",
+    "zh:f0c76ac303fd0ab59146c39bc121c5d7d86f878e9a69294e29444d4c653786f8",
+    "zh:f143a9a5af42b38fed328a161279906759ff39ac428ebcfe55606e05e1518b93",
+  ]
+}
+
+provider "registry.terraform.io/hashicorp/tls" {
+  version     = "4.0.4"
+  constraints = ">= 3.0.0"
+  hashes = [
+    "h1:pe9vq86dZZKCm+8k1RhzARwENslF3SXb9ErHbQfgjXU=",
+    "zh:23671ed83e1fcf79745534841e10291bbf34046b27d6e68a5d0aab77206f4a55",
+    "zh:45292421211ffd9e8e3eb3655677700e3c5047f71d8f7650d2ce30242335f848",
+    "zh:59fedb519f4433c0fdb1d58b27c210b27415fddd0cd73c5312530b4309c088be",
+    "zh:5a8eec2409a9ff7cd0758a9d818c74bcba92a240e6c5e54b99df68fff312bbd5",
+    "zh:5e6a4b39f3171f53292ab88058a59e64825f2b842760a4869e64dc1dc093d1fe",
+    "zh:810547d0bf9311d21c81cc306126d3547e7bd3f194fc295836acf164b9f8424e",
+    "zh:824a5f3617624243bed0259d7dd37d76017097dc3193dac669be342b90b2ab48",
+    "zh:9361ccc7048be5dcbc2fafe2d8216939765b3160bd52734f7a9fd917a39ecbd8",
+    "zh:aa02ea625aaf672e649296bce7580f62d724268189fe9ad7c1b36bb0fa12fa60",
+    "zh:c71b4cd40d6ec7815dfeefd57d88bc592c0c42f5e5858dcc88245d371b4b8b1e",
+    "zh:dabcd52f36b43d250a3d71ad7abfa07b5622c69068d989e60b79b2bb4f220316",
+    "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
+  ]
+}

+ 32 - 0
packages/app/docker/codebuild/buildspec.yml

@@ -0,0 +1,32 @@
+version: 0.2
+
+env:
+  variables:
+    DOCKER_BUILDKIT: 1
+    IMAGE_TAG: ''
+    IMAGE_TAG_GHCR: ''
+  secrets-manager:
+    DOCKER_REGISTRY_PASSWORD: growi/official-image-builder:DOCKER_REGISTRY_PASSWORD
+    DOCKER_REGISTRY_ON_GITHUB_PAT: growi/official-image-builder:DOCKER_REGISTRY_ON_GITHUB_PAT
+
+phases:
+  pre_build:
+    commands:
+      # login to docker.io
+      - echo ${DOCKER_REGISTRY_PASSWORD} | docker login --username wsmoogle --password-stdin
+      # login to ghcr.io
+      - echo ${DOCKER_REGISTRY_ON_GITHUB_PAT} | docker login ghcr.io --username wsmoogle --password-stdin
+  build:
+    commands:
+      - docker build -t ${IMAGE_TAG} -f ./packages/app/docker/Dockerfile .
+      - docker tag ${IMAGE_TAG} ${IMAGE_TAG_GHCR}
+
+  post_build:
+    commands:
+      - docker push $IMAGE_TAG
+      - docker push $IMAGE_TAG_GHCR
+
+cache:
+  paths:
+    - node_modules/**/*
+    - packages/*/node_modules/**/*

+ 25 - 0
packages/app/docker/codebuild/codebuild.tf

@@ -0,0 +1,25 @@
+module "codebuild" {
+  source = "cloudposse/codebuild/aws"
+
+  name                = "growi-official-image-builder"
+  description         = "The CodeBuild Project for GROWI official docker image"
+
+  artifact_type       = "NO_ARTIFACTS"
+
+  source_type         = "GITHUB"
+  source_location     = "https://github.com/weseek/growi.git"
+  source_version      = "refs/heads/master"
+  git_clone_depth     = 1
+
+  buildspec           = "packages/app/docker/codebuild/buildspec.yml"
+
+  # https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-available.html
+  build_image         = "aws/codebuild/amazonlinux2-x86_64-standard:3.0"
+  build_compute_type  = "BUILD_GENERAL1_LARGE"
+
+  privileged_mode     = true
+
+  cache_type          = "LOCAL"
+  local_cache_modes   = ["LOCAL_DOCKER_LAYER_CACHE", "LOCAL_CUSTOM_CACHE"]
+
+}

+ 23 - 0
packages/app/docker/codebuild/main.tf

@@ -0,0 +1,23 @@
+terraform {
+  backend "remote" {
+    organization = "weseek"
+
+    workspaces {
+      name = "growi-official-image-builder"
+    }
+  }
+
+  required_providers {
+    aws = {
+      source  = "hashicorp/aws"
+      version = "~> 4.16"
+    }
+  }
+
+  required_version = ">= 1.2.0"
+}
+
+provider "aws" {
+  profile = "weseek"
+  region  = "ap-northeast-1"
+}

+ 26 - 0
packages/app/docker/codebuild/oidc.tf

@@ -0,0 +1,26 @@
+module "oidc_github" {
+  source  = "unfunco/oidc-github/aws"
+
+  iam_role_name = "GitHubOIDC-for-growi"
+  iam_role_inline_policies = {
+    "inline_policy" : data.aws_iam_policy_document.policy_document.json
+  }
+
+  github_repositories = [
+    "weseek/growi",
+  ]
+}
+
+data "aws_iam_policy_document" "policy_document" {
+  statement {
+    actions   = [
+      "codebuild:StartBuild",
+      "codebuild:StopBuild",
+      "codebuild:RetryBuild",
+      "codebuild:BatchGetBuilds"
+    ]
+    resources = [
+      module.codebuild.project_arn
+    ]
+  }
+}

+ 15 - 0
packages/app/docker/codebuild/secretsmanager.tf

@@ -0,0 +1,15 @@
+resource "aws_secretsmanager_secret" "secret" {
+  name = "growi/official-image-builder"
+}
+
+resource "aws_secretsmanager_secret_version" "main" {
+  secret_id     = aws_secretsmanager_secret.secret.id
+  secret_string = jsonencode({
+    KEY1 = "CHANGE THIS"
+    KEY2 = "CHANGE THIS"
+  })
+
+  lifecycle {
+    ignore_changes = [secret_string, version_stages]
+  }
+}

+ 0 - 6
packages/app/docker/nocdn/.env.production.local

@@ -1,6 +0,0 @@
-
-##
-## Handled by Next.js with dotenv or dotenv-flow
-## https://nextjs.org/docs/basic-features/environment-variables
-##
-NO_CDN=true

+ 13 - 11
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "6.0.2",
+  "version": "6.0.3-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -46,7 +46,7 @@
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "resources:hackmd": "yarn lerna run build --scope=@growi/hackmd",
-    "resources:preset-themes": "yarn lerna run build --scope=@growi/preset-themes",
+    "resources:preset-themes": "yarn lerna run dev:nowatch --scope=@growi/preset-themes",
     "// resources:dl-resources": "yarn ts-node bin/download-cdn-resources.ts",
     "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config"
   },
@@ -66,14 +66,14 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^6.0.2",
-    "@growi/core": "^6.0.2",
-    "@growi/hackmd": "^6.0.2",
-    "@growi/preset-themes": "^6.0.2",
-    "@growi/remark-drawio": "^6.0.2",
-    "@growi/remark-growi-directive": "^6.0.2",
-    "@growi/remark-lsx": "^6.0.2",
-    "@growi/slack": "^6.0.2",
+    "@growi/codemirror-textlint": "^6.0.3-RC.0",
+    "@growi/core": "^6.0.3-RC.0",
+    "@growi/hackmd": "^6.0.3-RC.0",
+    "@growi/preset-themes": "^6.0.3-RC.0",
+    "@growi/remark-drawio": "^6.0.3-RC.0",
+    "@growi/remark-growi-directive": "^6.0.3-RC.0",
+    "@growi/remark-lsx": "^6.0.3-RC.0",
+    "@growi/slack": "^6.0.3-RC.0",
     "@promster/express": "^7.0.6",
     "@promster/server": "^7.0.8",
     "@slack/web-api": "^6.2.4",
@@ -96,6 +96,7 @@
     "cookie-parser": "^1.4.5",
     "csurf": "^1.11.0",
     "csv-to-markdown-table": "^1.1.0",
+    "dayjs": "^1.11.7",
     "date-fns": "^2.23.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
@@ -111,6 +112,7 @@
     "express-session": "^1.16.1",
     "express-validator": "^6.14.0",
     "extensible-custom-error": "^0.0.7",
+    "form-data": "^4.0.0",
     "graceful-fs": "^4.1.11",
     "hast-util-select": "^5.0.2",
     "helmet": "^4.6.0",
@@ -200,7 +202,7 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   "devDependencies": {
-    "@growi/ui": "^6.0.2",
+    "@growi/ui": "^6.0.3-RC.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^12.2.3",

+ 12 - 1
packages/app/public/static/locales/en_US/admin.json

@@ -526,7 +526,7 @@
       "upload": "Upload",
       "discard": "Discard uploaded data",
       "errors": {
-        "different_versions": "This growi and the uploaded data versions are not met",
+        "different_versions": "The version of this GROWI and the uploaded data are not the same",
         "at_least_one": "Select one or more collections.",
         "page_and_revision": "'Pages' and 'Revisions' must be imported both.",
         "depends": "'{{target}}' must be selected when '{{condition}}' is selected."
@@ -848,6 +848,12 @@
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
   },
+  "g2g_data_transfer": {
+    "transfer_data_to_another_growi": "Transfer data from this GROWI to another GROWI",
+    "advanced_options": "Advanced options",
+    "start_transfer": "Start transfer",
+    "paste_transfer_key": "Paste transter key here"
+  },
   "plugins": {
     "plugins": "Plugins",
     "plugin_installer": "Plugin Installer",
@@ -1024,6 +1030,11 @@
     "ADMIN_SEARCH_INDICES_NORMALIZE": "Normalize of Elasticsearch indexes",
     "ADMIN_SEARCH_INDICES_REBUILD": "Rebuild Elasticsearch indexes"
   },
+  "g2g": {
+    "transfer_success": "Completed GROWI to GROWI transfer successfully",
+    "error_generate_growi_archive": "Failed to generate GROWI archive file",
+    "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",

+ 10 - 0
packages/app/public/static/locales/en_US/commons.json

@@ -96,5 +96,15 @@
 
   "not_found_page": {
     "page_not_exist": "This page does not exist."
+  },
+
+  "g2g_data_transfer": {
+    "tab": "Data transfer",
+    "data_transfer": "GROWI To GROWI Data Transfer",
+    "transfer_data_to_this_growi": "Transfer data from another GROWI to this GROWI",
+    "publish_transfer_key": "Publish transfer key",
+    "transfer_key_limit": "Transfer keys are valid for 1 hour after issuance.",
+    "once_transfer_key_used": "Once the transfer key is used for transfer, it cannot be used for any other transfer.",
+    "transfer_to_growi_cloud": "If you wish to transfer to GROWI.cloud, please click here."
   }
 }

+ 1 - 0
packages/app/public/static/locales/en_US/translation.json

@@ -165,6 +165,7 @@
     "no_page_list": "There are no pages under this page."
   },
   "installer": {
+    "tab": "Create account",
     "title": "Installer",
     "setup": "Setup",
     "create_initial_account": "Create an initial account",

+ 11 - 0
packages/app/public/static/locales/ja_JP/admin.json

@@ -856,6 +856,12 @@
       "log_type": "https://docs.growi.org/ja/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
   },
+  "g2g_data_transfer": {
+    "transfer_data_to_another_growi": "このGROWIのデータを別GROWIへ移行する",
+    "advanced_options": "詳細オプション",
+    "start_transfer": "移行を開始する",
+    "paste_transfer_key": "移行キーをここにペースト"
+  },
   "plugins": {
     "plugins": "プラグイン",
     "plugin_installer": "プラグインインストーラー",
@@ -1032,6 +1038,11 @@
     "ADMIN_SEARCH_INDICES_NORMALIZE": "Elasticsearch のインデックスの正規化",
     "ADMIN_SEARCH_INDICES_REBUILD": "Elasticsearch のインデックスのリビルド"
   },
+  "g2g": {
+    "transfer_success": "G2G移行が完了しました",
+    "error_generate_growi_archive": "GROWI アーカイブファイルの作成に失敗しました",
+    "error_send_growi_archive": "GROWI アーカイブファイルの送信に失敗しました"
+  },
   "toaster": {
     "give_user_admin": "{{username}}を管理者に設定しました",
     "remove_user_admin": "{{username}}を管理者から外しました",

+ 10 - 0
packages/app/public/static/locales/ja_JP/commons.json

@@ -96,5 +96,15 @@
 
   "not_found_page": {
     "page_not_exist": "このページは存在しません。"
+  },
+
+  "g2g_data_transfer": {
+    "tab": "データ移行",
+    "data_transfer": "別GROWIとのデータ移行",
+    "transfer_data_to_this_growi": "別GROWIのデータをこのGROWIへ移行する",
+    "publish_transfer_key": "移行キーを発行する",
+    "transfer_key_limit": "※ 移行キーの有効期限は発行から1時間となります。",
+    "once_transfer_key_used": "※ 移行キーは一度移行に利用するとそれ移行はご利用いただけなくなります。",
+    "transfer_to_growi_cloud": "※ GROWI.cloud への移行を実施する場合はこちらをご確認ください。"
   }
 }

+ 1 - 0
packages/app/public/static/locales/ja_JP/translation.json

@@ -167,6 +167,7 @@
     "no_page_list": "このページの配下にはページが存在しません。"
   },
   "installer": {
+    "tab": "アカウント作成",
     "title": "インストーラー",
     "setup": "セットアップ",
     "create_initial_account": "最初のアカウントの作成",

+ 12 - 1
packages/app/public/static/locales/zh_CN/admin.json

@@ -534,7 +534,7 @@
       "upload": "Upload",
       "discard": "Discard uploaded data",
       "errors": {
-        "versions_not_met": "this growi and the uploaded data versions are not met",
+        "different_versions": "The version of this GROWI and the uploaded data are not the same",
         "at_least_one": "Select one or more collections.",
         "page_and_revision": "'Pages' and 'Revisions' must be imported both.",
         "depends": "'{{target}}' must be selected when '{{condition}}' is selected."
@@ -856,6 +856,12 @@
       "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
     }
   },
+  "g2g_data_transfer": {
+    "transfer_data_to_another_growi": "将数据从这个GROWI迁移到另一个GROWI上",
+    "advanced_options": "高级选项",
+    "start_transfer": "开始迁移",
+    "paste_transfer_key": "在这里粘贴过渡键"
+  },
   "plugins": {
     "plugins": "Plugins",
     "plugin_installer": "Plugin Installer",
@@ -1032,6 +1038,11 @@
     "ADMIN_SEARCH_INDICES_NORMALIZE": "试图重新连接Elasticsearch",
     "ADMIN_SEARCH_INDICES_REBUILD": "重建 Elasticsearch 索引"
   },
+  "g2g": {
+    "transfer_success": "Completed GROWI to GROWI transfer successfully",
+    "error_generate_growi_archive": "Failed to generate GROWI archive file",
+    "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 ",

+ 10 - 0
packages/app/public/static/locales/zh_CN/commons.json

@@ -96,5 +96,15 @@
 
   "not_found_page": {
     "page_not_exist": "该页面不存在"
+  },
+
+  "g2g_data_transfer": {
+    "tab": "数据迁移",
+    "data_transfer": "与另一个GROWI的数据转移",
+    "transfer_data_to_this_growi": "将数据从另一个GROWI迁移到这个GROWI上",
+    "publish_transfer_key": "发布迁移密钥",
+    "transfer_key_limit": "迁移密钥在签发后一小时内有效。",
+    "once_transfer_key_used": "一旦迁移密钥被用于迁移,它将不再可用于进一步的迁移。",
+    "transfer_to_growi_cloud": "如果您希望迁移到GROWI.cloud,请点击这里。"
   }
 }

+ 2 - 0
packages/app/public/static/locales/zh_CN/translation.json

@@ -159,6 +159,7 @@
   "not_allowed_to_see_this_page": "你不能看到这个页面",
   "Confirm": "确定",
   "Successfully requested": "进程成功接受",
+  "copied_to_clipboard": "它已复制到剪贴板。",
 	"form_validation": {
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
@@ -172,6 +173,7 @@
     "no_page_list": "There are no pages under this page."
   },
 	"installer": {
+    "tab": "创建账户",
     "title": "安装",
 		"setup": "安装",
 		"create_initial_account": "创建初始用户",

+ 15 - 0
packages/app/src/client/services/g2g-transfer.ts

@@ -0,0 +1,15 @@
+import { useCallback, useState } from 'react';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+export const useGenerateTransferKey = (): {transferKey: string, generateTransferKey: () => Promise<void>} => {
+  const [transferKey, setTransferKey] = useState('');
+
+  const generateTransferKey = useCallback(async() => {
+    const response = await apiv3Post('/g2g-transfer/generate-key', { appSiteUrl: window.location.origin });
+    const { transferKey } = response.data;
+    setTransferKey(transferKey);
+  }, []);
+
+  return { transferKey, generateTransferKey };
+};

+ 31 - 38
packages/app/src/components/Admin/App/AwsSetting.jsx → packages/app/src/components/Admin/App/AwsSetting.tsx

@@ -1,20 +1,25 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
 
-import AdminAppContainer from '~/client/services/AdminAppContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
+export type AwsSettingMoleculeProps = {
+  s3ReferenceFileWithRelayMode
+  s3Region
+  s3CustomEndpoint
+  s3Bucket
+  s3AccessKeyId
+  s3SecretAccessKey
+  onChangeS3ReferenceFileWithRelayMode: (val: boolean) => void
+  onChangeS3Region: (val: string) => void
+  onChangeS3CustomEndpoint: (val: string) => void
+  onChangeS3Bucket: (val: string) => void
+  onChangeS3AccessKeyId: (val: string) => void
+  onChangeS3SecretAccessKey: (val: string) => void
+};
 
-function AwsSetting(props) {
+export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element => {
   const { t } = useTranslation();
-  const { adminAppContainer } = props;
-  const { s3ReferenceFileWithRelayMode } = adminAppContainer.state;
 
   return (
-    <React.Fragment>
+    <>
 
       <div className="row form-group my-3">
         <label className="text-left text-md-right col-md-3 col-form-label">
@@ -31,21 +36,21 @@ function AwsSetting(props) {
               aria-haspopup="true"
               aria-expanded="true"
             >
-              {s3ReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_relay')}
-              {!s3ReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_redirect')}
+              {props.s3ReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_relay')}
+              {!props.s3ReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_redirect')}
             </button>
             <div className="dropdown-menu" aria-labelledby="ddS3ReferenceFileWithRelayMode">
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { adminAppContainer.changeS3ReferenceFileWithRelayMode(true) }}
+                onClick={() => { props?.onChangeS3ReferenceFileWithRelayMode(true) }}
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { adminAppContainer.changeS3ReferenceFileWithRelayMode(false) }}
+                onClick={() => { props?.onChangeS3ReferenceFileWithRelayMode(false) }}
               >
                 { t('admin:app_setting.file_delivery_method_redirect')}
               </button>
@@ -68,9 +73,9 @@ function AwsSetting(props) {
           <input
             className="form-control"
             placeholder={`${t('eg')} ap-northeast-1`}
-            defaultValue={adminAppContainer.state.s3Region || ''}
+            defaultValue={props.s3Region || ''}
             onChange={(e) => {
-              adminAppContainer.changeS3Region(e.target.value);
+              props?.onChangeS3Region(e.target.value);
             }}
           />
         </div>
@@ -85,9 +90,9 @@ function AwsSetting(props) {
             className="form-control"
             type="text"
             placeholder={`${t('eg')} http://localhost:9000`}
-            defaultValue={adminAppContainer.state.s3CustomEndpoint || ''}
+            defaultValue={props.s3CustomEndpoint || ''}
             onChange={(e) => {
-              adminAppContainer.changeS3CustomEndpoint(e.target.value);
+              props?.onChangeS3CustomEndpoint(e.target.value);
             }}
           />
           <p className="form-text text-muted">{t('admin:app_setting.custom_endpoint_change')}</p>
@@ -103,9 +108,9 @@ function AwsSetting(props) {
             className="form-control"
             type="text"
             placeholder={`${t('eg')} crowi`}
-            defaultValue={adminAppContainer.state.s3Bucket || ''}
+            defaultValue={props.s3Bucket || ''}
             onChange={(e) => {
-              adminAppContainer.changeS3Bucket(e.target.value);
+              props.onChangeS3Bucket(e.target.value);
             }}
           />
         </div>
@@ -119,9 +124,9 @@ function AwsSetting(props) {
           <input
             className="form-control"
             type="text"
-            defaultValue={adminAppContainer.state.s3AccessKeyId || ''}
+            defaultValue={props.s3AccessKeyId || ''}
             onChange={(e) => {
-              adminAppContainer.changeS3AccessKeyId(e.target.value);
+              props?.onChangeS3AccessKeyId(e.target.value);
             }}
           />
         </div>
@@ -135,27 +140,15 @@ function AwsSetting(props) {
           <input
             className="form-control"
             type="text"
-            defaultValue={adminAppContainer.state.s3SecretAccessKey || ''}
+            defaultValue={props.s3SecretAccessKey || ''}
             onChange={(e) => {
-              adminAppContainer.changeS3SecretAccessKey(e.target.value);
+              props?.onChangeS3SecretAccessKey(e.target.value);
             }}
           />
         </div>
       </div>
 
 
-    </React.Fragment>
+    </>
   );
-}
-
-
-/**
- * Wrapper component for using unstated
- */
-const AwsSettingWrapper = withUnstatedContainers(AwsSetting, [AdminAppContainer]);
-
-AwsSetting.propTypes = {
-  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
-
-export default AwsSettingWrapper;

+ 158 - 29
packages/app/src/components/Admin/App/FileUploadSetting.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { ChangeEvent, useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
@@ -8,30 +8,22 @@ import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
-import AwsSetting from './AwsSetting';
-import GcsSettings from './GcsSettings';
+import { AwsSettingMolecule } from './AwsSetting';
+import type { AwsSettingMoleculeProps } from './AwsSetting';
+import { GcsSettingMolecule } from './GcsSetting';
+import type { GcsSettingMoleculeProps } from './GcsSetting';
 
+const fileUploadTypes = ['aws', 'gcs', 'gridfs', 'local'] as const;
 
-type Props = {
-  adminAppContainer: AdminAppContainer,
-}
-
+type FileUploadSettingMoleculeProps = {
+  fileUploadType: string
+  isFixedFileUploadByEnvVar: boolean
+  envFileUploadType?: string
+  onChangeFileUploadType: (e: ChangeEvent, type: string) => void
+} & AwsSettingMoleculeProps & GcsSettingMoleculeProps;
 
-const FileUploadSetting = (props: Props) => {
+export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMoleculeProps): JSX.Element => {
   const { t } = useTranslation(['admin', 'commons']);
-  const { adminAppContainer } = props;
-  const { fileUploadType } = adminAppContainer.state;
-  const fileUploadTypes = ['aws', 'gcs', 'gridfs', 'local'];
-
-  const submitHandler = useCallback(async() => {
-    try {
-      await adminAppContainer.updateFileUploadSettingHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.file_upload_settings'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [adminAppContainer, t]);
 
   return (
     <>
@@ -59,29 +51,166 @@ const FileUploadSetting = (props: Props) => {
                   className="custom-control-input"
                   name="file-upload-type"
                   id={`file-upload-type-radio-${type}`}
-                  checked={adminAppContainer.state.fileUploadType === type}
-                  disabled={adminAppContainer.state.isFixedFileUploadByEnvVar}
-                  onChange={() => { adminAppContainer.changeFileUploadType(type) }}
+                  checked={props.fileUploadType === type}
+                  disabled={props.isFixedFileUploadByEnvVar}
+                  onChange={(e) => { props?.onChangeFileUploadType(e, type) }}
                 />
                 <label className="custom-control-label" htmlFor={`file-upload-type-radio-${type}`}>{t(`admin:app_setting.${type}_label`)}</label>
               </div>
             );
           })}
         </div>
-        {adminAppContainer.state.isFixedFileUploadByEnvVar && (
+        {props.isFixedFileUploadByEnvVar && (
           <p className="alert alert-warning mt-2 text-left offset-3 col-6">
             <i className="icon-exclamation icon-fw">
             </i><b>FIXED</b><br />
             {/* eslint-disable-next-line react/no-danger */}
-            <b dangerouslySetInnerHTML={{ __html: t('admin:app_setting.fixed_by_env_var', { fileUploadType: adminAppContainer.state.envFileUploadType }) }} />
+            <b dangerouslySetInnerHTML={{ __html: t('admin:app_setting.fixed_by_env_var', { fileUploadType: props.envFileUploadType }) }} />
           </p>
         )}
       </div>
 
-      {fileUploadType === 'aws' && <AwsSetting />}
-      {fileUploadType === 'gcs' && <GcsSettings />}
+      {props.fileUploadType === 'aws' && <AwsSettingMolecule
+        s3ReferenceFileWithRelayMode={props.s3ReferenceFileWithRelayMode}
+        s3Region={props.s3Region}
+        s3CustomEndpoint={props.s3CustomEndpoint}
+        s3Bucket={props.s3Bucket}
+        s3AccessKeyId={props.s3AccessKeyId}
+        s3SecretAccessKey={props.s3SecretAccessKey}
+        onChangeS3ReferenceFileWithRelayMode={props.onChangeS3ReferenceFileWithRelayMode}
+        onChangeS3Region={props.onChangeS3Region}
+        onChangeS3CustomEndpoint={props.onChangeS3CustomEndpoint}
+        onChangeS3Bucket={props.onChangeS3Bucket}
+        onChangeS3AccessKeyId={props.onChangeS3AccessKeyId}
+        onChangeS3SecretAccessKey={props.onChangeS3SecretAccessKey}
+      />}
+      {props.fileUploadType === 'gcs' && <GcsSettingMolecule
+        gcsReferenceFileWithRelayMode={props.gcsReferenceFileWithRelayMode}
+        gcsUseOnlyEnvVars={props.gcsUseOnlyEnvVars}
+        gcsApiKeyJsonPath={props.gcsApiKeyJsonPath}
+        gcsBucket={props.gcsBucket}
+        gcsUploadNamespace={props.gcsUploadNamespace}
+        envGcsApiKeyJsonPath={props.envGcsApiKeyJsonPath}
+        envGcsBucket={props.envGcsBucket}
+        envGcsUploadNamespace={props.envGcsUploadNamespace}
+        onChangeGcsReferenceFileWithRelayMode={props.onChangeGcsReferenceFileWithRelayMode}
+        onChangeGcsApiKeyJsonPath={props.onChangeGcsApiKeyJsonPath}
+        onChangeGcsBucket={props.onChangeGcsBucket}
+        onChangeGcsUploadNamespace={props.onChangeGcsUploadNamespace}
+      />}
+    </>
+  );
+});
+FileUploadSettingMolecule.displayName = 'FileUploadSettingMolecule';
+
+
+type FileUploadSettingProps = {
+  adminAppContainer: AdminAppContainer
+}
 
-      <AdminUpdateButtonRow onClick={submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
+  const { t } = useTranslation(['admin', 'commons']);
+  const { adminAppContainer } = props;
+
+  const {
+    fileUploadType, isFixedFileUploadByEnvVar, envFileUploadType, retrieveError,
+    s3ReferenceFileWithRelayMode,
+    s3Region, s3CustomEndpoint, s3Bucket,
+    s3AccessKeyId, s3SecretAccessKey,
+    gcsReferenceFileWithRelayMode, gcsUseOnlyEnvVars,
+    gcsApiKeyJsonPath, envGcsApiKeyJsonPath, gcsBucket,
+    envGcsBucket, gcsUploadNamespace, envGcsUploadNamespace,
+  } = adminAppContainer.state;
+
+  const submitHandler = useCallback(async() => {
+    try {
+      await adminAppContainer.updateFileUploadSettingHandler();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.file_upload_settings'), ns: 'commons' }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminAppContainer, t]);
+
+  const onChangeFileUploadTypeHandler = useCallback((e: ChangeEvent, type: string) => {
+    adminAppContainer.changeFileUploadType(type);
+  }, [adminAppContainer]);
+
+  // S3
+  const onChangeS3ReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
+    adminAppContainer.changeS3ReferenceFileWithRelayMode(val);
+  }, [adminAppContainer]);
+
+  const onChangeS3RegionHandler = useCallback((val: string) => {
+    adminAppContainer.changeS3Region(val);
+  }, [adminAppContainer]);
+
+  const onChangeS3CustomEndpointHandler = useCallback((val: string) => {
+    adminAppContainer.changeS3CustomEndpoint(val);
+  }, [adminAppContainer]);
+
+  const onChangeS3BucketHandler = useCallback((val: string) => {
+    adminAppContainer.changeS3Bucket(val);
+  }, [adminAppContainer]);
+
+  const onChangeS3AccessKeyIdHandler = useCallback((val: string) => {
+    adminAppContainer.changeS3AccessKeyId(val);
+  }, [adminAppContainer]);
+
+  const onChangeS3SecretAccessKeyHandler = useCallback((val: string) => {
+    adminAppContainer.changeS3SecretAccessKey(val);
+  }, [adminAppContainer]);
+
+  // GCS
+  const onChangeGcsReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
+    adminAppContainer.changeGcsReferenceFileWithRelayMode(val);
+  }, [adminAppContainer]);
+
+  const onChangeGcsApiKeyJsonPathHandler = useCallback((val: string) => {
+    adminAppContainer.changeGcsApiKeyJsonPath(val);
+  }, [adminAppContainer]);
+
+  const onChangeGcsBucketHandler = useCallback((val: string) => {
+    adminAppContainer.changeGcsBucket(val);
+  }, [adminAppContainer]);
+
+  const onChangeGcsUploadNamespaceHandler = useCallback((val: string) => {
+    adminAppContainer.changeGcsUploadNamespace(val);
+  }, [adminAppContainer]);
+
+  return (
+    <>
+      <FileUploadSettingMolecule
+        fileUploadType={fileUploadType}
+        isFixedFileUploadByEnvVar={isFixedFileUploadByEnvVar}
+        envFileUploadType={envFileUploadType}
+        onChangeFileUploadType={onChangeFileUploadTypeHandler}
+        s3ReferenceFileWithRelayMode={s3ReferenceFileWithRelayMode}
+        s3Region={s3Region}
+        s3CustomEndpoint={s3CustomEndpoint}
+        s3Bucket={s3Bucket}
+        s3AccessKeyId={s3AccessKeyId}
+        s3SecretAccessKey={s3SecretAccessKey}
+        onChangeS3ReferenceFileWithRelayMode={onChangeS3ReferenceFileWithRelayModeHandler}
+        onChangeS3Region={onChangeS3RegionHandler}
+        onChangeS3CustomEndpoint={onChangeS3CustomEndpointHandler}
+        onChangeS3Bucket={onChangeS3BucketHandler}
+        onChangeS3AccessKeyId={onChangeS3AccessKeyIdHandler}
+        onChangeS3SecretAccessKey={onChangeS3SecretAccessKeyHandler}
+        gcsReferenceFileWithRelayMode={gcsReferenceFileWithRelayMode}
+        gcsUseOnlyEnvVars={gcsUseOnlyEnvVars}
+        gcsApiKeyJsonPath={gcsApiKeyJsonPath}
+        gcsBucket={gcsBucket}
+        gcsUploadNamespace={gcsUploadNamespace}
+        envGcsApiKeyJsonPath={envGcsApiKeyJsonPath}
+        envGcsBucket={envGcsBucket}
+        envGcsUploadNamespace={envGcsUploadNamespace}
+        onChangeGcsReferenceFileWithRelayMode={onChangeGcsReferenceFileWithRelayModeHandler}
+        onChangeGcsApiKeyJsonPath={onChangeGcsApiKeyJsonPathHandler}
+        onChangeGcsBucket={onChangeGcsBucketHandler}
+        onChangeGcsUploadNamespace={onChangeGcsUploadNamespaceHandler}
+      />
+      <AdminUpdateButtonRow onClick={submitHandler} disabled={retrieveError != null} />
     </>
   );
 };

+ 37 - 34
packages/app/src/components/Admin/App/GcsSettings.jsx → packages/app/src/components/Admin/App/GcsSetting.tsx

@@ -1,18 +1,33 @@
-
-import React from 'react';
-
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
 
-import AdminAppContainer from '~/client/services/AdminAppContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
+export type GcsSettingMoleculeProps = {
+  gcsReferenceFileWithRelayMode
+  gcsUseOnlyEnvVars
+  gcsApiKeyJsonPath
+  gcsBucket
+  gcsUploadNamespace
+  envGcsApiKeyJsonPath?
+  envGcsBucket?
+  envGcsUploadNamespace?
+  onChangeGcsReferenceFileWithRelayMode: (val: boolean) => void
+  onChangeGcsApiKeyJsonPath: (val: string) => void
+  onChangeGcsBucket: (val: string) => void
+  onChangeGcsUploadNamespace: (val: string) => void
+};
 
-const GcsSetting = (props) => {
+export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element => {
   const { t } = useTranslation();
-  const { adminAppContainer } = props;
-  const { gcsReferenceFileWithRelayMode, gcsUseOnlyEnvVars } = adminAppContainer.state;
+
+  const {
+    gcsReferenceFileWithRelayMode,
+    gcsUseOnlyEnvVars,
+    gcsApiKeyJsonPath,
+    envGcsApiKeyJsonPath,
+    gcsBucket,
+    envGcsBucket,
+    gcsUploadNamespace,
+    envGcsUploadNamespace,
+  } = props;
 
   return (
     <>
@@ -39,14 +54,14 @@ const GcsSetting = (props) => {
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { adminAppContainer.changeGcsReferenceFileWithRelayMode(true) }}
+                onClick={() => { props?.onChangeGcsReferenceFileWithRelayMode(true) }}
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { adminAppContainer.changeGcsReferenceFileWithRelayMode(false) }}
+                onClick={() => { props?.onChangeGcsReferenceFileWithRelayMode(false) }}
               >
                 { t('admin:app_setting.file_delivery_method_redirect')}
               </button>
@@ -90,12 +105,12 @@ const GcsSetting = (props) => {
                 type="text"
                 name="gcsApiKeyJsonPath"
                 readOnly={gcsUseOnlyEnvVars}
-                defaultValue={adminAppContainer.state.gcsApiKeyJsonPath}
-                onChange={e => adminAppContainer.changeGcsApiKeyJsonPath(e.target.value)}
+                defaultValue={gcsApiKeyJsonPath}
+                onChange={e => props?.onChangeGcsApiKeyJsonPath(e.target.value)}
               />
             </td>
             <td>
-              <input className="form-control" type="text" value={adminAppContainer.state.envGcsApiKeyJsonPath || ''} readOnly tabIndex="-1" />
+              <input className="form-control" type="text" value={envGcsApiKeyJsonPath || ''} readOnly tabIndex={-1} />
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
                 <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'GCS_API_KEY_JSON_PATH' }) }} />
@@ -110,12 +125,12 @@ const GcsSetting = (props) => {
                 type="text"
                 name="gcsBucket"
                 readOnly={gcsUseOnlyEnvVars}
-                defaultValue={adminAppContainer.state.gcsBucket}
-                onChange={e => adminAppContainer.changeGcsBucket(e.target.value)}
+                defaultValue={gcsBucket}
+                onChange={e => props?.onChangeGcsBucket(e.target.value)}
               />
             </td>
             <td>
-              <input className="form-control" type="text" value={adminAppContainer.state.envGcsBucket || ''} readOnly tabIndex="-1" />
+              <input className="form-control" type="text" value={envGcsBucket || ''} readOnly tabIndex={-1} />
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
                 <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'GCS_BUCKET' }) }} />
@@ -130,12 +145,12 @@ const GcsSetting = (props) => {
                 type="text"
                 name="gcsUploadNamespace"
                 readOnly={gcsUseOnlyEnvVars}
-                defaultValue={adminAppContainer.state.gcsUploadNamespace}
-                onChange={e => adminAppContainer.changeGcsUploadNamespace(e.target.value)}
+                defaultValue={gcsUploadNamespace}
+                onChange={e => props?.onChangeGcsUploadNamespace(e.target.value)}
               />
             </td>
             <td>
-              <input className="form-control" type="text" value={adminAppContainer.state.envGcsUploadNamespace || ''} readOnly tabIndex="-1" />
+              <input className="form-control" type="text" value={envGcsUploadNamespace || ''} readOnly tabIndex={-1} />
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
                 <small dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'GCS_UPLOAD_NAMESPACE' }) }} />
@@ -147,16 +162,4 @@ const GcsSetting = (props) => {
 
     </>
   );
-
 };
-
-/**
- * Wrapper component for using unstated
- */
-const GcsSettingWrapper = withUnstatedContainers(GcsSetting, [AdminAppContainer]);
-
-GcsSetting.propTypes = {
-  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
-};
-
-export default GcsSettingWrapper;

+ 4 - 1
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -12,7 +12,7 @@ import { useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '../../../stores/co
 // import { withUnstatedContainers } from '../../UnstatedUtils';
 
 const AdminNavigation = (props) => {
-  const { t } = useTranslation('admin');
+  const { t } = useTranslation(['admin', 'commons']);
   // const { appContainer } = props;
   const pathname = window.location.pathname;
 
@@ -36,6 +36,7 @@ const AdminNavigation = (props) => {
       case 'user-groups':              return <><i className="mr-1 icon-fw icon-people"></i>{          t('user_group_management.user_group_management') }</>;
       case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
       case 'audit-log':                return <><i className="mr-1 icon-fw icon-feed"></i>{            t('audit_log_management.audit_log')}</>;
+      case 'data-transfer':            return <><i className="mr-1 icon-fw icon-arrow-right"></i>{     t('g2g_data_transfer.data_transfer', { ns: 'commons' })}</>;
       case 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{          t('plugins.plugins')}</>;
       case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
       default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_home_page') }</>;
@@ -93,6 +94,7 @@ const AdminNavigation = (props) => {
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
         <MenuLink menu="plugins"      isListGroupItems isActive={isActiveMenu('/plugins')} />
+        <MenuLink menu="data-transfer" isListGroupItems isActive={isActiveMenu('/data-transfer')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
         {growiCloudUri != null && growiAppIdForGrowiCloud != null
           && (
@@ -143,6 +145,7 @@ const AdminNavigation = (props) => {
             {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
             {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
             {isActiveMenu('/plugins') &&           <MenuLabel menu="plugins" />}
+            {isActiveMenu('/data-transfer') &&     <MenuLabel menu="data-transfer" />}
             {/* eslint-enable no-multi-spaces */}
           </span>
         </button>

+ 3 - 4
packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx

@@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useState } from 'react';
 import { PresetThemes, PresetThemesMetadatas } from '@growi/preset-themes';
 import { useTranslation } from 'next-i18next';
 
-import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
 import { useSWRxGrowiThemeSetting } from '~/stores/admin/customize';
 
@@ -20,7 +19,7 @@ type Props = {
 const CustomizeThemeSetting = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
-  const { data, error } = useSWRxGrowiThemeSetting();
+  const { data, error, update } = useSWRxGrowiThemeSetting();
   const [currentTheme, setCurrentTheme] = useState(data?.currentTheme);
 
   useEffect(() => {
@@ -38,7 +37,7 @@ const CustomizeThemeSetting = (props: Props): JSX.Element => {
     }
 
     try {
-      await apiv3Put('/customize-setting/theme', {
+      await update({
         theme: currentTheme,
       });
 
@@ -47,7 +46,7 @@ const CustomizeThemeSetting = (props: Props): JSX.Element => {
     catch (err) {
       toastError(err);
     }
-  }, [currentTheme, t]);
+  }, [currentTheme, t, update]);
 
   const availableThemes = data?.pluginThemesMetadatas == null
     ? PresetThemesMetadatas

+ 0 - 250
packages/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.jsx

@@ -1,250 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-import * as toastr from 'toastr';
-
-import { apiPost } from '~/client/util/apiv1-client';
-
-// import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-
-const GROUPS_PAGE = [
-  'pages', 'revisions', 'tags', 'pagetagrelations', 'pageredirects', 'comments', 'sharelinks',
-];
-const GROUPS_USER = [
-  'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
-  'useruisettings', 'editorsettings', 'bookmarks', 'subscriptions',
-  'inappnotificationsettings',
-];
-const GROUPS_CONFIG = [
-  'configs', 'updateposts', 'globalnotificationsettings', 'slackappintegrations',
-];
-const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
-
-class SelectCollectionsModal extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      selectedCollections: new Set(),
-    };
-
-    this.toggleCheckbox = this.toggleCheckbox.bind(this);
-    this.checkAll = this.checkAll.bind(this);
-    this.uncheckAll = this.uncheckAll.bind(this);
-    this.export = this.export.bind(this);
-    this.validateForm = this.validateForm.bind(this);
-  }
-
-  toggleCheckbox(e) {
-    const { target } = e;
-    const { name, checked } = target;
-
-    this.setState((prevState) => {
-      const selectedCollections = new Set(prevState.selectedCollections);
-      if (checked) {
-        selectedCollections.add(name);
-      }
-      else {
-        selectedCollections.delete(name);
-      }
-
-      return { selectedCollections };
-    });
-  }
-
-  checkAll() {
-    this.setState({ selectedCollections: new Set(this.props.collections) });
-  }
-
-  uncheckAll() {
-    this.setState({ selectedCollections: new Set() });
-  }
-
-  async export(e) {
-    e.preventDefault();
-
-    try {
-      // TODO: use apiv3Post
-      const result = await apiPost('/v3/export', { collections: Array.from(this.state.selectedCollections) });
-      // TODO: toastSuccess, toastError
-
-      if (!result.ok) {
-        throw new Error('Error occured.');
-      }
-
-      // TODO: toastSuccess, toastError
-      toastr.success(undefined, 'Export process has requested.', {
-        closeButton: true,
-        progressBar: true,
-        newestOnTop: false,
-        showDuration: '100',
-        hideDuration: '100',
-        timeOut: '1200',
-        extendedTimeOut: '150',
-      });
-
-      this.props.onExportingRequested();
-      this.props.onClose();
-
-      this.setState({ selectedCollections: new Set() });
-
-    }
-    catch (err) {
-      // TODO: toastSuccess, toastError
-      toastr.error(err, 'Error', {
-        closeButton: true,
-        progressBar: true,
-        newestOnTop: false,
-        showDuration: '100',
-        hideDuration: '100',
-        timeOut: '3000',
-      });
-    }
-  }
-
-  validateForm() {
-    return this.state.selectedCollections.size > 0;
-  }
-
-  renderWarnForUser() {
-    // whether this.state.selectedCollections includes one of GROUPS_USER
-    const isUserRelatedDataSelected = GROUPS_USER.some((collectionName) => {
-      return this.state.selectedCollections.has(collectionName);
-    });
-
-    if (!isUserRelatedDataSelected) {
-      return <></>;
-    }
-
-    const html = this.props.t('admin:export_management.desc_password_seed');
-
-    // eslint-disable-next-line react/no-danger
-    return <div className="card well" dangerouslySetInnerHTML={{ __html: html }}></div>;
-  }
-
-  renderGroups(groupList, color) {
-    const collectionNames = groupList.filter((collectionName) => {
-      return this.props.collections.includes(collectionName);
-    });
-
-    return this.renderCheckboxes(collectionNames, color);
-  }
-
-  renderOthers() {
-    const collectionNames = this.props.collections.filter((collectionName) => {
-      return !ALL_GROUPED_COLLECTIONS.includes(collectionName);
-    });
-
-    return this.renderCheckboxes(collectionNames);
-  }
-
-  renderCheckboxes(collectionNames, color) {
-    const checkboxColor = color ? `custom-checkbox-${color}` : 'custom-checkbox-info';
-
-    return (
-      <div className={`custom-control custom-checkbox ${checkboxColor}`}>
-        <div className="row">
-          {collectionNames.map((collectionName) => {
-            return (
-              <div className="col-sm-6 my-1" key={collectionName}>
-                <input
-                  type="checkbox"
-                  className="custom-control-input"
-                  id={collectionName}
-                  name={collectionName}
-                  value={collectionName}
-                  checked={this.state.selectedCollections.has(collectionName)}
-                  onChange={this.toggleCheckbox}
-                />
-                <label className="text-capitalize custom-control-label ml-3" htmlFor={collectionName}>
-                  {collectionName}
-                </label>
-              </div>
-            );
-          })}
-        </div>
-      </div>
-    );
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
-        <ModalHeader tag="h4" toggle={this.props.onClose} className="bg-info text-light">
-          {t('admin:export_management.export_collections')}
-        </ModalHeader>
-
-        <form onSubmit={this.export}>
-          <ModalBody>
-            <div className="row">
-              <div className="col-sm-12">
-                <button type="button" className="btn btn-sm btn-outline-secondary mr-2" onClick={this.checkAll}>
-                  <i className="fa fa-check-square-o"></i> {t('admin:export_management.check_all')}
-                </button>
-                <button type="button" className="btn btn-sm btn-outline-secondary mr-2" onClick={this.uncheckAll}>
-                  <i className="fa fa-square-o"></i> {t('admin:export_management.uncheck_all')}
-                </button>
-              </div>
-            </div>
-            <div className="row mt-4">
-              <div className="col-sm-12">
-                <h3 className="admin-setting-header">MongoDB Page Collections</h3>
-                {this.renderGroups(GROUPS_PAGE)}
-              </div>
-            </div>
-            <div className="row mt-4">
-              <div className="col-sm-12">
-                <h3 className="admin-setting-header">MongoDB User Collections</h3>
-                {this.renderGroups(GROUPS_USER, 'danger')}
-                {this.renderWarnForUser()}
-              </div>
-            </div>
-            <div className="row mt-4">
-              <div className="col-sm-12">
-                <h3 className="admin-setting-header">MongoDB Config Collections</h3>
-                {this.renderGroups(GROUPS_CONFIG)}
-              </div>
-            </div>
-            <div className="row mt-4">
-              <div className="col-sm-12">
-                <h3 className="admin-setting-header">MongoDB Other Collections</h3>
-                {this.renderOthers()}
-              </div>
-            </div>
-          </ModalBody>
-
-          <ModalFooter>
-            <button type="button" className="btn btn-sm btn-outline-secondary" onClick={this.props.onClose}>{t('admin:export_management.cancel')}</button>
-            <button type="submit" className="btn btn-sm btn-primary" disabled={!this.validateForm()}>{t('admin:export_management.export')}</button>
-          </ModalFooter>
-        </form>
-      </Modal>
-    );
-  }
-
-}
-
-SelectCollectionsModal.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  isOpen: PropTypes.bool.isRequired,
-  onExportingRequested: PropTypes.func.isRequired,
-  onClose: PropTypes.func.isRequired,
-  collections: PropTypes.arrayOf(PropTypes.string).isRequired,
-};
-
-const SelectCollectionsModalWrapperFc = (props) => {
-  const { t } = useTranslation();
-
-  return <SelectCollectionsModal t={t} {...props} />;
-};
-
-export default SelectCollectionsModalWrapperFc;

+ 232 - 0
packages/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx

@@ -0,0 +1,232 @@
+import React, { useCallback, useState, useEffect } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import * as toastr from 'toastr';
+
+import { apiPost } from '~/client/util/apiv1-client';
+
+// import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+
+const GROUPS_PAGE = [
+  'pages', 'revisions', 'tags', 'pagetagrelations', 'pageredirects', 'comments', 'sharelinks',
+];
+const GROUPS_USER = [
+  'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
+  'useruisettings', 'editorsettings', 'bookmarks', 'subscriptions',
+  'inappnotificationsettings',
+];
+const GROUPS_CONFIG = [
+  'configs', 'updateposts', 'globalnotificationsettings', 'slackappintegrations',
+];
+const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
+
+type Props = {
+  isOpen: boolean,
+  onExportingRequested: () => void,
+  onClose: () => void,
+  collections: string[],
+  isAllChecked?: boolean,
+};
+
+const SelectCollectionsModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    isOpen, onExportingRequested, onClose, collections, isAllChecked,
+  } = props;
+
+  const [selectedCollections, setSelectedCollections] = useState<Set<string>>(new Set());
+
+  const toggleCheckbox = useCallback((e) => {
+    const { target } = e;
+    const { name, checked } = target;
+
+    setSelectedCollections((prevState) => {
+      const selectedCollections = new Set(prevState);
+      if (checked) {
+        selectedCollections.add(name);
+      }
+      else {
+        selectedCollections.delete(name);
+      }
+
+      return selectedCollections;
+    });
+  }, []);
+
+  const checkAll = useCallback(() => {
+    setSelectedCollections(new Set(collections));
+  }, [collections]);
+
+  const uncheckAll = useCallback(() => {
+    setSelectedCollections(new Set());
+  }, []);
+
+  const doExport = useCallback(async(e) => {
+    e.preventDefault();
+
+    try {
+      // TODO: use apiv3Post
+      const result = await apiPost<any>('/v3/export', { collections: Array.from(selectedCollections) });
+      // TODO: toastSuccess, toastError
+
+      if (!result.ok) {
+        throw new Error('Error occured.');
+      }
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, 'Export process has requested.', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+
+      onExportingRequested();
+      onClose();
+      uncheckAll();
+    }
+    catch (err) {
+      // TODO: toastSuccess, toastError
+      toastr.error(err, 'Error', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    }
+  }, [onClose, onExportingRequested, selectedCollections, uncheckAll]);
+
+  const validateForm = useCallback(() => {
+    return selectedCollections.size > 0;
+  }, [selectedCollections.size]);
+
+  const renderWarnForUser = useCallback(() => {
+    // whether selectedCollections includes one of GROUPS_USER
+    const isUserRelatedDataSelected = GROUPS_USER.some((collectionName) => {
+      return selectedCollections.has(collectionName);
+    });
+
+    if (!isUserRelatedDataSelected) {
+      return <></>;
+    }
+
+    const html = t('admin:export_management.desc_password_seed');
+
+    // eslint-disable-next-line react/no-danger
+    return <div className="card well" dangerouslySetInnerHTML={{ __html: html }}></div>;
+  }, [selectedCollections, t]);
+
+  const renderCheckboxes = useCallback((collectionNames, color?) => {
+    const checkboxColor = color ? `custom-checkbox-${color}` : 'custom-checkbox-info';
+
+    return (
+      <div className={`custom-control custom-checkbox ${checkboxColor}`}>
+        <div className="row">
+          {collectionNames.map((collectionName) => {
+            return (
+              <div className="col-sm-6 my-1" key={collectionName}>
+                <input
+                  type="checkbox"
+                  className="custom-control-input"
+                  id={collectionName}
+                  name={collectionName}
+                  value={collectionName}
+                  checked={selectedCollections.has(collectionName)}
+                  onChange={toggleCheckbox}
+                />
+                <label className="text-capitalize custom-control-label ml-3" htmlFor={collectionName}>
+                  {collectionName}
+                </label>
+              </div>
+            );
+          })}
+        </div>
+      </div>
+    );
+  }, [selectedCollections, toggleCheckbox]);
+
+  const renderGroups = useCallback((groupList, color?) => {
+    const collectionNames = groupList.filter((collectionName) => {
+      return collections.includes(collectionName);
+    });
+
+    return renderCheckboxes(collectionNames, color);
+  }, [collections, renderCheckboxes]);
+
+  const renderOthers = useCallback(() => {
+    const collectionNames = collections.filter((collectionName) => {
+      return !ALL_GROUPED_COLLECTIONS.includes(collectionName);
+    });
+
+    return renderCheckboxes(collectionNames);
+  }, [collections, renderCheckboxes]);
+
+  useEffect(() => {
+    if (isAllChecked) checkAll();
+  }, [isAllChecked, checkAll]);
+
+  return (
+    <Modal isOpen={isOpen} toggle={onClose}>
+      <ModalHeader tag="h4" toggle={onClose} className="bg-info text-light">
+        {t('admin:export_management.export_collections')}
+      </ModalHeader>
+
+      <form onSubmit={doExport}>
+        <ModalBody>
+          <div className="row">
+            <div className="col-sm-12">
+              <button type="button" className="btn btn-sm btn-outline-secondary mr-2" onClick={checkAll}>
+                <i className="fa fa-check-square-o"></i> {t('admin:export_management.check_all')}
+              </button>
+              <button type="button" className="btn btn-sm btn-outline-secondary mr-2" onClick={uncheckAll}>
+                <i className="fa fa-square-o"></i> {t('admin:export_management.uncheck_all')}
+              </button>
+            </div>
+          </div>
+          <div className="row mt-4">
+            <div className="col-sm-12">
+              <h3 className="admin-setting-header">MongoDB Page Collections</h3>
+              {renderGroups(GROUPS_PAGE)}
+            </div>
+          </div>
+          <div className="row mt-4">
+            <div className="col-sm-12">
+              <h3 className="admin-setting-header">MongoDB User Collections</h3>
+              {renderGroups(GROUPS_USER, 'danger')}
+              {renderWarnForUser()}
+            </div>
+          </div>
+          <div className="row mt-4">
+            <div className="col-sm-12">
+              <h3 className="admin-setting-header">MongoDB Config Collections</h3>
+              {renderGroups(GROUPS_CONFIG)}
+            </div>
+          </div>
+          <div className="row mt-4">
+            <div className="col-sm-12">
+              <h3 className="admin-setting-header">MongoDB Other Collections</h3>
+              {renderOthers()}
+            </div>
+          </div>
+        </ModalBody>
+
+        <ModalFooter>
+          <button type="button" className="btn btn-sm btn-outline-secondary" onClick={onClose}>{t('admin:export_management.cancel')}</button>
+          <button type="submit" className="btn btn-sm btn-primary" disabled={!validateForm()}>{t('admin:export_management.export')}</button>
+        </ModalFooter>
+      </form>
+    </Modal>
+  );
+};
+
+export default SelectCollectionsModal;

+ 0 - 261
packages/app/src/components/Admin/ExportArchiveDataPage.jsx

@@ -1,261 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import * as toastr from 'toastr';
-
-
-// import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiDelete, apiGet } from '~/client/util/apiv1-client';
-import { useAdminSocket } from '~/stores/socket-io';
-
-import LabeledProgressBar from './Common/LabeledProgressBar';
-import ArchiveFilesTable from './ExportArchiveData/ArchiveFilesTable';
-import SelectCollectionsModal from './ExportArchiveData/SelectCollectionsModal';
-
-
-const IGNORED_COLLECTION_NAMES = [
-  'sessions', 'rlflx', 'activities',
-];
-
-class ExportArchiveDataPage extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      collections: [],
-      zipFileStats: [],
-      progressList: [],
-      isExportModalOpen: false,
-      isExporting: false,
-      isZipping: false,
-      isExported: false,
-    };
-
-    this.onZipFileStatAdd = this.onZipFileStatAdd.bind(this);
-    this.onZipFileStatRemove = this.onZipFileStatRemove.bind(this);
-    this.openExportModal = this.openExportModal.bind(this);
-    this.closeExportModal = this.closeExportModal.bind(this);
-    this.exportingRequestedHandler = this.exportingRequestedHandler.bind(this);
-  }
-
-  async UNSAFE_componentWillMount() {
-    // TODO:: use apiv3.get
-    // eslint-disable-next-line no-unused-vars
-    const [{ collections }, { status }] = await Promise.all([
-      apiGet('/v3/mongo/collections', {}),
-      apiGet('/v3/export/status', {}),
-    ]);
-    // TODO: toastSuccess, toastError
-
-    // filter only not ignored collection names
-    const filteredCollections = collections.filter((collectionName) => {
-      return !IGNORED_COLLECTION_NAMES.includes(collectionName);
-    });
-
-    const { zipFileStats, isExporting, progressList } = status;
-    this.setState({
-      collections: filteredCollections,
-      zipFileStats,
-      isExporting,
-      progressList,
-    });
-
-    this.setupWebsocketEventHandler();
-  }
-
-  setupWebsocketEventHandler() {
-    const { socket } = this.props;
-
-    if (socket != null) {
-      // websocket event
-      socket.on('admin:onProgressForExport', ({ currentCount, totalCount, progressList }) => {
-        this.setState({
-          isExporting: true,
-          progressList,
-        });
-      });
-
-      // websocket event
-      socket.on('admin:onStartZippingForExport', () => {
-        this.setState({
-          isZipping: true,
-        });
-      });
-
-      // websocket event
-      socket.on('admin:onTerminateForExport', ({ addedZipFileStat }) => {
-        const zipFileStats = this.state.zipFileStats.concat([addedZipFileStat]);
-
-        this.setState({
-          isExporting: false,
-          isZipping: false,
-          isExported: true,
-          zipFileStats,
-        });
-
-        // TODO: toastSuccess, toastError
-        toastr.success(undefined, `New Archive Data '${addedZipFileStat.fileName}' is added`, {
-          closeButton: true,
-          progressBar: true,
-          newestOnTop: false,
-          showDuration: '100',
-          hideDuration: '100',
-          timeOut: '1200',
-          extendedTimeOut: '150',
-        });
-      });
-    }
-  }
-
-  onZipFileStatAdd(newStat) {
-    this.setState((prevState) => {
-      return {
-        zipFileStats: [...prevState.zipFileStats, newStat],
-      };
-    });
-  }
-
-  async onZipFileStatRemove(fileName) {
-    try {
-      await apiDelete(`/v3/export/${fileName}`, {});
-
-      this.setState((prevState) => {
-        return {
-          zipFileStats: prevState.zipFileStats.filter(stat => stat.fileName !== fileName),
-        };
-      });
-
-      // TODO: toastSuccess, toastError
-      toastr.success(undefined, `Deleted ${fileName}`, {
-        closeButton: true,
-        progressBar: true,
-        newestOnTop: false,
-        showDuration: '100',
-        hideDuration: '100',
-        timeOut: '1200',
-        extendedTimeOut: '150',
-      });
-    }
-    catch (err) {
-      // TODO: toastSuccess, toastError
-      toastr.error(err, 'Error', {
-        closeButton: true,
-        progressBar: true,
-        newestOnTop: false,
-        showDuration: '100',
-        hideDuration: '100',
-        timeOut: '3000',
-      });
-    }
-  }
-
-  openExportModal() {
-    this.setState({ isExportModalOpen: true });
-  }
-
-  closeExportModal() {
-    this.setState({ isExportModalOpen: false });
-  }
-
-  /**
-   * event handler invoked when export process was requested successfully
-   */
-  exportingRequestedHandler() {
-  }
-
-  renderProgressBarsForCollections() {
-    const cols = this.state.progressList.map((progressData) => {
-      const { collectionName, currentCount, totalCount } = progressData;
-      return (
-        <div className="col-md-6" key={collectionName}>
-          <LabeledProgressBar
-            header={collectionName}
-            currentCount={currentCount}
-            totalCount={totalCount}
-          />
-        </div>
-      );
-    });
-
-    return <div className="row px-3">{cols}</div>;
-  }
-
-  renderProgressBarForZipping() {
-    const { isZipping, isExported } = this.state;
-    const showZippingBar = isZipping || isExported;
-
-    if (!showZippingBar) {
-      return <></>;
-    }
-
-    return (
-      <div className="row px-3">
-        <div className="col-md-12" key="progressBarForZipping">
-          <LabeledProgressBar
-            header="Zip Files"
-            currentCount={1}
-            totalCount={1}
-            isInProgress={isZipping}
-          />
-        </div>
-      </div>
-    );
-  }
-
-  render() {
-    const { t } = this.props;
-    const { isExporting, isExported, progressList } = this.state;
-
-    const showExportingData = (isExported || isExporting) && (progressList != null);
-
-    return (
-      <div data-testid="admin-export-archive-data">
-        <h2>{t('export_management.export_archive_data')}</h2>
-
-        <button type="button" className="btn btn-outline-secondary" disabled={isExporting} onClick={this.openExportModal}>
-          {t('export_management.create_new_archive_data')}
-        </button>
-
-        { showExportingData && (
-          <div className="mt-5">
-            <h3>{t('export_management.exporting_collection_list')}</h3>
-            { this.renderProgressBarsForCollections() }
-            { this.renderProgressBarForZipping() }
-          </div>
-        ) }
-
-        <div className="mt-5">
-          <h3>{t('export_management.exported_data_list')}</h3>
-          <ArchiveFilesTable
-            zipFileStats={this.state.zipFileStats}
-            onZipFileStatRemove={this.onZipFileStatRemove}
-          />
-        </div>
-
-        <SelectCollectionsModal
-          isOpen={this.state.isExportModalOpen}
-          onExportingRequested={this.exportingRequestedHandler}
-          onClose={this.closeExportModal}
-          collections={this.state.collections}
-        />
-      </div>
-    );
-  }
-
-}
-
-ExportArchiveDataPage.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  socket: PropTypes.object,
-};
-
-const ExportArchiveDataPageWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-  const { data: socket } = useAdminSocket();
-
-  return <ExportArchiveDataPage t={t} socket={socket} {...props} />;
-};
-
-export default ExportArchiveDataPageWrapperFC;

+ 198 - 0
packages/app/src/components/Admin/ExportArchiveDataPage.tsx

@@ -0,0 +1,198 @@
+import React, { useCallback, useEffect, useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import * as toastr from 'toastr';
+
+
+import { apiDelete } from '~/client/util/apiv1-client';
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { useAdminSocket } from '~/stores/socket-io';
+
+import LabeledProgressBar from './Common/LabeledProgressBar';
+import ArchiveFilesTable from './ExportArchiveData/ArchiveFilesTable';
+import SelectCollectionsModal from './ExportArchiveData/SelectCollectionsModal';
+
+
+const IGNORED_COLLECTION_NAMES = [
+  'sessions', 'rlflx', 'activities',
+];
+
+const ExportArchiveDataPage = (): JSX.Element => {
+  const { data: socket } = useAdminSocket();
+  const { t } = useTranslation();
+
+  const [collections, setCollections] = useState<any[]>([]);
+  const [zipFileStats, setZipFileStats] = useState<any[]>([]);
+  const [progressList, setProgressList] = useState<any[]>([]);
+  const [isExportModalOpen, setExportModalOpen] = useState(false);
+  const [isExporting, setExporting] = useState(false);
+  const [isZipping, setZipping] = useState(false);
+  const [isExported, setExported] = useState(false);
+
+  const fetchData = useCallback(async() => {
+    const [{ data: collectionsData }, { data: statusData }] = await Promise.all([
+      apiv3Get<{collections: any[]}>('/mongo/collections', {}),
+      apiv3Get<{status: { zipFileStats: any[], isExporting: boolean, progressList: any[] }}>('/export/status', {}),
+    ]);
+    // TODO: toastSuccess, toastError
+
+    // filter only not ignored collection names
+    const filteredCollections = collectionsData.collections.filter((collectionName) => {
+      return !IGNORED_COLLECTION_NAMES.includes(collectionName);
+    });
+
+    const { zipFileStats, isExporting, progressList } = statusData.status;
+    setCollections(filteredCollections);
+    setZipFileStats(zipFileStats);
+    setExporting(isExporting);
+    setProgressList(progressList);
+  }, []);
+
+  const setupWebsocketEventHandler = useCallback(() => {
+    if (socket != null) {
+      // websocket event
+      socket.on('admin:onProgressForExport', ({ currentCount, totalCount, progressList }) => {
+        setExporting(true);
+        setProgressList(progressList);
+      });
+
+      // websocket event
+      socket.on('admin:onStartZippingForExport', () => {
+        setZipping(true);
+      });
+
+      // websocket event
+      socket.on('admin:onTerminateForExport', ({ addedZipFileStat }) => {
+
+        setExporting(false);
+        setZipping(false);
+        setExported(true);
+        setZipFileStats(prev => prev.concat([addedZipFileStat]));
+
+        // TODO: toastSuccess, toastError
+        toastr.success(undefined, `New Archive Data '${addedZipFileStat.fileName}' is added`, {
+          closeButton: true,
+          progressBar: true,
+          newestOnTop: false,
+          showDuration: '100',
+          hideDuration: '100',
+          timeOut: '1200',
+          extendedTimeOut: '150',
+        });
+      });
+    }
+  }, [socket]);
+
+  const onZipFileStatRemove = useCallback(async(fileName) => {
+    try {
+      await apiDelete(`/v3/export/${fileName}`, {});
+
+      setZipFileStats(prev => prev.filter(stat => stat.fileName !== fileName));
+
+      // TODO: toastSuccess, toastError
+      toastr.success(undefined, `Deleted ${fileName}`, {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '1200',
+        extendedTimeOut: '150',
+      });
+    }
+    catch (err) {
+      // TODO: toastSuccess, toastError
+      toastr.error(err, 'Error', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    }
+  }, []);
+
+  const exportingRequestedHandler = useCallback(() => {}, []);
+
+  const renderProgressBarsForCollections = useCallback(() => {
+    const cols = progressList.map((progressData) => {
+      const { collectionName, currentCount, totalCount } = progressData;
+      return (
+        <div className="col-md-6" key={collectionName}>
+          <LabeledProgressBar
+            header={collectionName}
+            currentCount={currentCount}
+            totalCount={totalCount}
+          />
+        </div>
+      );
+    });
+
+    return <div className="row px-3">{cols}</div>;
+  }, [progressList]);
+
+  const renderProgressBarForZipping = useCallback(() => {
+    const showZippingBar = isZipping || isExported;
+
+    if (!showZippingBar) {
+      return <></>;
+    }
+
+    return (
+      <div className="row px-3">
+        <div className="col-md-12" key="progressBarForZipping">
+          <LabeledProgressBar
+            header="Zip Files"
+            currentCount={1}
+            totalCount={1}
+            isInProgress={isZipping}
+          />
+        </div>
+      </div>
+    );
+  }, [isExported, isZipping]);
+
+  useEffect(() => {
+    fetchData();
+
+    setupWebsocketEventHandler();
+  }, [fetchData, setupWebsocketEventHandler]);
+
+  const showExportingData = (isExported || isExporting) && (progressList != null);
+
+  return (
+    <div data-testid="admin-export-archive-data">
+      <h2>{t('export_management.export_archive_data')}</h2>
+
+      <button type="button" className="btn btn-outline-secondary" disabled={isExporting} onClick={() => setExportModalOpen(true)}>
+        {t('export_management.create_new_archive_data')}
+      </button>
+
+      { showExportingData && (
+        <div className="mt-5">
+          <h3>{t('export_management.exporting_collection_list')}</h3>
+          { renderProgressBarsForCollections() }
+          { renderProgressBarForZipping() }
+        </div>
+      ) }
+
+      <div className="mt-5">
+        <h3>{t('export_management.exported_data_list')}</h3>
+        <ArchiveFilesTable
+          zipFileStats={zipFileStats}
+          onZipFileStatRemove={onZipFileStatRemove}
+        />
+      </div>
+
+      <SelectCollectionsModal
+        isOpen={isExportModalOpen}
+        onExportingRequested={exportingRequestedHandler}
+        onClose={() => setExportModalOpen(false)}
+        collections={collections}
+      />
+    </div>
+  );
+};
+
+export default ExportArchiveDataPage;

+ 284 - 0
packages/app/src/components/Admin/G2GDataTransfer.tsx

@@ -0,0 +1,284 @@
+import React, {
+  ChangeEvent, useCallback, useEffect, useState,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { useGenerateTransferKey } from '~/client/services/g2g-transfer';
+import { toastError, toastSuccess } from '~/client/util/apiNotification';
+import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
+import { G2G_PROGRESS_STATUS, type G2GProgress } from '~/interfaces/g2g-transfer';
+import { useAdminSocket } from '~/stores/socket-io';
+
+import CustomCopyToClipBoard from '../Common/CustomCopyToClipBoard';
+
+// import { FileUploadSettingMolecule } from './App/FileUploadSetting';
+import G2GDataTransferExportForm from './G2GDataTransferExportForm';
+import G2GDataTransferStatusIcon from './G2GDataTransferStatusIcon';
+
+const IGNORED_COLLECTION_NAMES = [
+  'sessions', 'rlflx', 'activities', 'attachmentFiles.files', 'attachmentFiles.chunks',
+];
+
+const G2GDataTransfer = (): JSX.Element => {
+  const { data: socket } = useAdminSocket();
+  const { t } = useTranslation(['admin', 'commons']);
+
+  const [startTransferKey, setStartTransferKey] = useState('');
+  const [collections, setCollections] = useState<string[]>([]);
+  const [selectedCollections, setSelectedCollections] = useState<Set<string>>(new Set());
+  const [optionsMap, setOptionsMap] = useState<any>({});
+  const [isShowExportForm, setShowExportForm] = useState(false);
+  const [isTransferring, setTransferring] = useState(false);
+  const [g2gProgress, setG2GProgress] = useState<G2GProgress>({
+    mongo: G2G_PROGRESS_STATUS.PENDING,
+    attachments: G2G_PROGRESS_STATUS.PENDING,
+  });
+
+  // File upload settings
+  // const [fileUploadType, setFileUploadType] = useState('aws');
+  // const [s3ReferenceFileWithRelayMode, setS3ReferenceFileWithRelayMode] = useState(false);
+  // const [s3Region, setS3Region] = useState('');
+  // const [s3CustomEndpoint, setS3CustomEndpoint] = useState('');
+  // const [s3Bucket, setS3Bucket] = useState('');
+  // const [s3AccessKeyId, setS3AccessKeyId] = useState('');
+  // const [s3SecretAccessKey, setS3SecretAccessKey] = useState('');
+  // const [gcsReferenceFileWithRelayMode, setGcsReferenceFileWithRelayMode] = useState(false);
+  // const [gcsApiKeyJsonPath, setGcsApiKeyJsonPath] = useState('');
+  // const [gcsBucket, setGcsBucket] = useState('');
+  // const [gcsUploadNamespace, setGcsUploadNamespace] = useState('');
+
+  const updateSelectedCollections = (newSelectedCollections: Set<string>) => {
+    setSelectedCollections(newSelectedCollections);
+  };
+
+  const updateOptionsMap = (newOptionsMap: any) => {
+    setOptionsMap(newOptionsMap);
+  };
+
+  const onChangeTransferKeyHandler = useCallback((e) => {
+    setStartTransferKey(e.target.value);
+  }, []);
+
+  const setCollectionsAndSelectedCollections = useCallback(async() => {
+    const { data: collectionsData } = await apiv3Get<{collections: any[]}>('/mongo/collections', {});
+
+    // filter only not ignored collection names
+    const filteredCollections = collectionsData.collections.filter((collectionName) => {
+      return !IGNORED_COLLECTION_NAMES.includes(collectionName);
+    });
+
+    setCollections(filteredCollections);
+    setSelectedCollections(new Set(filteredCollections));
+  }, []);
+
+  const setupWebsocketEventHandler = useCallback(() => {
+    if (socket != null) {
+      socket.on('admin:g2gProgress', (g2gProgress: G2GProgress) => {
+        setG2GProgress(g2gProgress);
+
+        if (g2gProgress.mongo === G2G_PROGRESS_STATUS.COMPLETED && g2gProgress.attachments === G2G_PROGRESS_STATUS.COMPLETED) {
+          toastSuccess(t('admin:g2g:transfer_success'));
+        }
+      });
+
+      socket.on('admin:g2gError', ({ key }) => {
+        setTransferring(false);
+        toastError(t(key));
+      });
+    }
+  }, [socket, t, setTransferring, setG2GProgress]);
+
+  const cleanUpWebsocketEventHandler = useCallback(() => {
+    if (socket != null) {
+      socket.off('admin:g2gProgress');
+      socket.off('admin:g2gError');
+    }
+  }, [socket]);
+
+  const { transferKey, generateTransferKey } = useGenerateTransferKey();
+
+  const onClickHandler = useCallback(async() => {
+    try {
+      await generateTransferKey();
+    }
+    catch (errs) {
+      toastError(errs);
+    }
+  }, [generateTransferKey]);
+
+  const startTransfer = useCallback(async(e) => {
+    e.preventDefault();
+    setTransferring(true);
+
+    try {
+      await apiv3Post('/g2g-transfer/transfer', {
+        transferKey: startTransferKey,
+        collections: Array.from(selectedCollections),
+        optionsMap,
+      });
+    }
+    catch (errs) {
+      toastError(errs);
+    }
+  }, [setTransferring, startTransferKey, selectedCollections, optionsMap]);
+
+  // File upload
+  // const onChangeFileUploadTypeHandler = useCallback((e: ChangeEvent, type: string) => {
+  //   setFileUploadType(type);
+  // }, []);
+
+  // S3
+  // const onChangeS3ReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
+  //   setS3ReferenceFileWithRelayMode(val);
+  // }, []);
+
+  // const onChangeS3RegionHandler = useCallback((val: string) => {
+  //   setS3Region(val);
+  // }, []);
+
+  // const onChangeS3CustomEndpointHandler = useCallback((val: string) => {
+  //   setS3CustomEndpoint(val);
+  // }, []);
+
+  // const onChangeS3BucketHandler = useCallback((val: string) => {
+  //   setS3Bucket(val);
+  // }, []);
+
+  // const onChangeS3AccessKeyIdHandler = useCallback((val: string) => {
+  //   setS3AccessKeyId(val);
+  // }, []);
+
+  // const onChangeS3SecretAccessKeyHandler = useCallback((val: string) => {
+  //   setS3SecretAccessKey(val);
+  // }, []);
+
+  // // GCS
+  // const onChangeGcsReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
+  //   setGcsReferenceFileWithRelayMode(val);
+  // }, []);
+
+  // const onChangeGcsApiKeyJsonPathHandler = useCallback((val: string) => {
+  //   setGcsApiKeyJsonPath(val);
+  // }, []);
+
+  // const onChangeGcsBucketHandler = useCallback((val: string) => {
+  //   setGcsBucket(val);
+  // }, []);
+
+  // const onChangeGcsUploadNamespaceHandler = useCallback((val: string) => {
+  //   setGcsUploadNamespace(val);
+  // }, []);
+
+
+  useEffect(() => {
+    setCollectionsAndSelectedCollections();
+    setupWebsocketEventHandler();
+
+    return () => {
+      cleanUpWebsocketEventHandler();
+    };
+  }, [setCollectionsAndSelectedCollections, setupWebsocketEventHandler, cleanUpWebsocketEventHandler]);
+
+  return (
+    <div data-testid="admin-export-archive-data">
+      <h2 className="border-bottom">{t('admin:g2g_data_transfer.transfer_data_to_another_growi')}</h2>
+
+      <button type="button" className="btn btn-outline-secondary mt-4" disabled={isTransferring} onClick={() => setShowExportForm(!isShowExportForm)}>
+        {t('admin:g2g_data_transfer.advanced_options')}
+      </button>
+
+      {collections.length !== 0 && (
+        <div className={`${isShowExportForm ? '' : 'd-none'} px-3 pt-3`}>
+          {/* <h3 className='mb-1'>{t('admin:app_setting.file_upload')}</h3>
+          <FileUploadSettingMolecule
+            fileUploadType={fileUploadType}
+            isFixedFileUploadByEnvVar={false}
+            onChangeFileUploadType={onChangeFileUploadTypeHandler}
+            s3ReferenceFileWithRelayMode={s3ReferenceFileWithRelayMode}
+            s3Region={s3Region}
+            s3CustomEndpoint={s3CustomEndpoint}
+            s3Bucket={s3Bucket}
+            s3AccessKeyId={s3AccessKeyId}
+            s3SecretAccessKey={s3SecretAccessKey}
+            onChangeS3ReferenceFileWithRelayMode={onChangeS3ReferenceFileWithRelayModeHandler}
+            onChangeS3Region={onChangeS3RegionHandler}
+            onChangeS3CustomEndpoint={onChangeS3CustomEndpointHandler}
+            onChangeS3Bucket={onChangeS3BucketHandler}
+            onChangeS3AccessKeyId={onChangeS3AccessKeyIdHandler}
+            onChangeS3SecretAccessKey={onChangeS3SecretAccessKeyHandler}
+            gcsReferenceFileWithRelayMode={gcsReferenceFileWithRelayMode}
+            gcsUseOnlyEnvVars={false}
+            gcsApiKeyJsonPath={gcsApiKeyJsonPath}
+            gcsBucket={gcsBucket}
+            gcsUploadNamespace={gcsUploadNamespace}
+            onChangeGcsReferenceFileWithRelayMode={onChangeGcsReferenceFileWithRelayModeHandler}
+            onChangeGcsApiKeyJsonPath={onChangeGcsApiKeyJsonPathHandler}
+            onChangeGcsBucket={onChangeGcsBucketHandler}
+            onChangeGcsUploadNamespace={onChangeGcsUploadNamespaceHandler}
+          /> */}
+          <h3 className='mb-1'>{t('export_management.export_archive_data')}</h3>
+          <G2GDataTransferExportForm
+            allCollectionNames={collections}
+            selectedCollections={selectedCollections}
+            updateSelectedCollections={updateSelectedCollections}
+            optionsMap={optionsMap}
+            updateOptionsMap={updateOptionsMap}
+          />
+        </div>
+      )}
+
+      <form onSubmit={startTransfer}>
+        <div className="form-group row mt-3">
+          <div className="col-9">
+            <input
+              className="form-control"
+              type="text"
+              placeholder={t('admin:g2g_data_transfer.paste_transfer_key')}
+              onChange={onChangeTransferKeyHandler}
+              required
+            />
+          </div>
+          <div className="col-3">
+            <button type="submit" className="btn btn-primary w-100">{t('admin:g2g_data_transfer.start_transfer')}</button>
+          </div>
+        </div>
+      </form>
+
+      {isTransferring && (
+        <div className='border rounded p-4'>
+          <div>
+            <G2GDataTransferStatusIcon className='mr-2 mb-2' status={g2gProgress.mongo} /> MongoDB
+          </div>
+          <div>
+            <G2GDataTransferStatusIcon className='mr-2' status={g2gProgress.attachments} /> Attachments
+          </div>
+        </div>
+      )}
+
+      <h2 className="border-bottom mt-5">{t('commons:g2g_data_transfer.transfer_data_to_this_growi')}</h2>
+
+      <div className="form-group row mt-4">
+        <div className="col-md-3">
+          <button type="button" className="btn btn-primary w-100" onClick={onClickHandler}>
+            {t('commons:g2g_data_transfer.publish_transfer_key')}
+          </button>
+        </div>
+        <div className="col-md-9">
+          <div className="input-group-prepend mx-1">
+            <input className="form-control" type="text" value={transferKey} readOnly />
+            <CustomCopyToClipBoard textToBeCopied={transferKey} message="admin:slack_integration.copied_to_clipboard" />
+          </div>
+        </div>
+      </div>
+
+      <div className="alert alert-warning mt-4">
+        <p className="mb-1">{t('commons:g2g_data_transfer.transfer_key_limit')}</p>
+        <p className="mb-1">{t('commons:g2g_data_transfer.once_transfer_key_used')}</p>
+        <p className="mb-0">{t('commons:g2g_data_transfer.transfer_to_growi_cloud')}</p>
+      </div>
+    </div>
+  );
+};
+
+export default G2GDataTransfer;

+ 237 - 0
packages/app/src/components/Admin/G2GDataTransferExportForm.tsx

@@ -0,0 +1,237 @@
+import React, {
+  useState, useEffect, useCallback, useMemo,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import GrowiArchiveImportOption from '~/models/admin/growi-archive-import-option';
+import ImportOptionForPages from '~/models/admin/import-option-for-pages';
+import ImportOptionForRevisions from '~/models/admin/import-option-for-revisions';
+
+import ImportCollectionConfigurationModal from './ImportData/GrowiArchive/ImportCollectionConfigurationModal';
+import ImportCollectionItem, { DEFAULT_MODE, MODE_RESTRICTED_COLLECTION } from './ImportData/GrowiArchive/ImportCollectionItem';
+
+const GROUPS_PAGE = [
+  'pages', 'revisions', 'tags', 'pagetagrelations',
+];
+const GROUPS_USER = [
+  'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
+];
+const GROUPS_CONFIG = [
+  'configs', 'updateposts', 'globalnotificationsettings',
+];
+const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
+
+const IMPORT_OPTION_CLASS_MAPPING = {
+  pages: ImportOptionForPages,
+  revisions: ImportOptionForRevisions,
+};
+
+type Props = {
+  allCollectionNames: string[],
+  selectedCollections: Set<string>,
+  updateSelectedCollections: (newSelectedCollections: Set<string>) => void,
+  optionsMap: any,
+  updateOptionsMap: (newOptionsMap: any) => void,
+};
+
+const G2GDataTransferExportForm = (props: Props): JSX.Element => {
+  const { t } = useTranslation('admin');
+
+  const {
+    allCollectionNames, selectedCollections, updateSelectedCollections, optionsMap, updateOptionsMap,
+  } = props;
+
+  const [isConfigurationModalOpen, setConfigurationModalOpen] = useState(false);
+  const [collectionNameForConfiguration, setCollectionNameForConfiguration] = useState<any>();
+
+  const checkAll = useCallback(() => {
+    updateSelectedCollections(new Set(allCollectionNames));
+  }, [allCollectionNames, updateSelectedCollections]);
+
+  const uncheckAll = useCallback(() => {
+    updateSelectedCollections(new Set());
+  }, [updateSelectedCollections]);
+
+  const updateOption = useCallback((collectionName, data) => {
+    const options = optionsMap[collectionName];
+
+    // merge
+    Object.assign(options, data);
+
+    const updatedOptionsMap = {};
+    updatedOptionsMap[collectionName] = options;
+    updateOptionsMap((prev) => {
+      return { ...prev, updatedOptionsMap };
+    });
+  }, [optionsMap, updateOptionsMap]);
+
+  const ImportItems = ({ collectionNames }): JSX.Element => {
+    const toggleCheckbox = (collectionName, bool) => {
+      const collections = new Set(selectedCollections);
+      if (bool) {
+        collections.add(collectionName);
+      }
+      else {
+        collections.delete(collectionName);
+      }
+
+      updateSelectedCollections(collections);
+
+      // TODO: validation
+      // this.validate();
+    };
+
+    const openConfigurationModal = (collectionName) => {
+      setConfigurationModalOpen(true);
+      setCollectionNameForConfiguration(collectionName);
+    };
+
+    return (
+      <div className="row">
+        {collectionNames.map((collectionName) => {
+          const isConfigButtonAvailable = Object.keys(IMPORT_OPTION_CLASS_MAPPING).includes(collectionName);
+
+          if (optionsMap[collectionName] == null) {
+            return null;
+          }
+
+          return (
+            <div className="col-md-6 my-1" key={collectionName}>
+              <ImportCollectionItem
+                isImporting={false}
+                isImported={false}
+                insertedCount={0}
+                modifiedCount={0}
+                errorsCount={0}
+                collectionName={collectionName}
+                isSelected={selectedCollections.has(collectionName)}
+                option={optionsMap[collectionName]}
+                isConfigButtonAvailable={isConfigButtonAvailable}
+                // events
+                onChange={toggleCheckbox}
+                onOptionChange={updateOption}
+                onConfigButtonClicked={openConfigurationModal}
+                // TODO: show progress
+                isHideProgress
+              />
+            </div>
+          );
+        })}
+      </div>
+    );
+  };
+
+  const WarnForGroups = ({ errors }): JSX.Element => {
+    if (errors.length === 0) {
+      return <></>;
+    }
+
+    return (
+      <div className="alert alert-warning">
+        <ul>
+          {errors.map((error, i) => {
+            return <li key={i}>{error}</li>;
+          })}
+        </ul>
+      </div>
+    );
+  };
+
+  const GroupImportItems = ({ groupList, groupName, errors }): JSX.Element => {
+    const collectionNames = groupList.filter((groupCollectionName) => {
+      return allCollectionNames.includes(groupCollectionName);
+    });
+
+    if (collectionNames.length === 0) {
+      return <></>;
+    }
+
+    return (
+      <div className="mt-4">
+        <legend>{groupName} Collections</legend>
+        <ImportItems collectionNames={collectionNames} />
+        <WarnForGroups errors={errors} />
+      </div>
+    );
+  };
+
+  const OtherImportItems = (): JSX.Element => {
+    const collectionNames = allCollectionNames.filter((collectionName) => {
+      return !ALL_GROUPED_COLLECTIONS.includes(collectionName);
+    });
+
+    // TODO: エラー対応
+    return <GroupImportItems groupList={collectionNames} groupName='Other' errors={[]} />;
+  };
+
+  const configurationModal = useMemo(() => {
+    if (collectionNameForConfiguration == null) {
+      return <></>;
+    }
+
+    return (
+      <ImportCollectionConfigurationModal
+        isOpen={isConfigurationModalOpen}
+        onClose={() => setConfigurationModalOpen(false)}
+        onOptionChange={updateOption}
+        collectionName={collectionNameForConfiguration}
+        option={optionsMap[collectionNameForConfiguration]}
+      />
+    );
+  }, [collectionNameForConfiguration, isConfigurationModalOpen, optionsMap, updateOption]);
+
+  const setInitialOptionsMap = useCallback(() => {
+    const initialOptionsMap = {};
+    allCollectionNames.forEach((collectionName) => {
+      const initialMode = (MODE_RESTRICTED_COLLECTION[collectionName] != null)
+        ? MODE_RESTRICTED_COLLECTION[collectionName][0]
+        : DEFAULT_MODE;
+      const ImportOption = IMPORT_OPTION_CLASS_MAPPING[collectionName] || GrowiArchiveImportOption;
+      initialOptionsMap[collectionName] = new ImportOption(initialMode);
+    });
+    updateOptionsMap(initialOptionsMap);
+  }, [allCollectionNames, updateOptionsMap]);
+
+  useEffect(() => {
+    setInitialOptionsMap();
+  }, []);
+
+  return (
+    <>
+      <form className="form-inline mt-3">
+        <div className="form-group">
+          <button type="button" className="btn btn-sm btn-outline-secondary mr-2" onClick={checkAll}>
+            <i className="fa fa-check-square-o"></i> {t('admin:export_management.check_all')}
+          </button>
+        </div>
+        <div className="form-group">
+          <button type="button" className="btn btn-sm btn-outline-secondary mr-2" onClick={uncheckAll}>
+            <i className="fa fa-square-o"></i> {t('admin:export_management.uncheck_all')}
+          </button>
+        </div>
+      </form>
+
+      <div className="card well small my-4">
+        <ul>
+          <li>{t('admin:importer_management.growi_settings.description_of_import_mode.about')}</li>
+          <ul>
+            <li>{t('admin:importer_management.growi_settings.description_of_import_mode.insert')}</li>
+            <li>{t('admin:importer_management.growi_settings.description_of_import_mode.upsert')}</li>
+            <li>{t('admin:importer_management.growi_settings.description_of_import_mode.flash_and_insert')}</li>
+          </ul>
+        </ul>
+      </div>
+
+      {/* TODO: エラー追加 */}
+      <GroupImportItems groupList={GROUPS_PAGE} groupName='Page' errors={[]} />
+      <GroupImportItems groupList={GROUPS_USER} groupName='User' errors={[]} />
+      <GroupImportItems groupList={GROUPS_CONFIG} groupName='Config' errors={[]} />
+      <OtherImportItems />
+
+      {configurationModal}
+    </>
+  );
+};
+
+export default G2GDataTransferExportForm;

+ 43 - 0
packages/app/src/components/Admin/G2GDataTransferStatusIcon.tsx

@@ -0,0 +1,43 @@
+import React, { type ComponentPropsWithoutRef } from 'react';
+
+import { G2G_PROGRESS_STATUS, type G2GProgressStatus } from '~/interfaces/g2g-transfer';
+
+/**
+ * Props for {@link G2GDataTransferStatusIcon}
+ */
+interface Props extends ComponentPropsWithoutRef<'i'>{
+  status: G2GProgressStatus;
+}
+
+/**
+ * Icon for G2G transfer status
+ */
+const G2GDataTransferStatusIcon = ({ status, className, ...props }: Props): JSX.Element => {
+  if (status === G2G_PROGRESS_STATUS.IN_PROGRESS) {
+    return (
+      <i className={`fa fa-spinner fa-pulse fa-fw ${className}`} aria-label="in progress" {...props} />
+    );
+  }
+
+  if (status === G2G_PROGRESS_STATUS.COMPLETED) {
+    return (
+      <i className={`fa fa-check-circle-o fa-fw text-info ${className}`} aria-label="completed" {...props} />
+    );
+  }
+
+  if (status === G2G_PROGRESS_STATUS.ERROR) {
+    return (
+      <i className={`fa fa-exclamation-circle fa-fw text-danger ${className}`} aria-label="error" {...props} />
+    );
+  }
+
+  if (status === G2G_PROGRESS_STATUS.SKIPPED) {
+    return (
+      <i className={`fa fa-ban fa-fw ${className}`} aria-label="skipped" {...props} />
+    );
+  }
+
+  return <i className={`fa fa-circle-o fa-fw ${className}`} aria-label="pending" {...props} />;
+};
+
+export default G2GDataTransferStatusIcon;

+ 4 - 2
packages/app/src/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx

@@ -17,6 +17,7 @@ export const DEFAULT_MODE = 'insert';
 export const MODE_RESTRICTED_COLLECTION = {
   configs: ['flushAndInsert'],
   users: ['insert', 'upsert'],
+  pages: ['upsert', 'flushAndInsert'],
 };
 
 export default class ImportCollectionItem extends React.Component {
@@ -194,7 +195,7 @@ export default class ImportCollectionItem extends React.Component {
 
   render() {
     const {
-      isSelected,
+      isSelected, isHideProgress,
     } = this.props;
 
     return (
@@ -210,7 +211,7 @@ export default class ImportCollectionItem extends React.Component {
             </span>
           </div>
         </div>
-        {isSelected && (
+        {isSelected && !isHideProgress && (
           <>
             {this.renderProgressBar()}
             <div className="card-body">{this.renderBody()}</div>
@@ -225,6 +226,7 @@ export default class ImportCollectionItem extends React.Component {
 ImportCollectionItem.propTypes = {
   collectionName: PropTypes.string.isRequired,
   isSelected: PropTypes.bool.isRequired,
+  isHideProgress: PropTypes.bool,
   option: PropTypes.instanceOf(GrowiArchiveImportOption).isRequired,
 
   isImporting: PropTypes.bool.isRequired,

+ 6 - 8
packages/app/src/components/Admin/MarkdownSetting/WhiteListInput.jsx

@@ -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() {
@@ -41,12 +41,11 @@ class WhiteListInput extends React.Component {
         <div className="mt-4">
           <div className="d-flex justify-content-between">
             {t('markdown_settings.xss_options.tag_names')}
-            <p id="btn-import-tags" className="btn btn-sm btn-primary mb-0 disabled" onClick={this.onClickRecommendTagButton}>
+            <p id="btn-import-tags" className="btn btn-sm btn-primary mb-0" onClick={this.onClickRecommendTagButton}>
               {t('markdown_settings.xss_options.import_recommended', { target: 'Tags' })}
             </p>
           </div>
           <textarea
-            disabled
             className="form-control xss-list"
             name="recommendedTags"
             rows="6"
@@ -59,12 +58,11 @@ class WhiteListInput extends React.Component {
         <div className="mt-4">
           <div className="d-flex justify-content-between">
             {t('markdown_settings.xss_options.tag_attributes')}
-            <p id="btn-import-tags" className="btn btn-sm btn-primary mb-0 disabled" onClick={this.onClickRecommendAttrButton}>
+            <p id="btn-import-tags" className="btn btn-sm btn-primary mb-0" onClick={this.onClickRecommendAttrButton}>
               {t('markdown_settings.xss_options.import_recommended', { target: 'Attrs' })}
             </p>
           </div>
           <textarea
-            disabled
             className="form-control xss-list"
             name="recommendedAttrs"
             rows="6"

+ 1 - 4
packages/app/src/components/Admin/MarkdownSetting/XssForm.jsx

@@ -93,7 +93,6 @@ class XssForm extends React.Component {
           <div className="col-md-6 col-sm-12 align-self-start mb-4">
             <div className="custom-control custom-radio">
               <input
-                disabled
                 type="radio"
                 className="custom-control-input"
                 id="xssOption2"
@@ -102,9 +101,7 @@ class XssForm extends React.Component {
                 onChange={() => { adminMarkDownContainer.setState({ xssOption: RehypeSanitizeOption.CUSTOM }) }}
               />
               <label className="custom-control-label w-100" htmlFor="xssOption2">
-                <p className="font-weight-bold">{t('markdown_settings.xss_options.custom_whitelist')}
-                  <span className='text-warning'> (TBD: Currently unavailable)</span>
-                </p>
+                <p className="font-weight-bold">{t('markdown_settings.xss_options.custom_whitelist')}</p>
                 <WhiteListInput customizable />
               </label>
             </div>

+ 1 - 25
packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -12,6 +12,7 @@ import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
 import { useSiteUrl } from '~/stores/context';
 import loggerFactory from '~/utils/logger';
 
+import CustomCopyToClipBoard from '../../Common/CustomCopyToClipBoard';
 import Accordion from '../Common/Accordion';
 
 import ManageCommandsProcess from './ManageCommandsProcess';
@@ -117,31 +118,6 @@ const RegisteringProxyUrlProcess = () => {
   );
 };
 
-// To get different messages for each copy happend, wrapping CopyToClipBoard and Tooltip together
-const CustomCopyToClipBoard = (props) => {
-  const { t } = useTranslation();
-  const [tooltipOpen, setTooltipOpen] = useState(false);
-
-  const showToolTip = useCallback(() => {
-    setTooltipOpen(true);
-    setTimeout(() => {
-      setTooltipOpen(false);
-    }, 1000);
-  }, []);
-  return (
-    <>
-      <CopyToClipboard text={props.textToBeCopied || ''} onCopy={showToolTip}>
-        <div className="btn input-group-text" id="tooltipTarget">
-          <i className="fa fa-clipboard mx-1" aria-hidden="true"></i>
-        </div>
-      </CopyToClipboard>
-      <Tooltip target="tooltipTarget" fade={false} isOpen={tooltipOpen}>
-        {t(props.message)}
-      </Tooltip>
-    </>
-  );
-};
-
 const GeneratingTokensAndRegisteringProxyServiceProcess = (props) => {
   const { t } = useTranslation();
   const { slackAppIntegrationId } = props;

+ 1 - 1
packages/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx

@@ -43,7 +43,7 @@ export const UpdateParentConfirmModal: FC = () => {
                 {t('admin:user_group_management.update_parent_confirm_modal.danger_message')}
               </div>
 
-              <div className="custom-control custom-checkbox custom-checkbox-primary pl-5">
+              <div className="custom-control custom-checkbox custom-checkbox-succsess pl-5">
                 <input
                   className="custom-control-input"
                   name="forceUpdateParents"

+ 38 - 0
packages/app/src/components/Common/CustomCopyToClipBoard.tsx

@@ -0,0 +1,38 @@
+import React, { FC, useState, useCallback } from 'react';
+
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+import { useTranslation } from 'react-i18next';
+import { Tooltip } from 'reactstrap';
+
+type Props = {
+  message: string
+  textToBeCopied?: string
+}
+
+// To get different messages for each copy happend, wrapping CopyToClipBoard and Tooltip together
+const CustomCopyToClipBoard: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const [tooltipOpen, setTooltipOpen] = useState(false);
+
+  const showToolTip = useCallback(() => {
+    setTooltipOpen(true);
+    setTimeout(() => {
+      setTooltipOpen(false);
+    }, 1000);
+  }, []);
+
+  return (
+    <>
+      <CopyToClipboard text={props.textToBeCopied || ''} onCopy={showToolTip}>
+        <div className="btn input-group-text" id="tooltipTarget">
+          <i className="fa fa-clipboard mx-1" aria-hidden="true"></i>
+        </div>
+      </CopyToClipboard>
+      <Tooltip target="tooltipTarget" fade={false} isOpen={tooltipOpen}>
+        {t(props.message)}
+      </Tooltip>
+    </>
+  );
+};
+
+export default CustomCopyToClipBoard;

+ 42 - 0
packages/app/src/components/DataTransferForm.tsx

@@ -0,0 +1,42 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { useGenerateTransferKey } from '~/client/services/g2g-transfer';
+
+import CustomCopyToClipBoard from './Common/CustomCopyToClipBoard';
+
+const DataTransferForm = (): JSX.Element => {
+  const { t } = useTranslation('commons');
+  const { transferKey, generateTransferKey } = useGenerateTransferKey();
+
+  return (
+    <div data-testid="installerForm" className="p-3">
+      <p className="alert alert-success">
+        <strong>{ t('g2g_data_transfer.transfer_data_to_this_growi')}</strong>
+      </p>
+
+      <div className="form-group row mt-3">
+        <div className="col-md-12">
+          <button type="button" className="btn btn-primary w-100" onClick={generateTransferKey}>
+            {t('g2g_data_transfer.publish_transfer_key')}
+          </button>
+        </div>
+        <div className="col-md-12 mt-1">
+          <div className="input-group-prepend">
+            <input className="form-control" type="text" value={transferKey} readOnly />
+            <CustomCopyToClipBoard textToBeCopied={transferKey} message="copied_to_clipboard" />
+          </div>
+        </div>
+      </div>
+
+      <div className="alert alert-warning mt-4">
+        <p className="mb-1">{t('g2g_data_transfer.transfer_key_limit')}</p>
+        <p className="mb-1">{t('g2g_data_transfer.once_transfer_key_used')}</p>
+        <p className="mb-0">{t('g2g_data_transfer.transfer_to_growi_cloud')}</p>
+      </div>
+    </div>
+  );
+};
+
+export default DataTransferForm;

+ 33 - 1
packages/app/src/components/Fab.module.scss

@@ -1,9 +1,41 @@
+@use '~/styles/bootstrap/init' as bs;
+
 .grw-fab :global {
+  position: fixed;
+  right: 1.5rem;
+  bottom: 3rem;
+  z-index: bs.$zindex-fixed;
+
+  transition: all 200ms linear;
+
+  .btn-create-page {
+    width: 60px;
+    height: 60px;
+    font-size: 24px;
+
+    box-shadow: 2px 3px 6px #0000005d;
+    svg {
+      width: 28px;
+      height: 28px;
+    }
+  }
+
+  .btn-scroll-to-top {
+    width: 40px;
+    height: 40px;
+
+    opacity: 0.4;
+    svg {
+      width: 18px;
+      height: 18px;
+    }
+  }
+
   // workaround
   // https://stackoverflow.com/a/57667536
   .fadeInUp {
     & :local {
-      animation: fab-fadeinup 1s ease 0s;
+      animation: fab-fadeinup 0.5s ease 0s;
     }
   }
   .fadeOut {

+ 50 - 34
packages/app/src/components/Fab.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useState, useCallback, useRef,
+  useState, useCallback, useRef, useEffect,
 } from 'react';
 
 import { animateScroll } from 'react-scroll';
@@ -7,9 +7,9 @@ import { useRipple } from 'react-use-ripple';
 import StickyEvents from 'sticky-events';
 
 import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
-import { useCurrentUser } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import { useCurrentPagePath } from '~/stores/page';
+import { useIsAbleToChangeEditorMode } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
 import { CreatePageIcon } from './Icons/CreatePageIcon';
@@ -21,45 +21,61 @@ const logger = loggerFactory('growi:cli:Fab');
 
 export const Fab = (): JSX.Element => {
 
-  const { data: currentUser } = useCurrentUser();
+  const { data: isAbleToChangeEditorMode } = useIsAbleToChangeEditorMode();
   const { data: currentPath = '' } = useCurrentPagePath();
   const { open: openCreateModal } = usePageCreateModal();
 
-  const [animateClasses, setAnimateClasses] = useState('invisible');
-  const [buttonClasses, setButtonClasses] = useState('');
+  const [animateClasses, setAnimateClasses] = useState<string>('invisible');
+  const [buttonClasses, setButtonClasses] = useState<string>('');
+  const [isSticky, setIsSticky] = useState<boolean>(false);
 
   // ripple
   const createBtnRef = useRef(null);
   useRipple(createBtnRef, { rippleColor: 'rgba(255, 255, 255, 0.3)' });
 
-  /*
-  * TODO: Comment out to prevent err >>> TypeError: Cannot read properties of null (reading 'bottom')
-  *       We need add style={{ position: 'relative }} to child elements if disable StickyEvents. see: use grep = "<Fab".
-  */
-  // const stickyChangeHandler = useCallback((event) => {
-  //   logger.debug('StickyEvents.CHANGE detected');
-
-  //   const newAnimateClasses = event.detail.isSticky ? 'animated fadeInUp faster' : 'animated fadeOut faster';
-  //   const newButtonClasses = event.detail.isSticky ? '' : 'disabled grw-pointer-events-none';
-
-  //   setAnimateClasses(newAnimateClasses);
-  //   setButtonClasses(newButtonClasses);
-  // }, []);
-
-  // // setup effect by sticky event
-  // useEffect(() => {
-  //   // sticky
-  //   // See: https://github.com/ryanwalters/sticky-events
-  //   const stickyEvents = new StickyEvents({ stickySelector: '#grw-fav-sticky-trigger' });
-  //   const { stickySelector } = stickyEvents;
-  //   const elem = document.querySelector(stickySelector);
-  //   elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-
-  //   // return clean up handler
-  //   return () => {
-  //     elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-  //   };
-  // }, [stickyChangeHandler]);
+  /**
+   * After the fade animation is finished, fix the button display status.
+   * Prevents the fade animation occurred each time by button components rendered.
+   * Check Fab.module.scss for fade animation time.
+   */
+  useEffect(() => {
+    const timer = setTimeout(() => {
+      if (isSticky) {
+        setAnimateClasses('visible');
+        setButtonClasses('');
+      }
+      else {
+        setAnimateClasses('invisible');
+      }
+    }, 500);
+    return () => clearTimeout(timer);
+  }, [isSticky]);
+
+  const stickyChangeHandler = useCallback((event) => {
+    logger.debug('StickyEvents.CHANGE detected');
+
+    const newAnimateClasses = event.detail.isSticky ? 'animated fadeInUp faster' : 'animated fadeOut faster';
+    const newButtonClasses = event.detail.isSticky ? '' : 'disabled grw-pointer-events-none';
+
+    setAnimateClasses(newAnimateClasses);
+    setButtonClasses(newButtonClasses);
+    setIsSticky(event.detail.isSticky);
+  }, []);
+
+  // setup effect by sticky event
+  useEffect(() => {
+    // sticky
+    // See: https://github.com/ryanwalters/sticky-events
+    const stickyEvents = new StickyEvents({ stickySelector: '#grw-fav-sticky-trigger' });
+    const { stickySelector } = stickyEvents;
+    const elem = document.querySelector(stickySelector);
+    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+
+    // return clean up handler
+    return () => {
+      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+    };
+  }, [stickyChangeHandler]);
 
   const PageCreateButton = useCallback(() => {
     return (
@@ -102,7 +118,7 @@ export const Fab = (): JSX.Element => {
 
   return (
     <div className={`${styles['grw-fab']} grw-fab d-none d-md-block d-edit-none`} data-testid="grw-fab-container">
-      {currentUser != null && <PageCreateButton />}
+      {isAbleToChangeEditorMode && <PageCreateButton />}
       <ScrollToTopButton />
     </div>
   );

+ 2 - 0
packages/app/src/components/Layout/AdminLayout.tsx

@@ -10,6 +10,7 @@ import styles from './Admin.module.scss';
 
 
 const AdminNavigation = dynamic(() => import('~/components/Admin/Common/AdminNavigation'), { ssr: false });
+const PageCreateModal = dynamic(() => import('../PageCreateModal'), { ssr: false });
 const SystemVersion = dynamic(() => import('../SystemVersion'), { ssr: false });
 const HotkeysManager = dynamic(() => import('../Hotkeys/HotkeysManager'), { ssr: false });
 
@@ -45,6 +46,7 @@ const AdminLayout = ({
           </div>
         </div>
 
+        <PageCreateModal />
         <SystemVersion />
       </div>
 

+ 1 - 1
packages/app/src/components/Layout/BasicLayout.tsx

@@ -43,7 +43,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
             <Sidebar />
           </div>
 
-          <div className="flex-fill mw-0" style={{ position: 'relative' }}>
+          <div className="flex-fill mw-0">
             <AlertSiteUrlUndefined />
             {children}
           </div>

+ 2 - 1
packages/app/src/components/Layout/RawLayout.tsx

@@ -1,10 +1,11 @@
 import React, { ReactNode, useState } from 'react';
 
+import { ColorScheme } from '@growi/core';
 import Head from 'next/head';
 import { ToastContainer } from 'react-toastify';
 import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 
-import { ColorScheme, useNextThemes, NextThemesProvider } from '~/stores/use-next-themes';
+import { useNextThemes, NextThemesProvider } from '~/stores/use-next-themes';
 import loggerFactory from '~/utils/logger';
 
 

+ 1 - 1
packages/app/src/components/Layout/ShareLinkLayout.tsx

@@ -28,7 +28,7 @@ export const ShareLinkLayout = ({ children }: Props): JSX.Element => {
       <GrowiNavbar isGlobalSearchHidden={true} />
 
       <div className="page-wrapper d-flex d-print-block">
-        <div className="flex-fill mw-0" style={{ position: 'relative' }}>
+        <div className="flex-fill mw-0">
           {children}
         </div>
       </div>

+ 3 - 3
packages/app/src/components/Navbar/AppearanceModeDropdown.tsx

@@ -47,15 +47,15 @@ export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: Ap
 
   const followOsCheckboxModifiedHandler = useCallback((isChecked: boolean) => {
     if (isChecked) {
-      setTheme(Themes.system);
+      setTheme(Themes.SYSTEM);
     }
     else {
-      setTheme(resolvedTheme ?? Themes.light);
+      setTheme(resolvedTheme ?? Themes.LIGHT);
     }
   }, [resolvedTheme, setTheme]);
 
   const userPreferenceSwitchModifiedHandler = useCallback((isDarkMode: boolean) => {
-    setTheme(isDarkMode ? Themes.dark : Themes.light);
+    setTheme(isDarkMode ? Themes.DARK : Themes.LIGHT);
   }, [setTheme]);
 
   /* eslint-disable react/prop-types */

+ 33 - 31
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -27,7 +27,7 @@ import {
 import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import {
   EditorMode, useDrawerMode, useEditorMode, useIsAbleToShowPageManagement, useIsAbleToShowTagLabel,
-  useIsAbleToShowPageEditorModeManager, useIsAbleToShowPageAuthors,
+  useIsAbleToChangeEditorMode, useIsAbleToShowPageAuthors,
 } from '~/stores/ui';
 
 import CreateTemplateModal from '../CreateTemplateModal';
@@ -35,6 +35,7 @@ import AttachmentIcon from '../Icons/AttachmentIcon';
 import HistoryIcon from '../Icons/HistoryIcon';
 import PresentationIcon from '../Icons/PresentationIcon';
 import ShareLinkIcon from '../Icons/ShareLinkIcon';
+import { NotAvailable } from '../NotAvailable';
 import { NotAvailableForNow } from '../NotAvailableForNow';
 import { Skeleton } from '../Skeleton';
 
@@ -140,17 +141,20 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         {t('attachment_data')}
       </DropdownItem>
 
-      <DropdownItem
-        onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
-        disabled={isGuestUser || isSharedUser || isLinkSharingDisabled}
-        data-testid="open-page-accessories-modal-btn-with-share-link-management-data-tab"
-        className="grw-page-control-dropdown-item"
-      >
-        <span className="grw-page-control-dropdown-icon">
-          <ShareLinkIcon />
-        </span>
-        {t('share_links.share_link_management')}
-      </DropdownItem>
+      { !isGuestUser && !isSharedUser && (
+        <NotAvailable isDisabled={isLinkSharingDisabled ?? false} title="Disabled by admin">
+          <DropdownItem
+            onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
+            data-testid="open-page-accessories-modal-btn-with-share-link-management-data-tab"
+            className="grw-page-control-dropdown-item"
+          >
+            <span className="grw-page-control-dropdown-icon">
+              <ShareLinkIcon />
+            </span>
+            {t('share_links.share_link_management')}
+          </DropdownItem>
+        </NotAvailable>
+      ) }
     </>
   );
 };
@@ -184,7 +188,7 @@ const CreateTemplateMenuItems = (props: CreateTemplateMenuItemsProps): JSX.Eleme
 };
 
 type GrowiContextualSubNavigationProps = {
-  currentPage?: IPagePopulatedToShowRevision,
+  currentPage?: IPagePopulatedToShowRevision | null,
   isCompactMode?: boolean,
   isLinkSharingDisabled: boolean,
 };
@@ -216,10 +220,10 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToShowTagLabel } = useIsAbleToShowTagLabel();
-  const { data: isAbleToShowPageEditorModeManager } = useIsAbleToShowPageEditorModeManager();
+  const { data: isAbleToChangeEditorMode } = useIsAbleToChangeEditorMode();
   const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
 
-  const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRxTagsInfo(!isSharedPage ? currentPage?._id : undefined);
+  const { mutate: mutateSWRTagsInfo, data: tagsInfoData } = useSWRxTagsInfo(currentPage?._id);
 
   // eslint-disable-next-line max-len
   const { data: tagsForEditors, mutate: mutatePageTagsForEditors, sync: syncPageTagsForEditors } = usePageTagsForEditors(!isSharedPage ? currentPage?._id : undefined);
@@ -380,7 +384,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
                 ) }
               </div>
             ) }
-            {isAbleToShowPageEditorModeManager && (
+            {isAbleToChangeEditorMode && (
               <PageEditorModeManager
                 onPageEditorModeButtonClicked={viewType => mutateEditorMode(viewType)}
                 isBtnDisabled={isGuestUser}
@@ -423,21 +427,19 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     : currentPage?.path;
 
   return (
-    <div data-testid="grw-contextual-sub-nav">
-      <GrowiSubNavigation
-        pagePath={pagePath}
-        pageId={currentPage?._id}
-        showDrawerToggler={isDrawerMode}
-        showTagLabel={isAbleToShowTagLabel}
-        isGuestUser={isGuestUser}
-        isDrawerMode={isDrawerMode}
-        isCompactMode={isCompactMode}
-        tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}
-        tagsUpdatedHandler={isViewMode ? tagsUpdatedHandlerForViewMode : tagsUpdatedHandlerForEditMode}
-        rightComponent={RightComponent}
-        additionalClasses={['container-fluid']}
-      />
-    </div>
+    <GrowiSubNavigation
+      pagePath={pagePath}
+      pageId={currentPage?._id}
+      showDrawerToggler={isDrawerMode}
+      showTagLabel={isAbleToShowTagLabel}
+      isGuestUser={isGuestUser}
+      isDrawerMode={isDrawerMode}
+      isCompactMode={isCompactMode}
+      tags={isViewMode ? tagsInfoData?.tags : tagsForEditors}
+      tagsUpdatedHandler={isViewMode ? tagsUpdatedHandlerForViewMode : tagsUpdatedHandlerForEditMode}
+      rightComponent={RightComponent}
+      additionalClasses={['container-fluid']}
+    />
   );
 };
 

+ 0 - 9
packages/app/src/components/Navbar/GrowiSubNavigation.module.scss

@@ -172,12 +172,3 @@
     }
   }
 }
-
-/*
- * shadow
- */
-.grw-subnav-append-shadow-container {
-  .grw-subnav {
-    box-shadow: 0px 0px 6px 3px rgba(black, 0.15);
-  }
-}

+ 0 - 141
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx

@@ -1,141 +0,0 @@
-import React, {
-  useMemo, useState, useRef, useEffect, useCallback,
-} from 'react';
-
-import PropTypes from 'prop-types';
-import StickyEvents from 'sticky-events';
-import { debounce } from 'throttle-debounce';
-
-import { useSidebarCollapsed } from '~/stores/ui';
-import loggerFactory from '~/utils/logger';
-
-import { useSWRxCurrentPage } from '~/stores/page';
-
-import GrowiContextualSubNavigation from './GrowiContextualSubNavigation';
-
-import styles from './GrowiSubNavigationSwitcher.module.scss';
-
-const logger = loggerFactory('growi:cli:GrowiSubNavigationSticky');
-
-
-/**
- * Subnavigation
- *
- * needs:
- *   #grw-subnav-fixed-container element
- *   #grw-subnav-sticky-trigger element
- *
- * @param {object} props
- */
-const GrowiSubNavigationSwitcher = (props) => {
-
-  const { data: currentPage } = useSWRxCurrentPage();
-  const { data: isSidebarCollapsed } = useSidebarCollapsed();
-
-  const [isVisible, setVisible] = useState(false);
-  const [width, setWidth] = useState(null);
-
-  const fixedContainerRef = useRef();
-  /*
-  * Comment out to prevent err >>> TypeError: Cannot read properties of null (reading 'bottom')
-  * The above err occurs when moving to admin page after rendering normal pages.
-  * This is because id "grw-subnav-sticky-trigger" does not exist on admin pages.
-  */
-  // const stickyEvents = useMemo(() => new StickyEvents({ stickySelector: '#grw-subnav-sticky-trigger' }), []);
-
-  const initWidth = useCallback(() => {
-    const instance = fixedContainerRef.current;
-
-    if (instance == null || instance.parentNode == null) {
-      return;
-    }
-
-    // get parent width
-    const { clientWidth } = instance.parentNode;
-    // update style
-    setWidth(clientWidth);
-  }, []);
-
-  // const initVisible = useCallback(() => {
-  //   const elements = stickyEvents.stickyElements;
-
-  //   for (const elem of elements) {
-  //     const bool = stickyEvents.isSticking(elem);
-  //     if (bool) {
-  //       setVisible(bool);
-  //       break;
-  //     }
-  //   }
-
-  // }, [stickyEvents]);
-
-  // setup effect by resizing event
-  useEffect(() => {
-    const resizeHandler = debounce(100, initWidth);
-
-    window.addEventListener('resize', resizeHandler);
-
-    // return clean up handler
-    return () => {
-      window.removeEventListener('resize', resizeHandler);
-    };
-  }, [initWidth]);
-
-  const stickyChangeHandler = useCallback((event) => {
-    logger.debug('StickyEvents.CHANGE detected');
-    setVisible(event.detail.isSticky);
-  }, []);
-
-  // // setup effect by sticky event
-  // useEffect(() => {
-  //   // sticky
-  //   // See: https://github.com/ryanwalters/sticky-events
-  //   const { stickySelector } = stickyEvents;
-  //   const elem = document.querySelector(stickySelector);
-  //   elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-
-  //   // return clean up handler
-  //   return () => {
-  //     elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-  //   };
-  // }, [stickyChangeHandler, stickyEvents]);
-
-  // update width when sidebar collapsing changed
-  useEffect(() => {
-    if (isSidebarCollapsed != null) {
-      setTimeout(initWidth, 300);
-    }
-  }, [isSidebarCollapsed, initWidth]);
-
-  // // initialize
-  // useEffect(() => {
-  //   initWidth();
-
-  //   // check sticky state several times
-  //   setTimeout(initVisible, 100);
-  //   setTimeout(initVisible, 300);
-  //   setTimeout(initVisible, 2000);
-
-  // }, [initWidth, initVisible]);
-
-  // ${styles['grw-subnav-switcher']}
-
-  return (
-    <div className={`${styles['grw-subnav-switcher']} ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
-      <div
-        id="grw-subnav-fixed-container"
-        className={`grw-subnav-fixed-container ${styles['grw-subnav-fixed-container']} position-fixed grw-subnav-append-shadow-container`}
-        ref={fixedContainerRef}
-        style={{ width }}
-      >
-        <GrowiContextualSubNavigation currentPage isCompactMode isLinkSharingDisabled />
-      </div>
-    </div>
-  );
-};
-
-GrowiSubNavigationSwitcher.propTypes = {
-  isLinkSharingDisabled: PropTypes.bool,
-};
-
-export default GrowiSubNavigationSwitcher;

+ 9 - 0
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.module.scss

@@ -19,6 +19,15 @@ $easeInOutCubic: cubic-bezier(0.65, 0, 0.35, 1);
     .grw-subnav-fixed-container {
       transition: transform 150ms $easeInOutCubic;
     }
+
+    /*
+    * shadow
+    */
+    .grw-subnav-append-shadow-container {
+      .grw-subnav {
+        box-shadow: 0px 0px 6px 3px rgba(black, 0.15);
+      }
+    }
   }
 
   &:global {

+ 108 - 0
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.tsx

@@ -0,0 +1,108 @@
+import React, {
+  useState, useRef, useEffect, useCallback,
+} from 'react';
+
+import StickyEvents from 'sticky-events';
+import { debounce } from 'throttle-debounce';
+
+import { useSWRxCurrentPage } from '~/stores/page';
+import { useSidebarCollapsed } from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
+
+import GrowiContextualSubNavigation from './GrowiContextualSubNavigation';
+
+import styles from './GrowiSubNavigationSwitcher.module.scss';
+
+const logger = loggerFactory('growi:cli:GrowiSubNavigationSticky');
+
+export type GrowiSubNavigationSwitcherProps = {
+  isLinkSharingDisabled: boolean,
+}
+
+/**
+ * GrowiSubNavigation
+ *
+ * needs:
+ *   #grw-subnav-fixed-container element
+ *   #grw-subnav-sticky-trigger element
+ */
+export const GrowiSubNavigationSwitcher = (props: GrowiSubNavigationSwitcherProps): JSX.Element => {
+  const { isLinkSharingDisabled } = props;
+
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { data: isSidebarCollapsed } = useSidebarCollapsed();
+
+  const [isVisible, setIsVisible] = useState<boolean>(false);
+  const [width, setWidth] = useState<number>(0);
+
+  // use more specific type HTMLDivElement for avoid assertion error.
+  // see: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDivElement
+  const fixedContainerRef = useRef<HTMLDivElement>(null);
+
+  const initWidth = useCallback(() => {
+    if (fixedContainerRef.current && fixedContainerRef.current.parentElement) {
+      // get parent elements width
+      const { clientWidth } = fixedContainerRef.current.parentElement;
+      setWidth(clientWidth);
+    }
+  }, []);
+
+  const stickyChangeHandler = useCallback((event) => {
+    logger.debug('StickyEvents.CHANGE detected');
+    setIsVisible(event.detail.isSticky);
+  }, []);
+
+  // setup effect by sticky-events
+  useEffect(() => {
+    // sticky-events
+    // See: https://github.com/ryanwalters/sticky-events
+    const { stickySelector } = new StickyEvents({ stickySelector: '#grw-subnav-sticky-trigger' });
+    const elem = document.querySelector(stickySelector);
+    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+
+    // return clean up handler
+    return () => {
+      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+    };
+  }, [stickyChangeHandler]);
+
+  // setup effect by resizing event
+  useEffect(() => {
+    const resizeHandler = debounce(100, initWidth);
+    window.addEventListener('resize', resizeHandler);
+
+    // return clean up handler
+    return () => {
+      window.removeEventListener('resize', resizeHandler);
+    };
+  }, [initWidth]);
+
+  // update width when sidebar collapsing changed
+  useEffect(() => {
+    if (isSidebarCollapsed != null) {
+      setTimeout(initWidth, 300);
+    }
+  }, [isSidebarCollapsed, initWidth]);
+
+  // initialize width
+  useEffect(() => {
+    initWidth();
+  }, [initWidth]);
+
+  if (currentPage == null) {
+    return <></>;
+  }
+
+  return (
+    <div className={`${styles['grw-subnav-switcher']} ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
+      <div
+        id="grw-subnav-fixed-container"
+        className={`grw-subnav-fixed-container ${styles['grw-subnav-fixed-container']} position-fixed grw-subnav-append-shadow-container`}
+        ref={fixedContainerRef}
+        style={{ width }}
+      >
+        <GrowiContextualSubNavigation currentPage={currentPage} isCompactMode isLinkSharingDisabled={isLinkSharingDisabled} />
+      </div>
+    </div>
+  );
+};

+ 1 - 1
packages/app/src/components/Page.tsx

@@ -70,7 +70,7 @@ export const Page: FC<Props> = (props: Props) => {
   const { data: shareLinkId } = useShareLinkId();
   const { mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
-  const { data: tagsInfo } = useSWRxTagsInfo(!isSharedPage ? currentPage?._id : undefined);
+  const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();
   const { data: rendererOptions, mutate: mutateRendererOptions } = useViewOptions(storeTocNodeHandler);

+ 8 - 2
packages/app/src/components/PageComment/Comment.tsx

@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
 
 import { IUser, pathUtils } from '@growi/core';
 import { UserPicture } from '@growi/ui';
-import { format } from 'date-fns';
+import { format, parseISO } from 'date-fns';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 import { UncontrolledTooltip } from 'reactstrap';
@@ -79,12 +79,18 @@ export const Comment = (props: CommentProps): JSX.Element => {
   const getRootClassName = (comment: ICommentHasId) => {
     let className = 'page-comment flex-column';
 
+    // TODO: fix so that `comment.createdAt` to be type Date https://redmine.weseek.co.jp/issues/113876
+    let commentCreatedAt = comment.createdAt;
+    if (typeof commentCreatedAt === 'string') {
+      commentCreatedAt = parseISO(commentCreatedAt);
+    }
+
     // Conditional for called from SearchResultContext
     if (revisionId != null && revisionCreatedAt != null) {
       if (comment.revision === revisionId) {
         className += ' page-comment-current';
       }
-      else if (comment.createdAt.getTime() > revisionCreatedAt.getTime()) {
+      else if (commentCreatedAt.getTime() > revisionCreatedAt.getTime()) {
         className += ' page-comment-newer';
       }
       else {

+ 5 - 2
packages/app/src/components/PageRenameModal.tsx

@@ -20,7 +20,6 @@ import DuplicatedPathsTable from './DuplicatedPathsTable';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import PagePathAutoComplete from './PagePathAutoComplete';
 
-
 const isV5Compatible = (meta: unknown): boolean => {
   return isIPageInfoForEntity(meta) ? meta.isV5Compatible : true;
 };
@@ -139,6 +138,10 @@ const PageRenameModal = (): JSX.Element => {
       setExistingPaths(existPaths);
     }
     catch (err) {
+      // Do not toast in case of this error because debounce process may be executed after the renaming process is completed.
+      if (err.length === 1 && err[0].code === 'from-page-is-not-exist') {
+        return;
+      }
       setErrs(err);
       toastError(t('modal_rename.label.Failed to get exist path'));
     }
@@ -304,7 +307,7 @@ const PageRenameModal = (): JSX.Element => {
             </label>
           </div>
 
-          <div className="custom-control custom-checkbox custom-checkbox-primary">
+          <div className="custom-control custom-checkbox custom-checkbox-success">
             <input
               className="custom-control-input"
               name="remain_metadata"

+ 6 - 2
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -66,6 +66,10 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
     invokeSearch();
   }, [invokeSearch]);
 
+  useEffect(() => {
+    setKeyword(initialSearchConditions.keyword ?? '');
+  }, [initialSearchConditions.keyword]);
+
   return (
     <div className="position-sticky sticky-top shadow-sm">
       <div className="grw-search-page-nav d-flex py-3 align-items-center">
@@ -118,7 +122,7 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
             </div>
             <div className="d-none d-lg-flex align-items-center ml-auto search-control-include-options">
               <div className="border rounded px-2 py-1 mr-3">
-                <div className="custom-control custom-checkbox custom-checkbox-primary">
+                <div className="custom-control custom-checkbox custom-checkbox-succsess">
                   <input
                     className="custom-control-input mr-2"
                     type="checkbox"
@@ -132,7 +136,7 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
                 </div>
               </div>
               <div className="border rounded px-2 py-1">
-                <div className="custom-control custom-checkbox custom-checkbox-primary">
+                <div className="custom-control custom-checkbox custom-checkbox-succsess">
                   <input
                     className="custom-control-input mr-2"
                     type="checkbox"

+ 8 - 0
packages/app/src/components/SearchPage/SearchResultContent.module.scss

@@ -0,0 +1,8 @@
+/*
+* shadow
+*/
+.grw-subnav-append-shadow-container :global {
+  .grw-subnav {
+    box-shadow: 0px 0px 6px 3px rgba(black, 0.15);
+  }
+}

+ 3 - 1
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -29,6 +29,8 @@ import { ROOT_ELEM_ID as RevisionLoaderRoomElemId, RevisionLoaderProps } from '.
 import { ROOT_ELEM_ID as PageCommentRootElemId, PageCommentProps } from '../PageComment';
 import { PageContentFooterProps } from '../PageContentFooter';
 
+import styles from './SearchResultContent.module.scss';
+
 
 const GrowiSubNavigation = dynamic<GrowiSubNavigationProps>(() => import('../Navbar/GrowiSubNavigation').then(mod => mod.GrowiSubNavigation), { ssr: false });
 const SubNavButtons = dynamic<SubNavButtonsProps>(() => import('../Navbar/SubNavButtons').then(mod => mod.SubNavButtons), { ssr: false });
@@ -242,7 +244,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
 
   return (
     <div key={page._id} data-testid="search-result-content" className="search-result-content grw-page-path-text-muted-container d-flex flex-column">
-      <div className="grw-subnav-append-shadow-container">
+      <div className={`${styles['grw-subnav-append-shadow-container']} grw-subnav-append-shadow-container`}>
         <GrowiSubNavigation
           pagePath={page.path}
           pageId={page._id}

+ 11 - 0
packages/app/src/components/SearchTypeahead.tsx

@@ -45,6 +45,7 @@ type Props = TypeaheadProps & {
 
 // see https://github.com/ericgio/react-bootstrap-typeahead/issues/266#issuecomment-414987723
 type TypeaheadInstance = {
+  setState(input: { text: string | undefined; }): void;
   clear: () => void,
   focus: () => void,
   toggleMenu: () => void,
@@ -164,6 +165,16 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
     }
   }, [onSearchError, searchError]);
 
+  useEffect(() => {
+    // update input with Next Link
+    // update input workaround. see: https://github.com/ericgio/react-bootstrap-typeahead/issues/266#issuecomment-414987723
+    if (typeaheadRef.current != null) {
+      typeaheadRef.current.setState({
+        text: keywordOnInit,
+      });
+    }
+  }, [keywordOnInit]);
+
   const labelKey = useCallback((option?: IPageWithSearchMeta) => {
     return option?.data.path ?? '';
   }, []);

+ 1 - 0
packages/app/src/components/ShareLink/ShareLink.tsx

@@ -64,6 +64,7 @@ const ShareLink = (): JSX.Element => {
           className="btn btn-outline-secondary d-block mx-auto px-5"
           type="button"
           onClick={toggleShareLinkFormHandler}
+          data-testid="btn-sharelink-toggleform"
         >
           {isOpenShareLinkForm ? t('Close') : t('New')}
         </button>

+ 1 - 1
packages/app/src/components/ShareLink/ShareLinkForm.tsx

@@ -199,7 +199,7 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
             />
           </div>
         </div>
-        <button type="button" className="btn btn-primary d-block mx-auto px-5" onClick={handleIssueShareLink}>
+        <button type="button" className="btn btn-primary d-block mx-auto px-5" onClick={handleIssueShareLink} data-testid="btn-sharelink-issue">
           {t('share_links.Issue')}
         </button>
       </div>

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

@@ -55,6 +55,9 @@ const markTarget = (children: ItemNode[], targetPathOrId?: Nullable<string>): vo
     if (node.page._id === targetPathOrId || node.page.path === targetPathOrId) {
       node.page.isTarget = true;
     }
+    else {
+      node.page.isTarget = false;
+    }
     return node;
   });
 };

+ 20 - 20
packages/app/src/components/StickyStretchableScroller.tsx

@@ -69,26 +69,26 @@ export const StickyStretchableScroller: FC<StickyStretchableScrollerProps> = (pr
 
   const resetScrollbarDebounced = useMemo(() => debounce(100, resetScrollbar), [resetScrollbar]);
 
-  // const stickyChangeHandler = useCallback(() => {
-  //   logger.debug('StickyEvents.CHANGE detected');
-  //   resetScrollbarDebounced();
-  // }, [resetScrollbarDebounced]);
-
-  // // setup effect by sticky event
-  // useEffect(() => {
-  //   // sticky
-  //   // See: https://github.com/ryanwalters/sticky-events
-  //   const stickyEvents = new StickyEvents({ stickySelector: stickyElemSelector });
-  //   stickyEvents.enableEvents();
-  //   const { stickySelector } = stickyEvents;
-  //   const elem = document.querySelector(stickySelector);
-  //   elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-
-  //   // return clean up handler
-  //   return () => {
-  //     elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-  //   };
-  // }, [stickyElemSelector, stickyChangeHandler]);
+  const stickyChangeHandler = useCallback(() => {
+    logger.debug('StickyEvents.CHANGE detected');
+    resetScrollbarDebounced();
+  }, [resetScrollbarDebounced]);
+
+  // setup effect by sticky event
+  useEffect(() => {
+    // sticky
+    // See: https://github.com/ryanwalters/sticky-events
+    const stickyEvents = new StickyEvents({ stickySelector: stickyElemSelector });
+    stickyEvents.enableEvents();
+    const { stickySelector } = stickyEvents;
+    const elem = document.querySelector(stickySelector);
+    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+
+    // return clean up handler
+    return () => {
+      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+    };
+  }, [stickyElemSelector, stickyChangeHandler]);
 
   // setup effect by resizing event
   useEffect(() => {

+ 14 - 3
packages/app/src/components/TagCloudBox.tsx

@@ -1,7 +1,10 @@
 import React, { FC, memo } from 'react';
 
+import Link from 'next/link';
+
 import { IDataTagCount } from '~/interfaces/tag';
 
+
 type Props = {
   tags:IDataTagCount[],
   minSize?: number,
@@ -22,10 +25,18 @@ const TagCloudBox: FC<Props> = memo((props:(Props & typeof defaultProps)) => {
 
   const tagElements = tags.map((tag:IDataTagCount) => {
     const tagNameFormat = (tag.name).length > maxTagTextLength ? `${(tag.name).slice(0, maxTagTextLength)}...` : tag.name;
+
+    const url = new URL('/_search', 'https://example.com');
+    url.searchParams.append('q', `tag:${tag.name}`);
+
     return (
-      <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="grw-tag-label badge badge-secondary mr-2">
-        {tagNameFormat}
-      </a>
+      <Link
+        key={tag.name} href={`${url.pathname}${url.search}`}
+      >
+        <a className="grw-tag-label badge badge-secondary mr-2">
+          {tagNameFormat}
+        </a>
+      </Link>
     );
   });
 

+ 4 - 1
packages/app/src/components/TagList.tsx

@@ -33,10 +33,13 @@ const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) =
     return tagData.map((tag:IDataTagCount, index:number) => {
       const tagListClasses: string = index === 0 ? 'list-group-item d-flex' : 'list-group-item d-flex border-top-0';
 
+      const url = new URL('/_search', 'https://example.com');
+      url.searchParams.append('q', `tag:${tag.name}`);
+
       return (
         <Link
           key={tag._id}
-          href={`/_search?q=tag:${encodeURIComponent(tag.name)}`}
+          href={`${url.pathname}${url.search}`}
         >
           <a
             className={tagListClasses}

+ 1 - 1
packages/app/src/interfaces/customize.ts

@@ -1,4 +1,4 @@
-import { GrowiThemeMetadata } from '@growi/core';
+import type { GrowiThemeMetadata } from '@growi/core';
 
 export type IResLayoutSetting = {
   isContainerFluid: boolean,

+ 23 - 0
packages/app/src/interfaces/g2g-transfer.ts

@@ -0,0 +1,23 @@
+/**
+ * G2G transfer progress status master
+ */
+export const G2G_PROGRESS_STATUS = {
+  PENDING: 'PENDING',
+  IN_PROGRESS: 'IN_PROGRESS',
+  COMPLETED: 'COMPLETED',
+  ERROR: 'ERROR',
+  SKIPPED: 'SKIPPED',
+} as const;
+
+/**
+ * G2G transfer progress status
+ */
+export type G2GProgressStatus = typeof G2G_PROGRESS_STATUS[keyof typeof G2G_PROGRESS_STATUS];
+
+/**
+ * G2G transfer progress
+ */
+export interface G2GProgress {
+ mongo: G2GProgressStatus;
+ attachments: G2GProgressStatus;
+}

+ 6 - 0
packages/app/src/interfaces/transfer-key.ts

@@ -0,0 +1,6 @@
+export interface ITransferKey<ID = string> {
+  _id: ID
+  expireAt: Date
+  keyString: string,
+  key: string,
+}

+ 51 - 12
packages/app/src/pages/[[...path]].page.tsx

@@ -32,6 +32,7 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 // import { useRendererSettings } from '~/stores/renderer';
 // import { EditorMode, useEditorMode, useIsMobile } from '~/stores/ui';
 import type { EditorConfig } from '~/interfaces/editor-settings';
+import { IPageGrantData } from '~/interfaces/page';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
@@ -53,10 +54,10 @@ import loggerFactory from '~/utils/logger';
 // import { isUserPage, isTrashPage, isSharedPage } from '~/utils/path-utils';
 
 // import GrowiSubNavigation from '../client/js/components/Navbar/GrowiSubNavigation';
-// import GrowiSubNavigationSwitcher from '../client/js/components/Navbar/GrowiSubNavigationSwitcher';
 import { DescendantsPageListModal } from '../components/DescendantsPageListModal';
 import { BasicLayoutWithEditorMode } from '../components/Layout/BasicLayout';
-import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
+import GrowiContextualSubNavigationSubstance from '../components/Navbar/GrowiContextualSubNavigation';
+import type { GrowiSubNavigationSwitcherProps } from '../components/Navbar/GrowiSubNavigationSwitcher';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 // import { serializeUserSecurely } from '../server/models/serializers/user-serializer';
 // import PageStatusAlert from '../client/js/components/PageStatusAlert';
@@ -90,7 +91,8 @@ const NotCreatablePage = dynamic(() => import('../components/NotCreatablePage').
 const ForbiddenPage = dynamic(() => import('../components/ForbiddenPage'), { ssr: false });
 const UnsavedAlertDialog = dynamic(() => import('../components/UnsavedAlertDialog'), { ssr: false });
 const PageSideContents = dynamic<PageSideContentsProps>(() => import('../components/PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
-const GrowiSubNavigationSwitcher = dynamic(() => import('../components/Navbar/GrowiSubNavigationSwitcher'), { ssr: false });
+const GrowiSubNavigationSwitcher = dynamic<GrowiSubNavigationSwitcherProps>(() => import('../components/Navbar/GrowiSubNavigationSwitcher')
+  .then(mod => mod.GrowiSubNavigationSwitcher), { ssr: false });
 const UsersHomePageFooter = dynamic<UsersHomePageFooterProps>(() => import('../components/UsersHomePageFooter')
   .then(mod => mod.UsersHomePageFooter), { ssr: false });
 const DrawioModal = dynamic(() => import('../components/PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
@@ -131,6 +133,20 @@ superjson.registerCustom<IPageToShowRevisionWithMeta, IPageToShowRevisionWithMet
   'IPageToShowRevisionWithMetaTransformer',
 );
 
+// GrowiContextualSubNavigation for NOT shared page
+type GrowiContextualSubNavigationProps = {
+  isLinkSharingDisabled: boolean,
+}
+
+const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
+  const { isLinkSharingDisabled } = props;
+  const { data: currentPage } = useSWRxCurrentPage();
+  return (
+    <div data-testid="grw-contextual-sub-nav">
+      <GrowiContextualSubNavigationSubstance currentPage={currentPage} isLinkSharingDisabled={isLinkSharingDisabled}/>
+    </div>
+  );
+};
 
 const IdenticalPathPage = (): JSX.Element => {
   const IdenticalPathPage = dynamic(() => import('../components/IdenticalPathPage').then(mod => mod.IdenticalPathPage), { ssr: false });
@@ -184,6 +200,8 @@ type Props = CommonProps & {
   isIndentSizeForced: boolean,
   disableLinkSharing: boolean,
 
+  grantData?: IPageGrantData,
+
   rendererConfig: RendererConfig,
 
   // UI
@@ -283,8 +301,9 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
   // sync grant data
   useEffect(() => {
-    mutateSelectedGrant(grantData?.grantData.currentPageGrant);
-  }, [grantData?.grantData.currentPageGrant, mutateSelectedGrant]);
+    const grantDataToApply = props.grantData ? props.grantData : grantData?.grantData.currentPageGrant;
+    mutateSelectedGrant(grantDataToApply);
+  }, [grantData?.grantData.currentPageGrant, mutateSelectedGrant, props.grantData]);
 
   // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
   useEffect(() => {
@@ -328,11 +347,11 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
       <div className={`dynamic-layout-root ${growiLayoutFluidClass} h-100 d-flex flex-column justify-content-between`}>
         <header className="py-0 position-relative">
           <div id="grw-subnav-container">
-            <GrowiContextualSubNavigation currentPage={pageWithMeta?.data} isLinkSharingDisabled={props.disableLinkSharing} />
+            <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
           </div>
         </header>
         <div className="d-edit-none">
-          <GrowiSubNavigationSwitcher />
+          <GrowiSubNavigationSwitcher isLinkSharingDisabled={props.disableLinkSharing} />
         </div>
 
         <div id="grw-subnav-sticky-trigger" className="sticky-top"></div>
@@ -392,6 +411,21 @@ class MultiplePagesHitsError extends ExtensibleCustomError {
 
 }
 
+// apply parent page grant fot creating page
+async function applyGrantToPage(props: Props, ancestor: any) {
+  await ancestor.populate('grantedGroup');
+  const grant = {
+    grant: ancestor.grant,
+  };
+  const grantedGroup = ancestor.grantedGroup ? {
+    grantedGroup: {
+      id: ancestor.grantedGroup.id,
+      name: ancestor.grantedGroup.name,
+    },
+  } : {};
+  props.grantData = Object.assign(grant, grantedGroup);
+}
+
 async function injectPageData(context: GetServerSidePropsContext, props: Props): Promise<void> {
   const { model: mongooseModel } = await import('mongoose');
 
@@ -449,6 +483,12 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
       props.templateTagData = templateData.templateTags as string[];
       props.templateBodyData = templateData.templateBody as string;
     }
+
+    // apply pagrent page grant
+    const ancestor = await Page.findAncestorByPathAndViewer(currentPathname, user);
+    if (ancestor != null) {
+      await applyGrantToPage(props, ancestor);
+    }
   }
 
   props.pageWithMeta = pageWithMeta;
@@ -564,12 +604,11 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
 
     // XSS Options
-    attrWhiteList: crowi.xssService.getAttrWhiteList(),
-    tagWhiteList: crowi.xssService.getTagWhiteList(),
-    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
-
-    // XSS: rehype-sanitize options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
+    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
+    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
+    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 
   props.sidebarConfig = {

+ 5 - 2
packages/app/src/pages/_app.page.tsx

@@ -11,7 +11,7 @@ import * as nextI18nConfig from '^/config/next-i18next.config';
 import { ActivatePluginService } from '~/client/services/activate-plugin';
 import { useI18nextHMR } from '~/services/i18next-hmr';
 import {
-  useAppTitle, useConfidential, useGrowiVersion, useSiteUrl, useIsDefaultLogo,
+  useAppTitle, useConfidential, useGrowiVersion, useSiteUrl, useIsDefaultLogo, useForcedColorScheme,
 } from '~/stores/context';
 import { SWRConfigValue, swrGlobalConfiguration } from '~/utils/swr-utils';
 
@@ -20,7 +20,9 @@ import { CommonProps } from './utils/commons';
 import { registerTransformerForObjectId } from './utils/objectid-transformer';
 
 import '~/styles/style-app.scss';
-
+import '~/styles/theme/_apply-colors-light.scss';
+import '~/styles/theme/_apply-colors-dark.scss';
+import '~/styles/theme/_apply-colors.scss';
 
 const isDev = process.env.NODE_ENV === 'development';
 
@@ -64,6 +66,7 @@ function GrowiApp({ Component, pageProps }: GrowiAppProps): JSX.Element {
   useConfidential(commonPageProps.confidential);
   useGrowiVersion(commonPageProps.growiVersion);
   useIsDefaultLogo(commonPageProps.isDefaultLogo);
+  useForcedColorScheme(commonPageProps.forcedColorScheme);
 
   // Use the layout defined at the page level, if available
   const getLayout = Component.getLayout ?? (page => page);

+ 6 - 51
packages/app/src/pages/_document.page.tsx

@@ -1,8 +1,6 @@
 /* eslint-disable @next/next/google-font-display */
 import React from 'react';
 
-import type { ViteManifest } from '@growi/core';
-import { DefaultThemeMetadata, PresetThemesMetadatas } from '@growi/preset-themes';
 import Document, {
   DocumentContext, DocumentInitialProps,
   Html, Head, Main, NextScript,
@@ -14,40 +12,6 @@ import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:page:_document');
 
-type HeadersForThemesProps = {
-  theme: string,
-  presetThemesManifest: ViteManifest,
-  pluginThemeHref: string | undefined,
-}
-const HeadersForThemes = (props: HeadersForThemesProps): JSX.Element => {
-  const {
-    theme, presetThemesManifest, pluginThemeHref,
-  } = props;
-
-  const elements: JSX.Element[] = [];
-
-  // when plugin theme is specified
-  if (pluginThemeHref != null) {
-    elements.push(
-      <link rel="stylesheet" key={`link_custom-themes-${theme}`} href={pluginThemeHref} />,
-    );
-  }
-  // preset theme
-  else {
-    const themeMetadata = PresetThemesMetadatas.find(p => p.name === theme);
-    const manifestKey = themeMetadata?.manifestKey ?? DefaultThemeMetadata.manifestKey;
-    if (themeMetadata == null || !(themeMetadata.manifestKey in presetThemesManifest)) {
-      logger.warn(`Use default theme because the key for '${theme} does not exist in preset-themes manifest`);
-    }
-    const href = `/static/preset-themes/${presetThemesManifest[manifestKey].file}`; // configured by express.static
-    elements.push(
-      <link rel="stylesheet" key={`link_preset-themes-${theme}`} href={href} />,
-    );
-  }
-
-  return <>{elements}</>;
-};
-
 type HeadersForGrowiPluginProps = {
   pluginResourceEntries: GrowiPluginResourceEntries;
 }
@@ -72,12 +36,10 @@ const HeadersForGrowiPlugin = (props: HeadersForGrowiPluginProps): JSX.Element =
 };
 
 interface GrowiDocumentProps {
-  theme: string,
+  themeHref: string,
   customScript: string | null,
   customCss: string | null,
   customNoscript: string | null,
-  presetThemesManifest: ViteManifest,
-  pluginThemeHref: string | undefined,
   pluginResourceEntries: GrowiPluginResourceEntries;
 }
 declare type GrowiDocumentInitialProps = DocumentInitialProps & GrowiDocumentProps;
@@ -87,28 +49,22 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
   static override async getInitialProps(ctx: DocumentContext): Promise<GrowiDocumentInitialProps> {
     const initialProps: DocumentInitialProps = await Document.getInitialProps(ctx);
     const { crowi } = ctx.req as CrowiRequest<any>;
-    const { configManager, customizeService, pluginService } = crowi;
+    const { customizeService, pluginService } = crowi;
 
-    const theme = configManager.getConfig('crowi', 'customize:theme');
+    const { themeHref } = customizeService;
     const customScript: string | null = customizeService.getCustomScript();
     const customCss: string | null = customizeService.getCustomCss();
     const customNoscript: string | null = customizeService.getCustomNoscript();
 
-    // import preset-themes manifest
-    const presetThemesManifest = await import('@growi/preset-themes/dist/themes/manifest.json').then(imported => imported.default);
-
     // retrieve plugin manifests
     const pluginResourceEntries = await (pluginService as IPluginService).retrieveAllPluginResourceEntries();
-    const pluginThemeHref = await (pluginService as IPluginService).retrieveThemeHref(theme);
 
     return {
       ...initialProps,
-      theme,
+      themeHref,
       customScript,
       customCss,
       customNoscript,
-      presetThemesManifest,
-      pluginThemeHref,
       pluginResourceEntries,
     };
   }
@@ -137,7 +93,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
   override render(): JSX.Element {
     const {
       customCss, customScript, customNoscript,
-      theme, presetThemesManifest, pluginThemeHref, pluginResourceEntries,
+      themeHref, pluginResourceEntries,
     } = this.props;
 
     return (
@@ -150,8 +106,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
           <link rel='preload' href="/static/fonts/Lato-Regular-latin-ext.woff2" as="font" type="font/woff2" />
           <link rel='preload' href="/static/fonts/Lato-Bold-latin.woff2" as="font" type="font/woff2" />
           <link rel='preload' href="/static/fonts/Lato-Bold-latin-ext.woff2" as="font" type="font/woff2" />
-          <HeadersForThemes theme={theme}
-            presetThemesManifest={presetThemesManifest} pluginThemeHref={pluginThemeHref} />
+          <link rel="stylesheet" key="link-theme" href={themeHref} />
           <HeadersForGrowiPlugin pluginResourceEntries={pluginResourceEntries} />
           {this.renderCustomCss(customCss)}
         </Head>

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

@@ -136,8 +136,9 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    attrWhiteList: crowi.xssService.getAttrWhiteList(),
-    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
+    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 }

+ 3 - 2
packages/app/src/pages/_search.page.tsx

@@ -158,8 +158,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    attrWhiteList: crowi.xssService.getAttrWhiteList(),
-    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
+    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 

+ 54 - 0
packages/app/src/pages/admin/data-transfer.page.tsx

@@ -0,0 +1,54 @@
+import { isClient } from '@growi/core';
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import Head from 'next/head';
+import { Container, Provider } from 'unstated';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
+import { CommonProps } from '~/pages/utils/commons';
+import { useCurrentUser } from '~/stores/context';
+
+import { retrieveServerSideProps } from '../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+const G2GDataTransferPage = dynamic(() => import('~/components/Admin/G2GDataTransfer'), { ssr: false });
+
+
+type Props = CommonProps;
+
+
+const DataTransferPage: NextPage<Props> = (props) => {
+  const { t } = useTranslation('commons');
+  useCurrentUser(props.currentUser ?? null);
+
+  const title = t('g2g_data_transfer.data_transfer');
+
+  const injectableContainers: Container<any>[] = [];
+  if (isClient()) {
+    const adminAppContainer = new AdminAppContainer();
+    injectableContainers.push(adminAppContainer);
+  }
+
+  return (
+    <Provider inject={[...injectableContainers]}>
+      <AdminLayout componentTitle={title}>
+        <Head>
+          <title>{title}</title>
+        </Head>
+        <G2GDataTransferPage />
+      </AdminLayout>
+    </Provider>
+  );
+};
+
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context);
+  return props;
+};
+
+
+export default DataTransferPage;

+ 25 - 4
packages/app/src/pages/installer.page.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useMemo } from 'react';
 
 import { pagePathUtils } from '@growi/core';
 import {
@@ -6,6 +6,7 @@ import {
 } from 'next';
 import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+import dynamic from 'next/dynamic';
 import Head from 'next/head';
 
 import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
@@ -15,12 +16,14 @@ import {
   useCsrfToken, useAppTitle, useSiteUrl, useConfidential,
 } from '../stores/context';
 
-
 import {
   CommonProps, getNextI18NextConfig, getServerSideCommonProps, generateCustomTitle,
 } from './utils/commons';
 
 
+const DataTransferForm = dynamic(() => import('../components/DataTransferForm'), { ssr: false });
+const CustomNavAndContents = dynamic(() => import('../components/CustomNavigation/CustomNavAndContents'), { ssr: false });
+
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 
 async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
@@ -35,6 +38,24 @@ type Props = CommonProps & {
 
 const InstallerPage: NextPage<Props> = (props: Props) => {
   const { t } = useTranslation();
+  const { t: tCommons } = useTranslation('commons');
+
+  const navTabMapping = useMemo(() => {
+    return {
+      user_infomation: {
+        Icon: () => <i className="icon-fw icon-user"></i>,
+        Content: InstallerForm,
+        i18n: t('installer.tab'),
+        index: 0,
+      },
+      external_accounts: {
+        Icon: () => <i className="icon-fw icon-share-alt"></i>,
+        Content: DataTransferForm,
+        i18n: tCommons('g2g_data_transfer.tab'),
+        index: 1,
+      },
+    };
+  }, [t, tCommons]);
 
   // commons
   useAppTitle(props.appTitle);
@@ -50,8 +71,8 @@ const InstallerPage: NextPage<Props> = (props: Props) => {
       <Head>
         <title>{title}</title>
       </Head>
-      <div id="installer-form-container">
-        <InstallerForm />
+      <div id="installer-form-container" className="nologin-dialog mx-auto">
+        <CustomNavAndContents navTabMapping={navTabMapping} tabContentClasses={['p-0']} />
       </div>
     </NoLoginLayout>
   );

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

@@ -186,8 +186,9 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    attrWhiteList: crowi.xssService.getAttrWhiteList(),
-    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
+    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 }

+ 26 - 7
packages/app/src/pages/share/[[...path]].page.tsx

@@ -12,7 +12,7 @@ import superjson from 'superjson';
 import { useCurrentGrowiLayoutFluidClassName } from '~/client/services/layout';
 import { MainPane } from '~/components/Layout/MainPane';
 import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
-import GrowiContextualSubNavigation from '~/components/Navbar/GrowiContextualSubNavigation';
+import GrowiContextualSubNavigationSubstance from '~/components/Navbar/GrowiContextualSubNavigation';
 import { Page } from '~/components/Page';
 import type { PageSideContentsProps } from '~/components/PageSideContents';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
@@ -22,7 +22,7 @@ import { RendererConfig } from '~/interfaces/services/renderer';
 import { IShareLinkHasId } from '~/interfaces/share-link';
 import type { PageDocument } from '~/server/models/page';
 import {
-  useCurrentUser, useCurrentPathname, useCurrentPageId, useRendererConfig, useIsSearchPage,
+  useCurrentUser, useCurrentPageId, useRendererConfig, useIsSearchPage, useCurrentPathname,
   useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useDrawioUri, useIsContainerFluid,
 } from '~/stores/context';
 import loggerFactory from '~/utils/logger';
@@ -68,12 +68,29 @@ superjson.registerCustom<IShareLinkRelatedPage, string>(
   'IShareLinkRelatedPageTransformer',
 );
 
+// GrowiContextualSubNavigation for shared page
+// get page info from props not to send request 'GET /page' from client
+type GrowiContextualSubNavigationForSharedPageProps = {
+  currentPage?: IPagePopulatedToShowRevision,
+  isLinkSharingDisabled: boolean,
+}
+
+const GrowiContextualSubNavigationForSharedPage = (props: GrowiContextualSubNavigationForSharedPageProps): JSX.Element => {
+  const { currentPage, isLinkSharingDisabled } = props;
+  if (currentPage == null) { return <></> }
+  return (
+    <div data-testid="grw-contextual-sub-nav">
+      <GrowiContextualSubNavigationSubstance currentPage={currentPage} isLinkSharingDisabled={isLinkSharingDisabled}/>
+    </div>
+  );
+};
+
 const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
+  useCurrentPathname(props.shareLink?.relatedPage.path);
   useIsSearchPage(false);
   useShareLinkId(props.shareLink?._id);
   useCurrentPageId(props.shareLink?.relatedPage._id);
   useCurrentUser(props.currentUser);
-  const { data: currentPathname } = useCurrentPathname(props.currentPathname);
   useRendererConfig(props.rendererConfig);
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
@@ -88,7 +105,7 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   const isShowSharedPage = !props.disableLinkSharing && !isNotFound && !props.isExpired;
   const shareLink = props.shareLink;
 
-  const title = generateCustomTitleForPage(props, currentPathname ?? '');
+  const title = generateCustomTitleForPage(props, props.shareLinkRelatedPage?.path ?? '');
 
 
   const sideContents = shareLink != null
@@ -111,7 +128,8 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
 
       <div className={`dynamic-layout-root ${growiLayoutFluidClass} h-100 d-flex flex-column justify-content-between`}>
         <header className="py-0 position-relative">
-          {isShowSharedPage && <GrowiContextualSubNavigation currentPage={props.shareLinkRelatedPage} isLinkSharingDisabled={props.disableLinkSharing} />}
+          {isShowSharedPage
+          && <GrowiContextualSubNavigationForSharedPage currentPage={props.shareLinkRelatedPage} isLinkSharingDisabled={props.disableLinkSharing} />}
         </header>
 
         <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
@@ -191,8 +209,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    attrWhiteList: xssService.getAttrWhiteList(),
-    tagWhiteList: xssService.getTagWhiteList(),
+    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
+    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 }

+ 3 - 2
packages/app/src/pages/tags.page.tsx

@@ -165,8 +165,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    attrWhiteList: crowi.xssService.getAttrWhiteList(),
-    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
+    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 }

+ 3 - 2
packages/app/src/pages/trash.page.tsx

@@ -160,8 +160,9 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
 
     // XSS Options
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:rehypeSanitize:isEnabledPrevention'),
-    attrWhiteList: crowi.xssService.getAttrWhiteList(),
-    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    xssOption: configManager.getConfig('markdown', 'markdown:rehypeSanitize:option'),
+    attrWhiteList: JSON.parse(crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:attributes')),
+    tagWhiteList: crowi.configManager.getConfig('markdown', 'markdown:rehypeSanitize:tagNames'),
     highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 }

+ 7 - 3
packages/app/src/pages/utils/commons.ts

@@ -1,12 +1,13 @@
+import type { ColorScheme, IUser, IUserHasId } from '@growi/core';
 import {
-  DevidedPagePath, Lang, AllLang, IUser, IUserHasId,
+  DevidedPagePath, Lang, AllLang,
 } from '@growi/core';
 import { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { SSRConfig, UserConfig } from 'next-i18next';
 
 import * as nextI18NextConfig from '^/config/next-i18next.config';
 
-import { CrowiRequest } from '~/interfaces/crowi-request';
+import type { CrowiRequest } from '~/interfaces/crowi-request';
 
 export type CommonProps = {
   namespacesRequired: string[], // i18next
@@ -22,6 +23,7 @@ export type CommonProps = {
   redirectDestination: string | null,
   isDefaultLogo: boolean,
   currentUser?: IUser,
+  forcedColorScheme?: ColorScheme,
 } & Partial<SSRConfig>;
 
 // eslint-disable-next-line max-len
@@ -34,7 +36,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
   } = crowi;
 
   const url = new URL(context.resolvedUrl, 'http://example.com');
-  const currentPathname = decodeURI(url.pathname);
+  const currentPathname = decodeURIComponent(url.pathname);
 
   const isMaintenanceMode = appService.isMaintenanceMode();
 
@@ -47,6 +49,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
   const redirectDestination = !isMaintenanceMode && currentPathname === '/maintenance' ? '/' : isMaintenanceMode && !currentPathname.match('/admin/*') && !(currentPathname === '/maintenance') ? '/maintenance' : null;
   const isCustomizedLogoUploaded = await attachmentService.isBrandLogoExist();
   const isDefaultLogo = crowi.configManager.getConfig('crowi', 'customize:isDefaultLogo') || !isCustomizedLogoUploaded;
+  const forcedColorScheme = crowi.customizeService.forcedColorScheme;
 
   const props: CommonProps = {
     namespacesRequired: ['translation'],
@@ -62,6 +65,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
     redirectDestination,
     currentUser,
     isDefaultLogo,
+    forcedColorScheme,
   };
 
   return { props };

+ 15 - 1
packages/app/src/server/crowi/index.js

@@ -23,6 +23,7 @@ import AclService from '../service/acl';
 import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
 import ConfigManager from '../service/config-manager';
+import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
@@ -56,6 +57,8 @@ function Crowi() {
   this.config = {};
   this.configManager = null;
   this.s2sMessagingService = null;
+  this.g2gTransferPusherService = null;
+  this.g2gTransferReceiverService = null;
   this.mailService = null;
   this.passportService = null;
   this.globalNotificationService = null;
@@ -126,11 +129,11 @@ Crowi.prototype.init = async function() {
     this.setupPluginer(),
     this.setupMailer(),
     this.setupSlackIntegrationService(),
+    this.setupG2GTransferService(),
     this.setUpFileUpload(),
     this.setUpFileUploaderSwitchService(),
     this.setupAttachmentService(),
     this.setUpAcl(),
-    this.setUpCustomize(),
     this.setUpRestQiitaAPI(),
     this.setupUserGroupService(),
     this.setupExport(),
@@ -141,6 +144,7 @@ Crowi.prototype.init = async function() {
     this.setupActivityService(),
     this.setupCommentService(),
     this.setupSyncPageStatusService(),
+    this.setUpCustomize(), // depends on pluginService
   ]);
 
   // globalNotification depends on slack and mailer
@@ -604,6 +608,7 @@ Crowi.prototype.setUpCustomize = async function() {
     this.customizeService = new CustomizeService(this);
     this.customizeService.initCustomCss();
     this.customizeService.initCustomTitle();
+    this.customizeService.initGrowiTheme();
 
     // add as a message handler
     if (this.s2sMessagingService != null) {
@@ -764,4 +769,13 @@ Crowi.prototype.setupSlackIntegrationService = async function() {
   }
 };
 
+Crowi.prototype.setupG2GTransferService = async function() {
+  if (this.g2gTransferPusherService == null) {
+    this.g2gTransferPusherService = new G2GTransferPusherService(this);
+  }
+  if (this.g2gTransferReceiverService == null) {
+    this.g2gTransferReceiverService = new G2GTransferReceiverService(this);
+  }
+};
+
 export default Crowi;

+ 1 - 1
packages/app/src/server/models/config.ts

@@ -116,6 +116,7 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'customize:highlightJsStyle' : 'github',
   'customize:highlightJsStyleBorder' : false,
   'customize:theme' : PresetThemes.DEFAULT,
+  'customize:theme:forcedColorScheme' : null,
   'customize:isContainerFluid' : false,
   'customize:isEnabledTimeline' : true,
   'customize:isEnabledAttachTitleHeader' : false,
@@ -229,7 +230,6 @@ schema.statics.getLocalconfig = function(crowi) {
       DRAWIO_URI: env.DRAWIO_URI || null,
       HACKMD_URI: env.HACKMD_URI || null,
       MATHJAX: env.MATHJAX || null,
-      NO_CDN: env.NO_CDN || null,
       GROWI_CLOUD_URI: env.GROWI_CLOUD_URI || null,
       GROWI_APP_ID_FOR_GROWI_CLOUD: env.GROWI_APP_ID_FOR_GROWI_CLOUD || null,
     },

+ 29 - 0
packages/app/src/server/models/transfer-key.ts

@@ -0,0 +1,29 @@
+import { Model, Schema, HydratedDocument } from 'mongoose';
+
+import { ITransferKey } from '~/interfaces/transfer-key';
+
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+interface ITransferKeyMethods {
+  findOneActiveTransferKey(key: string): Promise<HydratedDocument<ITransferKey, ITransferKeyMethods> | null>;
+}
+
+type TransferKeyModel = Model<ITransferKey, any, ITransferKeyMethods>;
+
+const schema = new Schema<ITransferKey, TransferKeyModel, ITransferKeyMethods>({
+  expireAt: { type: Date, default: () => new Date(), expires: '30m' },
+  keyString: { type: String, unique: true }, // original key string
+  key: { type: String, unique: true },
+}, {
+  timestamps: {
+    createdAt: true,
+    updatedAt: false,
+  },
+});
+
+// TODO: validate createdAt
+schema.statics.findOneActiveTransferKey = async function(key: string): Promise<HydratedDocument<ITransferKey, ITransferKeyMethods> | null> {
+  return this.findOne({ key });
+};
+
+export default getOrCreateModel('TransferKey', schema);

+ 5 - 1
packages/app/src/server/models/user.js

@@ -447,7 +447,7 @@ module.exports = function(crowi) {
 
     const userUpperLimit = configManager.getConfig('crowi', 'security:userUpperLimit');
 
-    const activeUsers = await this.countListByStatus(STATUS_ACTIVE);
+    const activeUsers = await this.countActiveUsers();
     if (userUpperLimit <= activeUsers) {
       return true;
     }
@@ -455,6 +455,10 @@ module.exports = function(crowi) {
     return false;
   };
 
+  userSchema.statics.countActiveUsers = async function() {
+    return this.countListByStatus(STATUS_ACTIVE);
+  };
+
   userSchema.statics.countListByStatus = async function(status) {
     const User = this;
     const conditions = { status };

+ 34 - 0
packages/app/src/server/models/vo/g2g-transfer-error.ts

@@ -0,0 +1,34 @@
+import ExtensibleCustomError from 'extensible-custom-error';
+
+export const G2GTransferErrorCode = {
+  INVALID_TRANSFER_KEY_STRING: 'INVALID_TRANSFER_KEY_STRING',
+  FAILED_TO_RETRIEVE_GROWI_INFO: 'FAILED_TO_RETRIEVE_GROWI_INFO',
+  FAILED_TO_RETRIEVE_FILE_METADATA: 'FAILED_TO_RETRIEVE_FILE_METADATA',
+} as const;
+
+export type G2GTransferErrorCode = typeof G2GTransferErrorCode[keyof typeof G2GTransferErrorCode];
+
+export class G2GTransferError extends ExtensibleCustomError {
+
+  readonly id = 'G2GTransferError';
+
+  code!: G2GTransferErrorCode;
+
+  constructor(message: string, code: G2GTransferErrorCode) {
+    super(message);
+    this.code = code;
+  }
+
+}
+
+export const isG2GTransferError = (err: any): err is G2GTransferError => {
+  if (err == null || typeof err !== 'object') {
+    return false;
+  }
+
+  if (err instanceof G2GTransferError) {
+    return true;
+  }
+
+  return err?.id === 'G2GTransferError';
+};

+ 1 - 0
packages/app/src/server/routes/apiv3/customize-setting.js

@@ -325,6 +325,7 @@ module.exports = (crowi) => {
       const customizedParams = {
         theme: await crowi.configManager.getConfig('crowi', 'customize:theme'),
       };
+      customizeService.initGrowiTheme();
       const parameters = { action: SupportedAction.ACTION_ADMIN_THEME_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ customizedParams });

+ 332 - 0
packages/app/src/server/routes/apiv3/g2g-transfer.ts

@@ -0,0 +1,332 @@
+import { createReadStream } from 'fs';
+import path from 'path';
+
+import { ErrorV3 } from '@growi/core';
+import express, { NextFunction, Request, Router } from 'express';
+import { body } from 'express-validator';
+import multer from 'multer';
+
+import { isG2GTransferError } from '~/server/models/vo/g2g-transfer-error';
+import { IDataGROWIInfo, X_GROWI_TRANSFER_KEY_HEADER_NAME } from '~/server/service/g2g-transfer';
+import loggerFactory from '~/utils/logger';
+import { TransferKey } from '~/utils/vo/transfer-key';
+
+
+import Crowi from '../../crowi';
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
+import { ApiV3Response } from './interfaces/apiv3-response';
+
+interface AuthorizedRequest extends Request {
+  user?: any
+}
+
+const logger = loggerFactory('growi:routes:apiv3:transfer');
+
+const validator = {
+  transfer: [
+    body('transferKey').isString().withMessage('transferKey is required'),
+    body('collections').isArray().withMessage('collections is required'),
+    body('optionsMap').isObject().withMessage('optionsMap is required'),
+  ],
+};
+
+/*
+ * Routes
+ */
+module.exports = (crowi: Crowi): Router => {
+  const {
+    g2gTransferPusherService, g2gTransferReceiverService, exportService, importService,
+    growiBridgeService, configManager,
+  } = crowi;
+  if (g2gTransferPusherService == null || g2gTransferReceiverService == null || exportService == null || importService == null
+    || growiBridgeService == null || configManager == null) {
+    throw Error('GROWI is not ready for g2g transfer');
+  }
+
+  const uploads = multer({
+    storage: multer.diskStorage({
+      destination: (req, file, cb) => {
+        cb(null, importService.baseDir);
+      },
+      filename(req, file, cb) {
+        // to prevent hashing the file name. files with same name will be overwritten.
+        cb(null, file.originalname);
+      },
+    }),
+    fileFilter: (req, file, cb) => {
+      if (path.extname(file.originalname) === '.zip') {
+        return cb(null, true);
+      }
+      cb(new Error('Only ".zip" is allowed'));
+    },
+  });
+
+  const uploadsForAttachment = multer({
+    storage: multer.diskStorage({
+      destination: (req, file, cb) => {
+        cb(null, importService.baseDir);
+      },
+      filename(req, file, cb) {
+        // to prevent hashing the file name. files with same name will be overwritten.
+        cb(null, file.originalname);
+      },
+    }),
+  });
+
+  const isInstalled = crowi.configManager?.getConfig('crowi', 'app:installed');
+
+  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
+  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+
+  // Middleware
+  const adminRequiredIfInstalled = (req: Request, res: ApiV3Response, next: NextFunction) => {
+    if (!isInstalled) {
+      next();
+      return;
+    }
+
+    return adminRequired(req, res, next);
+  };
+
+  // Middleware
+  const appSiteUrlRequiredIfNotInstalled = (req: Request, res: ApiV3Response, next: NextFunction) => {
+    if (!isInstalled && req.body.appSiteUrl != null) {
+      next();
+      return;
+    }
+
+    if (crowi.configManager?.getConfig('crowi', 'app:siteUrl') != null || req.body.appSiteUrl != null) {
+      next();
+      return;
+    }
+
+    return res.apiv3Err(new ErrorV3('Body param "appSiteUrl" is required when GROWI is NOT installed yet'), 400);
+  };
+
+  // Local middleware to check if key is valid or not
+  const validateTransferKey = async(req: Request, res: ApiV3Response, next: NextFunction) => {
+    const transferKey = req.headers[X_GROWI_TRANSFER_KEY_HEADER_NAME] as string;
+
+    try {
+      await g2gTransferReceiverService.validateTransferKey(transferKey);
+    }
+    catch (err) {
+      return res.apiv3Err(new ErrorV3('Invalid transfer key', 'invalid_transfer_key'), 403);
+    }
+
+    next();
+  };
+
+  const router = express.Router();
+  const receiveRouter = express.Router();
+  const pushRouter = express.Router();
+
+  // eslint-disable-next-line max-len
+  receiveRouter.get('/files', validateTransferKey, async(req: Request, res: ApiV3Response) => {
+    const files = await crowi.fileUploadService.listFiles();
+    return res.apiv3({ files });
+  });
+
+  // Auto import
+  // eslint-disable-next-line max-len
+  receiveRouter.post('/', uploads.single('transferDataZipFile'), validateTransferKey, async(req: Request & { file: any; }, res: ApiV3Response) => {
+    const { file } = req;
+    const {
+      collections: strCollections,
+      optionsMap: strOptionsMap,
+      operatorUserId,
+      uploadConfigs: strUploadConfigs,
+    } = req.body;
+
+    /*
+     * parse multipart form data
+     */
+    let collections;
+    let optionsMap;
+    let sourceGROWIUploadConfigs;
+    try {
+      collections = JSON.parse(strCollections);
+      optionsMap = JSON.parse(strOptionsMap);
+      sourceGROWIUploadConfigs = JSON.parse(strUploadConfigs);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(new ErrorV3('Failed to parse request body.', 'parse_failed'), 500);
+    }
+
+    /*
+     * unzip and parse
+     */
+    let meta;
+    let innerFileStats;
+    try {
+      const zipFile = importService.getFile(file.filename);
+      await importService.unzip(zipFile);
+
+      const { meta: parsedMeta, innerFileStats: _innerFileStats } = await growiBridgeService.parseZipFile(zipFile);
+      innerFileStats = _innerFileStats;
+      meta = parsedMeta;
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(new ErrorV3('Failed to validate transfer data file.', 'validation_failed'), 500);
+    }
+
+    /*
+     * validate meta.json
+     */
+    try {
+      importService.validate(meta);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(
+        new ErrorV3(
+          'The version of this GROWI and the uploaded GROWI data are not the same',
+          'version_incompatible',
+        ),
+        500,
+      );
+    }
+
+    /*
+     * generate maps of ImportSettings to import
+     */
+    let importSettingsMap;
+    try {
+      importSettingsMap = g2gTransferReceiverService.getImportSettingMap(innerFileStats, optionsMap, operatorUserId);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(new ErrorV3('Import settings are invalid. See GROWI docs about details.', 'import_settings_invalid'));
+    }
+
+    try {
+      await g2gTransferReceiverService.importCollections(collections, importSettingsMap, sourceGROWIUploadConfigs);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(new ErrorV3('Failed to import MongoDB collections', 'mongo_collection_import_failure'), 500);
+    }
+
+    return res.apiv3({ message: 'Successfully started to receive transfer data.' });
+  });
+
+  // This endpoint uses multer's MemoryStorage since the received data should be persisted directly on attachment storage.
+  receiveRouter.post('/attachment', uploadsForAttachment.single('content'), validateTransferKey,
+    async(req: Request & { file: any; }, res: ApiV3Response) => {
+      const { file } = req;
+      const { attachmentMetadata } = req.body;
+
+      let attachmentMap;
+      try {
+        attachmentMap = JSON.parse(attachmentMetadata);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3('Failed to parse body.', 'parse_failed'), 500);
+      }
+
+      const fileStream = createReadStream(file.path, {
+        flags: 'r', mode: 0o666, autoClose: true,
+      });
+      try {
+        await g2gTransferReceiverService.receiveAttachment(fileStream, attachmentMap);
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(new ErrorV3('Failed to upload.', 'upload_failed'), 500);
+      }
+
+      return res.apiv3({ message: 'Successfully imported attached file.' });
+    });
+
+  receiveRouter.get('/growi-info', validateTransferKey, async(req: Request, res: ApiV3Response) => {
+    let growiInfo: IDataGROWIInfo;
+    try {
+      growiInfo = await g2gTransferReceiverService.answerGROWIInfo();
+    }
+    catch (err) {
+      logger.error(err);
+
+      if (!isG2GTransferError(err)) {
+        return res.apiv3Err(new ErrorV3('Failed to prepare GROWI info', 'failed_to_prepare_growi_info'), 500);
+      }
+
+      return res.apiv3Err(new ErrorV3(err.message, err.code), 500);
+    }
+
+    return res.apiv3({ growiInfo });
+  });
+
+  // eslint-disable-next-line max-len
+  receiveRouter.post('/generate-key', accessTokenParser, adminRequiredIfInstalled, appSiteUrlRequiredIfNotInstalled, async(req: Request, res: ApiV3Response) => {
+    const appSiteUrl = req.body.appSiteUrl ?? crowi.configManager?.getConfig('crowi', 'app:siteUrl');
+
+    let appSiteUrlOrigin: string;
+    try {
+      appSiteUrlOrigin = new URL(appSiteUrl).origin;
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(new ErrorV3('appSiteUrl may be wrong', 'failed_to_generate_key_string'));
+    }
+
+    // Save TransferKey document
+    let transferKeyString: string;
+    try {
+      transferKeyString = await g2gTransferReceiverService.createTransferKey(appSiteUrlOrigin);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(new ErrorV3('Error occurred while generating transfer key.', 'failed_to_generate_key'));
+    }
+
+    return res.apiv3({ transferKey: transferKeyString });
+  });
+
+  // eslint-disable-next-line max-len
+  pushRouter.post('/transfer', accessTokenParser, loginRequiredStrictly, adminRequired, validator.transfer, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
+    const { transferKey, collections, optionsMap } = req.body;
+
+    // Parse transfer key
+    let tk: TransferKey;
+    try {
+      tk = TransferKey.parse(transferKey);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(new ErrorV3('Transfer key is invalid', 'transfer_key_invalid'), 400);
+    }
+
+    // get growi info
+    let destGROWIInfo: IDataGROWIInfo;
+    try {
+      destGROWIInfo = await g2gTransferPusherService.askGROWIInfo(tk);
+    }
+    catch (err) {
+      logger.error(err);
+      return res.apiv3Err(new ErrorV3('Error occurred while asking GROWI info.', 'failed_to_ask_growi_info'));
+    }
+
+    // Check if can transfer
+    const transferability = await g2gTransferPusherService.getTransferability(destGROWIInfo);
+    if (!transferability.canTransfer) {
+      return res.apiv3Err(new ErrorV3(transferability.reason, 'growi_incompatible_to_transfer'));
+    }
+
+    // Start transfer
+    // DO NOT "await". Let it run in the background.
+    // Errors should be emitted through websocket.
+    g2gTransferPusherService.startTransfer(tk, req.user, collections, optionsMap, destGROWIInfo);
+
+    return res.apiv3({ message: 'Successfully requested auto transfer.' });
+  });
+
+  // Merge receiveRouter and pushRouter
+  router.use(receiveRouter, pushRouter);
+
+  return router;
+};

+ 16 - 12
packages/app/src/server/routes/apiv3/import.js

@@ -1,11 +1,14 @@
 import { ErrorV3 } from '@growi/core';
-import mongoose from 'mongoose';
 
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 
+import overwriteParamsAttachmentFilesChunks from './overwrite-params/attachmentFiles.chunks';
+import overwriteParamsPages from './overwrite-params/pages';
+import overwriteParamsRevisions from './overwrite-params/revisions';
+
 
 const logger = loggerFactory('growi:routes:apiv3:import'); // eslint-disable-line no-unused-vars
 
@@ -51,23 +54,23 @@ const router = express.Router();
 /**
  * generate overwrite params with overwrite-params/* modules
  * @param {string} collectionName
- * @param {object} req Request Object
+ * @param {string} operatorUserId Operator user id
  * @param {GrowiArchiveImportOption} options GrowiArchiveImportOption instance
  */
-const generateOverwriteParams = (collectionName, req, options) => {
+export const generateOverwriteParams = (collectionName, operatorUserId, options) => {
   switch (collectionName) {
     case 'pages':
-      return require('./overwrite-params/pages')(req, options);
+      return overwriteParamsPages(operatorUserId, options);
     case 'revisions':
-      return require('./overwrite-params/revisions')(req, options);
+      return overwriteParamsRevisions(operatorUserId, options);
     case 'attachmentFiles.chunks':
-      return require('./overwrite-params/attachmentFiles.chunks')(req, options);
+      return overwriteParamsAttachmentFilesChunks(operatorUserId, options);
     default:
       return {};
   }
 };
 
-module.exports = (crowi) => {
+export default function route(crowi) {
   const { growiBridgeService, importService, socketIoService } = crowi;
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi);
@@ -282,7 +285,7 @@ module.exports = (crowi) => {
       importSettings.jsonFileName = fileName;
 
       // generate overwrite params
-      importSettings.overwriteParams = generateOverwriteParams(collectionName, req, options);
+      importSettings.overwriteParams = generateOverwriteParams(collectionName, req.user._id, options);
 
       importSettingsMap[collectionName] = importSettings;
     });
@@ -292,6 +295,7 @@ module.exports = (crowi) => {
      */
     try {
       importService.import(collections, importSettingsMap);
+
       const parameters = { action: SupportedAction.ACTION_ADMIN_GROWI_DATA_IMPORTED };
       activityEvent.emit('update', res.locals.activity._id, parameters);
     }
@@ -352,9 +356,9 @@ module.exports = (crowi) => {
       return res.apiv3(data);
     }
     catch {
-      const msg = 'the version of this growi and the growi that exported the data are not met';
-      const varidationErr = 'versions-are-not-met';
-      return res.apiv3Err(new ErrorV3(msg, varidationErr), 500);
+      const msg = 'The version of this GROWI and the uploaded GROWI data are not the same';
+      const validationErr = 'versions-are-not-met';
+      return res.apiv3Err(new ErrorV3(msg, validationErr), 500);
     }
   });
 
@@ -384,4 +388,4 @@ module.exports = (crowi) => {
   });
 
   return router;
-};
+}

+ 6 - 2
packages/app/src/server/routes/apiv3/index.js

@@ -5,6 +5,8 @@ import injectUserRegistrationOrderByTokenMiddleware from '../../middlewares/inje
 import * as loginFormValidator from '../../middlewares/login-form-validator';
 import * as registerFormValidator from '../../middlewares/register-form-validator';
 
+import g2gTransfer from './g2g-transfer';
+import importRoute from './import';
 import pageListing from './page-listing';
 import * as userActivation from './user-activation';
 
@@ -33,13 +35,14 @@ module.exports = (crowi, app) => {
   routerForAdmin.use('/users', require('./users')(crowi));
   routerForAdmin.use('/user-groups', require('./user-group')(crowi));
   routerForAdmin.use('/export', require('./export')(crowi));
-  routerForAdmin.use('/import', require('./import')(crowi));
+  routerForAdmin.use('/import', importRoute(crowi));
   routerForAdmin.use('/search', require('./search')(crowi));
   routerForAdmin.use('/security-setting', require('./security-setting')(crowi));
   routerForAdmin.use('/mongo', require('./mongo')(crowi));
   routerForAdmin.use('/slack-integration-settings', require('./slack-integration-settings')(crowi));
   routerForAdmin.use('/slack-integration-legacy-settings', require('./slack-integration-legacy-settings')(crowi));
   routerForAdmin.use('/activity', require('./activity')(crowi));
+  routerForAdmin.use('/g2g-transfer', g2gTransfer(crowi));
 
   // auth
   const applicationInstalled = require('../../middlewares/application-installed')(crowi);
@@ -48,7 +51,8 @@ module.exports = (crowi, app) => {
   const loginPassport = require('../login-passport')(crowi, app);
 
   routerForAuth.post('/login', applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation,
-    addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.cannotLoginErrorHadnler, loginPassport.loginFailure);
+    addActivity, loginPassport.isEnableLoginWithLocalOrLdap, loginPassport.loginWithLocal, loginPassport.loginWithLdap,
+    loginPassport.cannotLoginErrorHadnler, loginPassport.loginFailure);
 
   routerForAuth.use('/invited', require('./invited')(crowi));
   routerForAuth.use('/logout', require('./logout')(crowi));

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