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

Merge branch 'dev/7.0.x' into feat/yjs-editor

ryoji-s 2 лет назад
Родитель
Сommit
5016426dcc
100 измененных файлов с 1735 добавлено и 789 удалено
  1. 3 1
      .devcontainer/Dockerfile
  2. 2 1
      .devcontainer/devcontainer.json
  3. 2 36
      .devcontainer/docker-compose.yml
  4. 3 0
      .github/workflows/reusable-app-prod.yml
  5. 25 1
      CHANGELOG.md
  6. 1 1
      apps/app/.env.test
  7. 4 2
      apps/app/_obsolete/src/components/PageEditorByHackmd.tsx
  8. 0 17
      apps/app/_obsolete/src/components/Sidebar/NavigationResizeHexagon.tsx
  9. 1 1
      apps/app/docker/README.md
  10. 15 4
      apps/app/package.json
  11. 59 1
      apps/app/public/static/locales/en_US/admin.json
  12. 13 0
      apps/app/public/static/locales/en_US/commons.json
  13. 0 2
      apps/app/public/static/locales/en_US/translation.json
  14. 60 1
      apps/app/public/static/locales/ja_JP/admin.json
  15. 13 0
      apps/app/public/static/locales/ja_JP/commons.json
  16. 0 2
      apps/app/public/static/locales/ja_JP/translation.json
  17. 60 1
      apps/app/public/static/locales/zh_CN/admin.json
  18. 13 0
      apps/app/public/static/locales/zh_CN/commons.json
  19. 0 2
      apps/app/public/static/locales/zh_CN/translation.json
  20. 3 0
      apps/app/resource/fonts/MaterialSymbolsOutlined-opsz,wght,FILL@20..48,300,0..1.woff2
  21. 1 1
      apps/app/resource/search/mappings-es7.json
  22. 1 1
      apps/app/resource/search/mappings-es8-for-ci.json
  23. 1 1
      apps/app/resource/search/mappings-es8.json
  24. 78 0
      apps/app/src/client/services/AdminAppContainer.js
  25. 17 1
      apps/app/src/client/services/page-operation.ts
  26. 11 6
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  27. 11 6
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  28. 2 3
      apps/app/src/client/services/user-ui-settings.ts
  29. 1 1
      apps/app/src/client/util/apiv1-client.ts
  30. 1 1
      apps/app/src/client/util/apiv3-client.ts
  31. 211 0
      apps/app/src/components/Admin/App/AzureSetting.tsx
  32. 75 2
      apps/app/src/components/Admin/App/FileUploadSetting.tsx
  33. 12 0
      apps/app/src/components/Admin/App/MaskedInput.module.scss
  34. 43 0
      apps/app/src/components/Admin/App/MaskedInput.tsx
  35. 3 2
      apps/app/src/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  36. 2 2
      apps/app/src/components/Admin/Security/LdapSecuritySettingContents.jsx
  37. 1 1
      apps/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx
  38. 23 11
      apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  39. 10 1
      apps/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  40. 6 0
      apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  41. 42 24
      apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  42. 1 1
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.module.scss
  43. 63 38
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  44. 35 36
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  45. 50 0
      apps/app/src/components/Admin/UserGroupDetail/use-user-group-resource.ts
  46. 1 1
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  47. 3 3
      apps/app/src/components/Common/CopyDropdown/CopyDropdown.jsx
  48. 8 0
      apps/app/src/components/Common/CopyDropdown/CopyDropdown.module.scss
  49. 16 0
      apps/app/src/components/Common/DrawerToggler/DrawerToggler.module.scss
  50. 36 0
      apps/app/src/components/Common/DrawerToggler/DrawerToggler.tsx
  51. 1 0
      apps/app/src/components/Common/DrawerToggler/index.ts
  52. 1 0
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  53. 7 0
      apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss
  54. 1 2
      apps/app/src/components/Common/PagePathNav/PagePathNav.tsx
  55. 37 0
      apps/app/src/components/Common/PageViewLayout.module.scss
  56. 3 3
      apps/app/src/components/Common/PageViewLayout.tsx
  57. 23 0
      apps/app/src/components/FontFamily/GlobalFonts.tsx
  58. 1 0
      apps/app/src/components/FontFamily/types.d.ts
  59. 20 0
      apps/app/src/components/FontFamily/use-lato.tsx
  60. 18 0
      apps/app/src/components/FontFamily/use-material-symbols-outlined.tsx
  61. 23 0
      apps/app/src/components/FontFamily/use-source-han-code-jp.tsx
  62. 1 6
      apps/app/src/components/IdenticalPathPage.module.scss
  63. 11 102
      apps/app/src/components/InAppNotification/InAppNotificationElm.tsx
  64. 1 1
      apps/app/src/components/InAppNotification/InAppNotificationList.tsx
  65. 47 0
      apps/app/src/components/InAppNotification/PageNotification/ModelNotification.tsx
  66. 51 33
      apps/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx
  67. 27 28
      apps/app/src/components/InAppNotification/PageNotification/UserModelNotification.tsx
  68. 99 0
      apps/app/src/components/InAppNotification/PageNotification/useActionAndMsg.ts
  69. 2 4
      apps/app/src/components/InstallerForm.tsx
  70. 1 1
      apps/app/src/components/Layout/BasicLayout.tsx
  71. 0 3
      apps/app/src/components/Layout/PageViewLayout.module.scss
  72. 0 31
      apps/app/src/components/Navbar/DrawerToggler.tsx
  73. 22 55
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  74. 21 5
      apps/app/src/components/Navbar/GrowiNavbarBottom.module.scss
  75. 29 17
      apps/app/src/components/Navbar/GrowiNavbarBottom.tsx
  76. 11 10
      apps/app/src/components/Navbar/PageEditorModeManager.module.scss
  77. 10 8
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  78. 7 7
      apps/app/src/components/Navbar/hooks.tsx
  79. 1 1
      apps/app/src/components/Page/PageView.tsx
  80. 66 32
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  81. 10 1
      apps/app/src/components/PageAlert/PageGrantAlert.tsx
  82. 10 13
      apps/app/src/components/PageComment/Comment.tsx
  83. 11 5
      apps/app/src/components/PageComment/CommentEditor.tsx
  84. 4 1
      apps/app/src/components/PageComment/DeleteCommentModal.tsx
  85. 3 1
      apps/app/src/components/PageControls/BookmarkButtons.module.scss
  86. 3 1
      apps/app/src/components/PageControls/LikeButtons.module.scss
  87. 3 1
      apps/app/src/components/PageControls/PageControls.module.scss
  88. 44 4
      apps/app/src/components/PageControls/PageControls.tsx
  89. 3 1
      apps/app/src/components/PageControls/SeenUserInfo.module.scss
  90. 3 1
      apps/app/src/components/PageControls/SubscribeButton.module.scss
  91. 0 17
      apps/app/src/components/PageControls/_button-styles.scss
  92. 9 9
      apps/app/src/components/PageDeleteModal.tsx
  93. 0 106
      apps/app/src/components/PageEditor/Editor.module.scss
  94. 6 6
      apps/app/src/components/PageEditor/Editor.tsx
  95. 7 18
      apps/app/src/components/PageEditor/EditorNavbarBottom.tsx
  96. 24 26
      apps/app/src/components/PageEditor/PageEditor.tsx
  97. 1 1
      apps/app/src/components/PageList/PageList.module.scss
  98. 7 8
      apps/app/src/components/PageList/PageListItemL.tsx
  99. 1 2
      apps/app/src/components/PageList/PageListItemS.tsx
  100. 2 2
      apps/app/src/components/PageRenameModal.tsx

+ 3 - 1
.devcontainer/Dockerfile

@@ -37,7 +37,9 @@ RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable
 RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
 
 RUN apt-get update \
-    && apt-get -y install --no-install-recommends git-lfs \
+    && apt-get -y install --no-install-recommends \
+      git-lfs \
+      iputils-ping net-tools dnsutils \
 
     # Uncomment below lines to install Chromium
     # --- works only on AMD64 ---

+ 2 - 1
.devcontainer/devcontainer.json

@@ -19,6 +19,7 @@
     "eamodio.gitlens",
     "github.vscode-pull-request-github",
     "cschleiden.vscode-github-actions",
+    "mongodb.mongodb-vscode",
     "msjsdiag.debugger-for-chrome",
     "firefox-devtools.vscode-firefox-debug",
     "editorconfig.editorconfig",
@@ -34,7 +35,7 @@
   // "shutdownAction": "none",
 
   // Use 'postCreateCommand' to run commands after the container is created.
-  "postCreateCommand": "yarn global add turbo node-gyp && yarn install",
+  "postCreateCommand": "git-lfs pull & turbo run bootstrap",
 
   // Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
   "remoteUser": "node"

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

@@ -31,17 +31,10 @@ services:
     image: mongo:6.0
     restart: unless-stopped
     ports:
-      - 27018:27017
+      - 27017
     volumes:
       - /data/db
 
-  # ogp:
-  #   image: ghcr.io/weseek/growi-unique-ogp:latest
-  #   ports:
-  #     - 8088:8088
-  #   restart: unless-stopped
-  #   tty: true
-
   # This container requires '../../growi-docker-compose' repository
   #   cloned from https://github.com/weseek/growi-docker-compose.git
   elasticsearch:
@@ -52,7 +45,7 @@ services:
         - version=8.7.0
     restart: unless-stopped
     ports:
-      - 9201:9200
+      - 9200
     environment:
       - bootstrap.memory_lock=true
       - "ES_JAVA_OPTS=-Xms256m -Xmx256m"
@@ -65,33 +58,6 @@ services:
       - /usr/share/elasticsearch/data
       - ../../growi-docker-compose/elasticsearch/v8/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
 
-  #need to adjust kibana version based on elasticsearch version (use same version as elasticsearch version)
-  # kibana:
-  #   image: docker.elastic.co/kibana/kibana:8.7.0
-  #   restart: unless-stopped
-  #   environment:
-  #     ELASTICSEARCH_HOSTS: 'http://elasticsearch:9200'
-  #   ports:
-  #     - 5601:5601
-  #   depends_on:
-  #     - elasticsearch
-
-  # This container requires '../../growi-docker-compose' repository
-  #   cloned from https://github.com/weseek/growi-docker-compose.git
-  hackmd:
-    build:
-      context: ../../growi-docker-compose/hackmd
-    restart: unless-stopped
-    environment:
-      - GROWI_URI=http://localhost:3000
-      # define 'storage' option value
-      # see https://github.com/sequelize/cli/blob/7160d0/src/helpers/config-helper.js#L192
-      - CMD_DB_URL=sqlite://dummyhost/hackmd/sqlite/codimd.db
-      - CMD_CSP_ENABLE=false
-    ports:
-      - 3011:3000
-    volumes:
-      - /files/sqlite
 volumes:
   node_modules:
   node_modules_app:

+ 3 - 0
.github/workflows/reusable-app-prod.yml

@@ -27,6 +27,9 @@ jobs:
 
     steps:
     - uses: actions/checkout@v3
+      with:
+        # retrieve local font files
+        lfs: true
 
     - uses: actions/setup-node@v3
       with:

+ 25 - 1
CHANGELOG.md

@@ -1,9 +1,33 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.2.1...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.2.3...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v6.2.3](https://github.com/weseek/growi/compare/v6.2.2...v6.2.3) - 2023-11-13
+
+### 🚀 Improvement
+
+- imprv: Certify shared page attachment middleware (#8211) @yuki-takei
+- imprv: Printing styles 2 (#8203) @yuki-takei
+
+## [v6.2.2](https://github.com/weseek/growi/compare/v6.2.1...v6.2.2) - 2023-10-30
+
+### 🚀 Improvement
+
+- imprv: Printing styles (#8195) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: Show liker counts in lsx (#8194) @yuki-takei
+
+### 🧰 Maintenance
+
+- ci(deps-dev): bump postcss from 8.4.26 to 8.4.31 (#8142) @dependabot
+- ci(deps): bump cypress-io/github-action from 5 to 6 (#8051) @dependabot
+- ci(deps): bump amannn/action-semantic-pull-request from 5.0.2 to 5.3.0 (#8127) @dependabot
+- ci(deps): bump aws-actions/configure-aws-credentials from 2 to 4 (#8128) @dependabot
+
 ## [v6.2.1](https://github.com/weseek/growi/compare/v6.2.0...v6.2.1) - 2023-10-03
 
 ### BREAKING CHANGES

+ 1 - 1
apps/app/.env.test

@@ -5,5 +5,5 @@
 ## > To prevent accidentally leaking env variables to the client, only variables prefixed with
 ## > VITE_ are exposed to your Vite-processed code. e.g. for the following env variables:
 ##
-VITE_MONGOMS_VERSION="6.0.6"
+VITE_MONGOMS_VERSION="6.0.9"
 # VITE_MONGOMS_DEBUG=1

+ 4 - 2
apps/app/_obsolete/src/components/PageEditorByHackmd.tsx

@@ -98,13 +98,15 @@ export const PageEditorByHackmd = (): JSX.Element => {
     if (grantData == null) {
       return;
     }
+    const grantedGroups = grantData.grantedGroups?.map((group) => {
+      return { item: group.id, type: group.type };
+    });
     const optionsToSave = {
       isSlackEnabled: isSlackEnabled ?? false,
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       grant: grantData.grant,
       pageTags: pageTags ?? [],
-      grantUserGroupId: grantData.grantedGroup?.id,
-      grantUserGroupName: grantData.grantedGroup?.name,
+      grantUserGroupIds: grantedGroups,
     };
     return optionsToSave;
   }, [grantData, isSlackEnabled, pageTags]);

+ 0 - 17
apps/app/_obsolete/src/components/Sidebar/NavigationResizeHexagon.tsx

@@ -1,17 +0,0 @@
-import React from 'react';
-
-export const NavigationResizeHexagon = React.memo((): JSX.Element => (
-  <svg
-    xmlns="http://www.w3.org/2000/svg"
-    viewBox="0 0 27.691 23.999"
-  >
-    <g className="background" transform="translate(0 0)">
-      <path d="M20.768,0l6.923,12L20.768,24H6.923L0,12,6.923,0Z" transform="translate(0)"></path>
-    </g>
-    <g className="icon" transform="translate(10 6)">
-      { /* eslint-disable-next-line max-len */ }
-      <path d="M2.124,9.114l5.28,5.34a.647.647,0,0,0,.922,0l.616-.623a.665.665,0,0,0,0-.932L4.759,8.648,8.943,4.4a.665.665,0,0,0,0-.932l-.616-.623a.647.647,0,0,0-.922,0l-5.28,5.34A.665.665,0,0,0,2.124,9.114Z" transform="translate(-1.933 -2.648)"></path>
-    </g>
-  </svg>
-));
-NavigationResizeHexagon.displayName = 'NavigationResizeHexagon';

+ 1 - 1
apps/app/docker/README.md

@@ -11,7 +11,7 @@ Supported tags and respective Dockerfile links
 ------------------------------------------------
 
 * [`7.0.0`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.0/apps/app/docker/Dockerfile)
-* [`6.2.1`, `6.2`, `6`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.1/apps/app/docker/Dockerfile)
+* [`6.2.3`, `6.2`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.3/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)
 
 

+ 15 - 4
apps/app/package.json

@@ -40,6 +40,7 @@
     "reg:run": "reg-suit run",
     "vitest:run": "vitest run config src --coverage",
     "vitest:run:integ": "vitest run -c vitest.config.integ.ts src --coverage",
+    "previtest:run:integ": "vitest run -c test-with-vite/download-mongo-binary/vitest.config.ts test-with-vite/download-mongo-binary",
     "//// misc": "",
     "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
@@ -50,14 +51,18 @@
     "version": "yarn version --no-git-tag-version --preid=RC"
   },
   "// comments for dependencies": {
+    "@aws-skd/*": "fix version above 3.186.0 that is required by mongodb@4.16.0",
+    "@keycloak/keycloak-admin-client": "19.0.0 or above exports only ESM.",
     "escape-string-regexp": "5.0.0 or above exports only ESM",
     "string-width": "5.0.0 or above exports only ESM.",
     "remark-wiki-link": "!!DO NOT REMOVE!! including 'mdast-util-wiki-link' and 'micromark-extension-wiki-link' required by pukiwiki-like-linker"
   },
   "dependencies": {
     "@akebifiky/remark-simple-plantuml": "^1.0.2",
-    "@aws-sdk/client-s3": "^3.58.0",
-    "@aws-sdk/s3-request-presigner": "^3.58.0",
+    "@aws-sdk/client-s3": "3.454.0",
+    "@aws-sdk/s3-request-presigner": "3.454.0",
+    "@azure/identity": "^3.3.2",
+    "@azure/storage-blob": "^12.16.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
@@ -72,9 +77,11 @@
     "@growi/remark-growi-directive": "link:../../packages/remark-growi-directive",
     "@growi/remark-lsx": "link:../../packages/remark-lsx",
     "@growi/slack": "link:../../packages/slack",
+    "@keycloak/keycloak-admin-client": "^18.0.0",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
     "@types/jest": "^29.5.2",
+    "@types/ldapjs": "^2.2.5",
     "JSONStream": "^1.3.5",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
@@ -120,6 +127,7 @@
     "i18next-localstorage-backend": "^4.0.0",
     "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
+    "ldapjs": "^3.0.2",
     "lucene-query-parser": "^1.2.0",
     "markdown-table": "^1.1.1",
     "md5": "^2.2.1",
@@ -212,13 +220,15 @@
     "@growi/ui": "link:../../packages/ui",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
-    "@material-symbols/font-300": "^0.13.1",
     "@next/bundle-analyzer": "^13.2.3",
     "@swc-node/jest": "^1.6.2",
     "@swc/jest": "^0.2.24",
     "@types/express": "^4.17.11",
     "@types/jest": "^29.5.2",
     "@types/react-scroll": "^1.8.4",
+    "@types/throttle-debounce": "^5.0.1",
+    "@types/url-join": "^4.0.2",
+    "@vitest/coverage-v8": "^0.34.6",
     "autoprefixer": "^9.0.0",
     "babel-loader": "^8.2.5",
     "bootstrap": "^5.3.1",
@@ -237,7 +247,8 @@
     "jest-date-mock": "^1.0.8",
     "jest-localstorage-mock": "^2.4.14",
     "load-css-file": "^1.0.0",
-    "mongodb-memory-server": "^8.12.2",
+    "material-icons": "^1.11.3",
+    "mongodb-memory-server-core": "^9.1.1",
     "morgan": "^1.10.0",
     "null-loader": "^4.0.1",
     "plantuml-encoder": "^1.2.5",

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

@@ -9,6 +9,7 @@
   "specified_users": "Specified users",
   "only_me": "Only me",
   "only_inside_the_group": "Only inside the group",
+  "optional": "Optional",
   "security_settings": {
     "security_settings": "Security Settings",
     "scope_of_page_disclosure": "Scope of page disclosure",
@@ -94,7 +95,6 @@
     "enable_link_sharing": "Enable link sharing",
     "all_share_links": "All share links",
     "configuration": " Configuration",
-    "optional": "Optional",
     "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
     "Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
     "Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>email</code> match",
@@ -381,6 +381,13 @@
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "gridfs_label": "MongoDB(GridFS)",
+    "azure_label": "Azure(Blob)",
+    "azure_tenant_id": "Tenant ID",
+    "azure_client_id": "Client ID",
+    "azure_client_secret": "Client Secret",
+    "azure_storage_account_name": "Storage Account Name",
+    "azure_storage_container_name": "Container Name",
+    "azure_note_for_the_only_env_option": "The Azure Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
     "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "test_connection": "Test connection to mail",
     "change_setting": "Caution:if you change this setting not completed, you will not be able to access files you have uploaded so far.",
@@ -1053,6 +1060,57 @@
     "error_generate_growi_archive": "Failed to generate GROWI archive file",
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
+  "external_user_group": {
+    "management": "External Group Management",
+    "execute_sync": "Execute Sync",
+    "sync": "Sync",
+    "invalid_sync_settings": "Invalid sync settings",
+    "update_sync_settings_failed": "Failed to update sync settings",
+    "description_form_detail": "Please note that edited value will be overwritten on next sync if description mapper is set in sync settings",
+    "only_description_edit_allowed": "Only description can be edited for external user groups",
+    "sync_being_executed": "There is a running external group sync process started by you or another user. The next sync cannot be executed until this finishes.",
+    "sync_succeeded": "External group sync succeeded",
+    "sync_failed": "External group sync failed",
+    "provider": "Provider",
+    "confirmation_before_sync": "Confirmation before sync",
+    "execution_time_warning": "If the number of groups or users is large, it might take a while until sync finishes",
+    "parallel_sync_forbidden": "While sync is executing, you cannot execute a different external group sync",
+    "ldap": {
+      "group_sync_settings": "LDAP Group Sync Settings",
+      "group_search_base_DN": "Group Search Base DN",
+      "group_search_base_dn_detail": "The base DN for searching groups. The value set in security settings will be used if not set here.",
+      "membership_attribute": "Membership Attribute",
+      "membership_attribute_detail": "Attribute of the group object which indicates user membership info",
+      "membership_attribute_type": "Membership Attribute Type",
+      "membership_attribute_type_detail": "Whether membership attribute value is of type DN or UID",
+      "child_group_attribute": "Child Group Attribute",
+      "child_group_attribute_detail": "Attribute of the group object which indicates child group info. The attribute value needs to be the DN of the child group.",
+      "preserve_deleted_ldap_groups": "Preserve Deleted LDAP Groups",
+      "name_mapper_detail": "Attribute to map as group name",
+      "updated_group_sync_settings": "Updated LDAP group sync settings",
+      "password": "Password",
+      "password_detail": "Login password is necessary because Bind type is set to User Bind",
+      "auth_not_set": "Enable and configure LDAP auth in security settings before sync"
+    },
+    "keycloak": {
+      "group_sync_settings": "Keycloak Group Sync Settings",
+      "host": "Host",
+      "host_detail": "Keycloak host URL",
+      "group_realm": "Group Realm",
+      "group_realm_detail": "Realm that contains the groups to sync",
+      "group_sync_client_realm": "Realm of client used to request to Admin API",
+      "group_sync_client_realm_detail": "Realm that contains the client used to authenticate to request to Keycloak admin API",
+      "group_sync_client_id": "Client ID",
+      "group_sync_client_id_detail": "Id of the client used to authenticate to request to Keycloak admin API",
+      "group_sync_client_secret": "Client Secret",
+      "group_sync_client_secret_detail": "Id of the secret used to authenticate to request to Keycloak admin API",
+      "updated_group_sync_settings": "Updated Keycloak group sync settings",
+      "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups",
+      "auth_not_set": "Enable and configure OIDC or SAML with Keycloak in security settings before sync"
+    },
+    "auto_generate_user_on_sync": "Auto Generate User on Sync",
+    "description_mapper_detail": "Attribute to map as group description. Description can be edited after sync. However, when a mapper is set, the edited value can possibly be overwritten by the next sync."
+  },
   "toaster": {
     "grant_user_admin": "Succeeded to grant {{username}} admin",
     "revoke_user_admin": "Succeeded to revoke {{username}} admin",

+ 13 - 0
apps/app/public/static/locales/en_US/commons.json

@@ -68,6 +68,19 @@
     "feedback": "Feedback"
   },
 
+  "create_page_dropdown": {
+    "new_page": "Create New Page",
+    "todays": {
+      "desc": "Create today's ...",
+      "memo": "memo"
+    },
+    "template": {
+      "desc": "Create/Edit template page",
+      "children": "Template for children",
+      "descendants": "Template for descendants"
+    }
+  },
+
   "copy_to_clipboard": {
     "Copy to clipboard": "Copy to clipboard",
     "Page path": "Page path",

+ 0 - 2
apps/app/public/static/locales/en_US/translation.json

@@ -106,8 +106,6 @@
   "Disclose E-mail": "Disclose E-mail",
   "page exists": "this page already exists",
   "Error occurred": "Error occurred",
-  "Create today's": "Create today's ...",
-  "Memo": "memo",
   "Input page name": "Input page name",
   "Input page name (optional)": "Input page name (optional)",
   "New Page": "New page",

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

@@ -10,6 +10,7 @@
   "Created": "作成日",
   "Edit": "編集",
   "Description": "説明",
+  "Execute": "実行",
   "last_login": "最終ログイン",
   "wiki_management_homepage": "Wiki管理トップ",
   "public": "公開",
@@ -17,6 +18,7 @@
   "specified_users": "特定ユーザーのみ",
   "only_me": "自分のみ",
   "only_inside_the_group": "特定グループのみ",
+  "optional": "オプション",
   "security_settings": {
     "security_settings": "セキュリティ設定",
     "scope_of_page_disclosure": "ページの公開範囲",
@@ -102,7 +104,6 @@
     "enable_link_sharing": "リンクのシェアを許可",
     "all_share_links": "全てのシェアリンク",
     "configuration": "設定",
-    "optional": "オプション",
     "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
     "Treat username matching as identical_warn": "警告: <code>username</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
     "Treat email matching as identical": "新規ログイン時、<code>email</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
@@ -388,6 +389,13 @@
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "gridfs_label": "MongoDB(GridFS)",
+    "azure_label": "Azure(Blob)",
+    "azure_tenant_id": "テナントID",
+    "azure_client_id": "クライアントID",
+    "azure_client_secret": "クライアントシークレット",
+    "azure_storage_account_name": "ストレージアカウント名",
+    "azure_storage_container_name": "コンテナ名",
+    "azure_note_for_the_only_env_option": "現在Azure設定は環境変数の値によって制限されています<br>この設定を変更する場合は環境変数 <code>{{env}}</code> の値をfalseに変更もしくは削除してください",
     "fixed_by_env_var": "環境変数 <code>FILE_UPLOAD={{fileUploadType}}</code> により固定されています。",
     "file_upload": "ファイルをアップロードするための設定を行います。ファイルアップロードの設定を完了させると、ファイルアップロード機能、プロフィール写真機能などが有効になります。",
     "test_connection": "接続テスト",
@@ -1062,6 +1070,57 @@
     "error_generate_growi_archive": "GROWI アーカイブファイルの作成に失敗しました",
     "error_send_growi_archive": "GROWI アーカイブファイルの送信に失敗しました"
   },
+  "external_user_group": {
+    "management": "外部グループ管理",
+    "execute_sync": "同期実行",
+    "sync": "同期",
+    "invalid_sync_settings": "同期設定に誤りがあります",
+    "update_sync_settings_failed": "同期設定の更新が失敗しました",
+    "description_form_detail": "同期設定で「説明」の mapper が設定されている場合、編集内容は再同期によって上書きされることに注意してください",
+    "only_description_edit_allowed": "外部グループは説明の編集のみが可能です",
+    "sync_being_executed": "自身または他のユーザが実行した外部グループ同期が終了するまで次の実行ができません",
+    "sync_succeeded": "外部グループ同期に成功しました",
+    "sync_failed": "外部グループ同期に失敗しました",
+    "provider": "プロバイダ",
+    "confirmation_before_sync": "同期実行前の確認",
+    "execution_time_warning": "同期するグループやユーザが多い場合、同期が完了するまでに時間を要します",
+    "parallel_sync_forbidden": "同期実行中は、他の外部グループ同期は実行できません",
+    "ldap": {
+      "group_sync_settings": "LDAP グループ同期設定",
+      "group_search_base_DN": "グループ検索ベース DN",
+      "group_search_base_dn_detail": "グループ検索をするベース DN。設定されていない場合、セキュリティ設定で設定されたものが利用されます。",
+      "membership_attribute": "所属メンバーを表す LDAP 属性",
+      "membership_attribute_detail": "グループの所属メンバーを表すグループオブジェクトの属性",
+      "membership_attribute_type": "「所属メンバーを表す LDAP 属性」値の種類",
+      "membership_attribute_type_detail": "グループの所属メンバーを表すグループオブジェクトの属性値は DN か UID か",
+      "child_group_attribute": "子グループを表す LDAP 属性",
+      "child_group_attribute_detail": "グループに所属する子グループを表すグループオブジェクトの属性。属性値は DN である必要があります。",
+      "preserve_deleted_ldap_groups": "LDAP から削除されたグループを GROWI に残す",
+      "name_mapper_detail": "グループの「名前」として読み込む属性",
+      "updated_group_sync_settings": "LDAP グループ同期設定を更新しました",
+      "password": "パスワード",
+      "password_detail": "認証設定がユーザ Bind のため、ログイン時のパスワードの入力が必要となります",
+      "auth_not_set": "同期実行前にセキュリティ設定で LDAP 認証を有効にし、設定してください"
+    },
+    "keycloak": {
+      "group_sync_settings": "Keycloak グループ同期設定",
+      "host": "Host",
+      "host_detail": "Keycloak ホスト URL",
+      "group_realm": "Group Realm",
+      "group_realm_detail": "同期対象のグループがある realm",
+      "group_sync_client_realm": "Admin API にリクエストするための client がある realm",
+      "group_sync_client_realm_detail": "Keycloak admin API にリクエストするための認証に使う client がある realm",
+      "group_sync_client_id": "Client の ID",
+      "group_sync_client_id_detail": "Keycloak admin API にリクエストするための認証に使う client の Client ID",
+      "group_sync_client_secret": "Client の Secret",
+      "group_sync_client_secret_detail": "Keycloak admin API にリクエストするための認証に使う client の secret",
+      "updated_group_sync_settings": "Keycloak グループ同期設定を更新しました",
+      "preserve_deleted_keycloak_groups": "Keycloak から削除されたグループを GROWI に残す",
+      "auth_not_set": "同期実行前にセキュリティ設定で Keycloak を使った OIDC または SAML 認証を有効にし、設定してください"
+    },
+    "auto_generate_user_on_sync": "作成されていない GROWI アカウントを自動生成する",
+    "description_mapper_detail": "グループの「説明」として読み込む属性。「説明」は同期後に編集可能です。ただし、mapper が設定されている場合、編集内容は再同期によって上書きされます。"
+  },
   "toaster": {
     "grant_user_admin": "{{username}}を管理者に設定しました",
     "revoke_user_admin": "{{username}}を管理者から外しました",

+ 13 - 0
apps/app/public/static/locales/ja_JP/commons.json

@@ -70,6 +70,19 @@
     "feedback": "ご意見・ご要望"
   },
 
+  "create_page_dropdown": {
+    "new_page": "新規ページ作成",
+    "todays": {
+      "desc": "今日の◯◯を作成",
+      "memo": "メモ"
+    },
+    "template": {
+      "desc": "テンプレートページの作成/編集",
+      "children": "同一階層テンプレート",
+      "decendants": "下位層テンプレート"
+    }
+  },
+
   "copy_to_clipboard": {
     "Copy to clipboard": "クリップボードにコピー",
     "Page path": "ページ名",

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

@@ -105,8 +105,6 @@
   "Disclose E-mail": "メールアドレスの公開",
   "page exists": "このページはすでに存在しています",
   "Error occurred": "エラーが発生しました",
-  "Create today's": "今日の◯◯を作成",
-  "Memo": "メモ",
   "Input page name": "ページ名を入力",
   "Input page name (optional)": "ページ名を入力(空欄OK)",
   "New Page": "新規ページ",

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

@@ -10,6 +10,7 @@
   "Page": "页面",
   "Edit": "编辑",
   "Description": "描述",
+  "Execute": "执行",
   "last_login": "上次登录",
   "wiki_management_homepage": "Wiki管理首页",
   "public": "公共",
@@ -17,6 +18,7 @@
   "specified_users": "仅指定用户",
   "only_me": "只有我",
   "only_inside_the_group": "仅组内",
+  "optional": "可选的",
   "security_settings": {
     "security_settings": "安全设置",
     "scope_of_page_disclosure": "页面公开范围",
@@ -102,7 +104,6 @@
     "enable_link_sharing": "启用链接共享",
     "all_share_links": "所有共享链接",
 		"configuration": " 配置",
-		"optional": "可选的",
 		"Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
 		"Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
 		"Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>email</code> match",
@@ -388,6 +389,13 @@
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "gridfs_label": "MongoDB(GridFS)",
+    "azure_label": "Azure(Blob)",
+    "azure_tenant_id": "Tenant ID",
+    "azure_client_id": "Client ID",
+    "azure_client_secret": "Client Secret",
+    "azure_storage_account_name": "Storage Account Name",
+    "azure_storage_container_name": "Container Name",
+    "azure_note_for_the_only_env_option": "The Azure Settings is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
     "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
     "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "test_connection": "测试邮件服务器连接",
@@ -1061,6 +1069,57 @@
     "error_generate_growi_archive": "Failed to generate GROWI archive file",
     "error_send_growi_archive": "Failed to send GROWI archive file to the destination GROWI"
   },
+  "external_user_group": {
+    "management": "External Group Management",
+    "execute_sync": "Execute Sync",
+    "sync": "Sync",
+    "invalid_sync_settings": "Invalid sync settings",
+    "update_sync_settings_failed": "Failed to update sync settings",
+    "description_form_detail": "Please note that edited value will be overwritten on next sync if description mapper is set in sync settings",
+    "only_description_edit_allowed": "Only description can be edited for external user groups",
+    "sync_being_executed": "There is a running external group sync process started by you or another user. The next sync cannot be executed until this finishes.",
+    "sync_succeeded": "External group sync succeeded",
+    "sync_failed": "External group sync failed",
+    "provider": "Provider",
+    "confirmation_before_sync": "Confirmation before sync",
+    "execution_time_warning": "If the number of groups or users is large, it might take a while until sync finishes",
+    "parallel_sync_forbidden": "While sync is executing, you cannot execute a different external group sync",
+    "ldap": {
+      "group_sync_settings": "LDAP Group Sync Settings",
+      "group_search_base_DN": "Group Search Base DN",
+      "group_search_base_dn_detail": "The base DN for searching groups. The value set in security settings will be used if not set here.",
+      "membership_attribute": "Membership Attribute",
+      "membership_attribute_detail": "Attribute of the group object which indicates user membership info",
+      "membership_attribute_type": "Membership Attribute Type",
+      "membership_attribute_type_detail": "Whether membership attribute value is of type DN or UID",
+      "child_group_attribute": "Child Group Attribute",
+      "child_group_attribute_detail": "Attribute of the group object which indicates child group info. The attribute value needs to be the DN of the child group.",
+      "preserve_deleted_ldap_groups": "Preserve Deleted LDAP Groups",
+      "name_mapper_detail": "Attribute to map as group name",
+      "updated_group_sync_settings": "Updated LDAP group sync settings",
+      "password": "Password",
+      "password_detail": "Login password is necessary because Bind type is set to User Bind",
+      "auth_not_set": "Enable and configure LDAP auth in security settings before sync"
+    },
+    "keycloak": {
+      "group_sync_settings": "Keycloak Group Sync Settings",
+      "host": "Host",
+      "host_detail": "Keycloak host URL",
+      "group_realm": "Group Realm",
+      "group_realm_detail": "Realm that contains the groups to sync",
+      "group_sync_client_realm": "Realm of client used to request to Admin API",
+      "group_sync_client_realm_detail": "Realm that contains the client used to authenticate to request to Keycloak admin API",
+      "group_sync_client_id": "Client ID",
+      "group_sync_client_id_detail": "Id of the client used to authenticate to request to Keycloak admin API",
+      "group_sync_client_secret": "Client Secret",
+      "group_sync_client_secret_detail": "Id of the secret used to authenticate to request to Keycloak admin API",
+      "updated_group_sync_settings": "Updated Keycloak group sync settings",
+      "preserve_deleted_keycloak_groups": "Preserve Deleted Keycloak Groups",
+      "auth_not_set": "Enable and configure OIDC or SAML with Keycloak in security settings before sync"
+    },
+    "auto_generate_user_on_sync": "Auto Generate User on Sync",
+    "description_mapper_detail": "Attribute to map as group description. Description can be edited after sync. However, when a mapper is set, the edited value can possibly be overwritten by the next sync."
+  },
   "toaster": {
     "grant_user_admin": "Succeeded to grant {{username}} admin",
     "revoke_user_admin": "Succeeded to revoke {{username}} admin",

+ 13 - 0
apps/app/public/static/locales/zh_CN/commons.json

@@ -71,6 +71,19 @@
     "feedback": "意见和要求"
   },
 
+  "create_page_dropdown": {
+    "new_page": "新页面",
+    "todays": {
+      "desc": "Create today's ...",
+      "memo": "memo"
+    },
+    "template": {
+      "desc": "创建/编辑模板页",
+      "children": "子模板",
+      "descendants": "子代模板"
+    }
+  },
+
 	"copy_to_clipboard": {
 		"Copy to clipboard": "复制到剪贴板",
 		"Page path": "页面路径",

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

@@ -111,8 +111,6 @@
 	"Disclose E-mail": "显示邮箱",
 	"page exists": "页面已存在",
 	"Error occurred": "Error occurred",
-	"Create today's": "Create today's ...",
-	"Memo": "memo",
 	"Input page name": "Input page name",
 	"Input page name (optional)": "Input page name (optional)",
 	"New Page": "新页面",

+ 3 - 0
apps/app/resource/fonts/MaterialSymbolsOutlined-opsz,wght,FILL@20..48,300,0..1.woff2

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

+ 1 - 1
apps/app/resource/search/mappings-es7.json

@@ -96,7 +96,7 @@
       "granted_users": {
         "type": "keyword"
       },
-      "granted_group": {
+      "granted_groups": {
         "type": "keyword"
       },
       "created_at": {

+ 1 - 1
apps/app/resource/search/mappings-es8-for-ci.json

@@ -99,7 +99,7 @@
       "granted_users": {
         "type": "keyword"
       },
-      "granted_group": {
+      "granted_groups": {
         "type": "keyword"
       },
       "created_at": {

+ 1 - 1
apps/app/resource/search/mappings-es8.json

@@ -96,7 +96,7 @@
       "granted_users": {
         "type": "keyword"
       },
-      "granted_group": {
+      "granted_groups": {
         "type": "keyword"
       },
       "created_at": {

+ 78 - 0
apps/app/src/client/services/AdminAppContainer.js

@@ -60,6 +60,19 @@ export default class AdminAppContainer extends Container {
       s3SecretAccessKey: '',
       s3ReferenceFileWithRelayMode: false,
 
+      azureReferenceFileWithRelayMode: false,
+      azureUseOnlyEnvVars: false,
+      azureTenantId: '',
+      azureClientId: '',
+      azureClientSecret: '',
+      azureStorageAccountName: '',
+      azureStorageContainerName: '',
+      envAzureTenantId: '',
+      envAzureClientId: '',
+      envAzureClientSecret: '',
+      envAzureStorageAccountName: '',
+      envAzureStorageContainerName: '',
+
       isEnabledPlugins: true,
 
       isMaintenanceMode: false,
@@ -120,6 +133,20 @@ export default class AdminAppContainer extends Container {
       envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
       envGcsBucket: appSettingsParams.envGcsBucket,
       envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
+
+      azureUseOnlyEnvVars: appSettingsParams.azureUseOnlyEnvVars,
+      azureTenantId: appSettingsParams.azureTenantId,
+      azureClientId: appSettingsParams.azureClientId,
+      azureClientSecret: appSettingsParams.azureClientSecret,
+      azureStorageAccountName: appSettingsParams.azureStorageAccountName,
+      azureStorageContainerName: appSettingsParams.azureStorageContainerName,
+      azureReferenceFileWithRelayMode: appSettingsParams.azureReferenceFileWithRelayMode,
+      envAzureTenantId: appSettingsParams.envAzureTenantId,
+      envAzureClientId: appSettingsParams.envAzureClientId,
+      envAzureClientSecret: appSettingsParams.envAzureClientSecret,
+      envAzureStorageAccountName: appSettingsParams.envAzureStorageAccountName,
+      envAzureStorageContainerName: appSettingsParams.envAzureStorageContainerName,
+
       isEnabledPlugins: appSettingsParams.isEnabledPlugins,
       isMaintenanceMode: appSettingsParams.isMaintenanceMode,
     });
@@ -316,6 +343,48 @@ export default class AdminAppContainer extends Container {
     this.setState({ gcsReferenceFileWithRelayMode });
   }
 
+  /**
+   * Change azureReferenceFileWithRelayMode
+   */
+  changeAzureReferenceFileWithRelayMode(azureReferenceFileWithRelayMode) {
+    this.setState({ azureReferenceFileWithRelayMode });
+  }
+
+  /**
+   * Change azureTenantId
+   */
+  changeAzureTenantId(azureTenantId) {
+    this.setState({ azureTenantId });
+  }
+
+  /**
+   * Change azureClientId
+   */
+  changeAzureClientId(azureClientId) {
+    this.setState({ azureClientId });
+  }
+
+  /**
+   * Change azureClientSecret
+   */
+  changeAzureClientSecret(azureClientSecret) {
+    this.setState({ azureClientSecret });
+  }
+
+  /**
+   * Change azureStorageAccountName
+   */
+  changeAzureStorageAccountName(azureStorageAccountName) {
+    this.setState({ azureStorageAccountName });
+  }
+
+  /**
+   * Change azureStorageContainerName
+   */
+  changeAzureStorageContainerName(azureStorageContainerName) {
+    this.setState({ azureStorageContainerName });
+  }
+
   /**
    * Update app setting
    * @memberOf AdminAppContainer
@@ -430,6 +499,15 @@ export default class AdminAppContainer extends Container {
       requestParams.s3ReferenceFileWithRelayMode = this.state.s3ReferenceFileWithRelayMode;
     }
 
+    if (fileUploadType === 'azure') {
+      requestParams.azureTenantId = this.state.azureTenantId;
+      requestParams.azureClientId = this.state.azureClientId;
+      requestParams.azureClientSecret = this.state.azureClientSecret;
+      requestParams.azureStorageAccountName = this.state.azureStorageAccountName;
+      requestParams.azureStorageContainerName = this.state.azureStorageContainerName;
+      requestParams.azureReferenceFileWithRelayMode = this.state.azureReferenceFileWithRelayMode;
+    }
+
     const response = await apiv3Put('/app-settings/file-upload-setting', requestParams);
     const { responseParams } = response.data;
     return this.setState(responseParams);

+ 17 - 1
apps/app/src/client/services/page-operation.ts

@@ -9,7 +9,7 @@ import { useCurrentPageId, useSWRMUTxCurrentPage, useSWRxTagsInfo } from '~/stor
 import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
 
-import { apiPost } from '../util/apiv1-client';
+import { apiGet, apiPost } from '../util/apiv1-client';
 import { apiv3Post, apiv3Put } from '../util/apiv3-client';
 import { toastError } from '../util/toastr';
 
@@ -203,3 +203,19 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null, opts?: Up
 export const unlink = async(path: string): Promise<void> => {
   await apiPost('/pages.unlink', { path });
 };
+
+
+interface PageExistRequest {
+  pagePaths: string;
+}
+
+interface PageExistResponse {
+  pages: Record<string, boolean>;
+  ok: boolean
+}
+
+export const exist = async(pagePaths: string): Promise<PageExistResponse> => {
+  const request: PageExistRequest = { pagePaths };
+  const res = await apiGet<PageExistResponse>('/pages.exist', request);
+  return res;
+};

+ 11 - 6
apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts

@@ -30,27 +30,32 @@ export const useDrawioModalLauncherForView = (opts?: {
   const { data: shareLinkId } = useShareLinkId();
 
   const { data: currentPage } = useSWRxCurrentPage();
-  const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
 
   const { open: openDrawioModal } = useDrawioModal();
 
   const saveOrUpdate = useSaveOrUpdate();
 
   const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
-    if (currentPage == null || tagsInfo == null || shareLinkId != null) {
+    if (currentPage == null || shareLinkId != null) {
       return;
     }
 
     const currentMarkdown = currentPage.revision.body;
     const newMarkdown = mdu.replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
 
+    const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
+      return {
+        type: g.type,
+        item: g.item._id,
+      };
+    });
+
     const optionsToSave: OptionsToSave = {
       isSlackEnabled: false,
       slackChannels: '',
       grant: currentPage.grant,
-      grantUserGroupId: currentPage.grantedGroup?._id,
-      grantUserGroupName: currentPage.grantedGroup?.name,
-      pageTags: tagsInfo.tags,
+      // grantUserGroupIds,
+      // pageTags: tagsInfo.tags,
     };
 
     try {
@@ -67,7 +72,7 @@ export const useDrawioModalLauncherForView = (opts?: {
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
     }
-  }, [currentPage, opts, saveOrUpdate, shareLinkId, tagsInfo]);
+  }, [currentPage, opts, saveOrUpdate, shareLinkId]);
 
 
   // set handler to open DrawioModal

+ 11 - 6
apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts

@@ -29,27 +29,32 @@ export const useHandsontableModalLauncherForView = (opts?: {
   const { data: shareLinkId } = useShareLinkId();
 
   const { data: currentPage } = useSWRxCurrentPage();
-  const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
 
   const { open: openHandsontableModal } = useHandsontableModal();
 
   const saveOrUpdate = useSaveOrUpdate();
 
   const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
-    if (currentPage == null || tagsInfo == null || shareLinkId != null) {
+    if (currentPage == null || shareLinkId != null) {
       return;
     }
 
     const currentMarkdown = currentPage.revision.body;
     const newMarkdown = mtu.replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
 
+    const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
+      return {
+        type: g.type,
+        item: g.item._id,
+      };
+    });
+
     const optionsToSave: OptionsToSave = {
       isSlackEnabled: false,
       slackChannels: '',
       grant: currentPage.grant,
-      grantUserGroupId: currentPage.grantedGroup?._id,
-      grantUserGroupName: currentPage.grantedGroup?.name,
-      pageTags: tagsInfo.tags,
+      // grantUserGroupIds,
+      // pageTags: tagsInfo.tags,
     };
 
     try {
@@ -66,7 +71,7 @@ export const useHandsontableModalLauncherForView = (opts?: {
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
     }
-  }, [currentPage, opts, saveOrUpdate, shareLinkId, tagsInfo]);
+  }, [currentPage, opts, saveOrUpdate, shareLinkId]);
 
 
   // set handler to open HandsonTableModal

+ 2 - 3
apps/app/src/client/services/user-ui-settings.ts

@@ -17,12 +17,11 @@ const _putUserUISettingsInBulk = (): Promise<AxiosResponse<IUserUISettings>> =>
 
 const _putUserUISettingsInBulkDebounced = debounce(1500, _putUserUISettingsInBulk);
 
-type ScheduleToPutFunction = (settings: Partial<IUserUISettings>) => Promise<AxiosResponse<IUserUISettings>>;
-export const scheduleToPut: ScheduleToPutFunction = (settings: Partial<IUserUISettings>): Promise<AxiosResponse<IUserUISettings>> => {
+export const scheduleToPut = (settings: Partial<IUserUISettings>): void => {
   settingsForBulk = {
     ...settingsForBulk,
     ...settings,
   };
 
-  return _putUserUISettingsInBulkDebounced();
+  _putUserUISettingsInBulkDebounced();
 };

+ 1 - 1
apps/app/src/client/util/apiv1-client.ts

@@ -1,4 +1,4 @@
-import * as urljoin from 'url-join';
+import urljoin from 'url-join';
 
 import axios from '~/utils/axios';
 

+ 1 - 1
apps/app/src/client/util/apiv3-client.ts

@@ -1,6 +1,6 @@
 // eslint-disable-next-line no-restricted-imports
 import { AxiosResponse } from 'axios';
-import * as urljoin from 'url-join';
+import urljoin from 'url-join';
 
 // eslint-disable-next-line no-restricted-imports
 

+ 211 - 0
apps/app/src/components/Admin/App/AzureSetting.tsx

@@ -0,0 +1,211 @@
+import { useTranslation } from 'next-i18next';
+
+import MaskedInput from './MaskedInput';
+
+export type AzureSettingMoleculeProps = {
+  azureReferenceFileWithRelayMode
+  azureUseOnlyEnvVars
+  azureTenantId
+  azureClientId
+  azureClientSecret
+  azureStorageAccountName
+  azureStorageContainerName
+  envAzureTenantId?
+  envAzureClientId?
+  envAzureClientSecret?
+  envAzureStorageAccountName?
+  envAzureStorageContainerName?
+  onChangeAzureReferenceFileWithRelayMode: (val: boolean) => void
+  onChangeAzureTenantId: (val: string) => void
+  onChangeAzureClientId: (val: string) => void
+  onChangeAzureClientSecret: (val: string) => void
+  onChangeAzureStorageAccountName: (val: string) => void
+  onChangeAzureStorageContainerName: (val: string) => void
+};
+
+export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    azureReferenceFileWithRelayMode,
+    azureUseOnlyEnvVars,
+    azureTenantId,
+    azureClientId,
+    azureClientSecret,
+    azureStorageAccountName,
+    envAzureTenantId,
+    envAzureClientId,
+    envAzureClientSecret,
+    envAzureStorageAccountName,
+    azureStorageContainerName,
+    envAzureStorageContainerName,
+  } = props;
+
+  return (
+    <>
+
+      <div className="row form-group my-3">
+        <label className="text-left text-md-right col-md-3 col-form-label">
+          {t('admin:app_setting.file_delivery_method')}
+        </label>
+
+        <div className="col-md-6">
+          <div className="dropdown">
+            <button
+              className="btn btn-outline-secondary dropdown-toggle"
+              type="button"
+              id="ddAzureReferenceFileWithRelayMode"
+              data-toggle="dropdown"
+              aria-haspopup="true"
+              aria-expanded="true"
+            >
+              {azureReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_relay')}
+              {!azureReferenceFileWithRelayMode && t('admin:app_setting.file_delivery_method_redirect')}
+            </button>
+            <div className="dropdown-menu" aria-labelledby="ddAzureReferenceFileWithRelayMode">
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { props?.onChangeAzureReferenceFileWithRelayMode(true) }}
+              >
+                {t('admin:app_setting.file_delivery_method_relay')}
+              </button>
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { props?.onChangeAzureReferenceFileWithRelayMode(false) }}
+              >
+                { t('admin:app_setting.file_delivery_method_redirect')}
+              </button>
+            </div>
+
+            <p className="form-text text-muted small">
+              {t('admin:app_setting.file_delivery_method_redirect_info')}
+              <br />
+              {t('admin:app_setting.file_delivery_method_relay_info')}
+            </p>
+          </div>
+        </div>
+      </div>
+
+      {azureUseOnlyEnvVars && (
+        <p
+          className="alert alert-info"
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: t('admin:app_setting.azure_note_for_the_only_env_option', { env: 'AZURE_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }) }}
+        />
+      )}
+      <table className={`table settings-table ${azureUseOnlyEnvVars && 'use-only-env-vars'}`}>
+        <colgroup>
+          <col className="item-name" />
+          <col className="from-db" />
+          <col className="from-env-vars" />
+        </colgroup>
+        <thead>
+          <tr>
+            <th></th>
+            <th>Database</th>
+            <th>Environment variables</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr>
+            <th>{t('admin:app_setting.azure_tenant_id')}</th>
+            <td>
+              <MaskedInput
+                name="azureTenantId"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureTenantId}
+                onChange={e => props?.onChangeAzureTenantId(e.target.value)}
+              />
+            </td>
+            <td>
+              <MaskedInput name="envAzureTenantId" defaultValue={envAzureTenantId || ''} 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: 'AZURE_TENANT_ID' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>{t('admin:app_setting.azure_client_id')}</th>
+            <td>
+              <MaskedInput
+                name="azureClientId"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureClientId}
+                onChange={e => props?.onChangeAzureClientId(e.target.value)}
+              />
+            </td>
+            <td>
+              <MaskedInput name="envAzureClientId" defaultValue={envAzureClientId || ''} 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: 'AZURE_CLIENT_ID' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>{t('admin:app_setting.azure_client_secret')}</th>
+            <td>
+              <MaskedInput
+                name="azureClientSecret"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureClientSecret}
+                onChange={e => props?.onChangeAzureClientSecret(e.target.value)}
+              />
+            </td>
+            <td>
+              <MaskedInput name="envAzureClientSecret" defaultValue={envAzureClientSecret || ''} 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: 'AZURE_CLIENT_SECRET' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>{t('admin:app_setting.azure_storage_account_name')}</th>
+            <td>
+              <input
+                className="form-control"
+                type="text"
+                name="azureStorageAccountName"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureStorageAccountName}
+                onChange={e => props?.onChangeAzureStorageAccountName(e.target.value)}
+              />
+            </td>
+            <td>
+              <input className="form-control" type="text" value={envAzureStorageAccountName || ''} 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: 'AZURE_STORAGE_ACCOUNT_NAME' }) }} />
+              </p>
+            </td>
+          </tr>
+          <tr>
+            <th>{t('admin:app_setting.azure_storage_container_name')}</th>
+            <td>
+              <input
+                className="form-control"
+                type="text"
+                name="azureStorageContainerName"
+                readOnly={azureUseOnlyEnvVars}
+                defaultValue={azureStorageContainerName}
+                onChange={e => props?.onChangeAzureStorageContainerName(e.target.value)}
+              />
+            </td>
+            <td>
+              <input className="form-control" type="text" value={envAzureStorageContainerName || ''} 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: 'AZURE_STORAGE_CONTAINER_NAME' }) }} />
+              </p>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+
+    </>
+  );
+};

+ 75 - 2
apps/app/src/components/Admin/App/FileUploadSetting.tsx

@@ -10,17 +10,20 @@ import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 import { AwsSettingMolecule } from './AwsSetting';
 import type { AwsSettingMoleculeProps } from './AwsSetting';
+import { AzureSettingMolecule } from './AzureSetting';
+import type { AzureSettingMoleculeProps } from './AzureSetting';
 import { GcsSettingMolecule } from './GcsSetting';
 import type { GcsSettingMoleculeProps } from './GcsSetting';
 
-const fileUploadTypes = ['aws', 'gcs', 'gridfs', 'local'] as const;
+
+const fileUploadTypes = ['aws', 'gcs', 'azure', 'gridfs', 'local'] as const;
 
 type FileUploadSettingMoleculeProps = {
   fileUploadType: string
   isFixedFileUploadByEnvVar: boolean
   envFileUploadType?: string
   onChangeFileUploadType: (e: ChangeEvent, type: string) => void
-} & AwsSettingMoleculeProps & GcsSettingMoleculeProps;
+} & AwsSettingMoleculeProps & GcsSettingMoleculeProps & AzureSettingMoleculeProps;
 
 export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMoleculeProps): JSX.Element => {
   const { t } = useTranslation(['admin', 'commons']);
@@ -102,6 +105,28 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
           onChangeGcsUploadNamespace={props.onChangeGcsUploadNamespace}
         />
       )}
+      {props.fileUploadType === 'azure' && (
+        <AzureSettingMolecule
+          azureReferenceFileWithRelayMode={props.azureReferenceFileWithRelayMode}
+          azureUseOnlyEnvVars={props.azureUseOnlyEnvVars}
+          azureTenantId={props.azureTenantId}
+          azureClientId={props.azureClientId}
+          azureClientSecret={props.azureClientSecret}
+          azureStorageAccountName={props.azureStorageAccountName}
+          azureStorageContainerName={props.azureStorageContainerName}
+          envAzureStorageAccountName={props.envAzureStorageAccountName}
+          envAzureStorageContainerName={props.envAzureStorageContainerName}
+          envAzureTenantId={props.envAzureTenantId}
+          envAzureClientId={props.envAzureClientId}
+          envAzureClientSecret={props.envAzureClientSecret}
+          onChangeAzureReferenceFileWithRelayMode={props.onChangeAzureReferenceFileWithRelayMode}
+          onChangeAzureTenantId={props.onChangeAzureTenantId}
+          onChangeAzureClientId={props.onChangeAzureClientId}
+          onChangeAzureClientSecret={props.onChangeAzureClientSecret}
+          onChangeAzureStorageAccountName={props.onChangeAzureStorageAccountName}
+          onChangeAzureStorageContainerName={props.onChangeAzureStorageContainerName}
+        />
+      )}
     </>
   );
 });
@@ -124,6 +149,11 @@ const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
     gcsReferenceFileWithRelayMode, gcsUseOnlyEnvVars,
     gcsApiKeyJsonPath, envGcsApiKeyJsonPath, gcsBucket,
     envGcsBucket, gcsUploadNamespace, envGcsUploadNamespace,
+    azureReferenceFileWithRelayMode, azureUseOnlyEnvVars,
+    azureTenantId, azureClientId, azureClientSecret,
+    azureStorageAccountName, azureStorageContainerName,
+    envAzureTenantId, envAzureClientId, envAzureClientSecret,
+    envAzureStorageAccountName, envAzureStorageContainerName,
   } = adminAppContainer.state;
 
   const submitHandler = useCallback(async() => {
@@ -182,6 +212,31 @@ const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
     adminAppContainer.changeGcsUploadNamespace(val);
   }, [adminAppContainer]);
 
+  // Azure
+  const onChangeAzureReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
+    adminAppContainer.changeAzureReferenceFileWithRelayMode(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureTenantIdHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureTenantId(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureClientIdHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureClientId(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureClientSecretHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureClientSecret(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureStorageAccountNameHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureStorageAccountName(val);
+  }, [adminAppContainer]);
+
+  const onChangeAzureStorageContainerNameHandler = useCallback((val: string) => {
+    adminAppContainer.changeAzureStorageContainerName(val);
+  }, [adminAppContainer]);
+
   return (
     <>
       <FileUploadSettingMolecule
@@ -213,6 +268,24 @@ const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
         onChangeGcsApiKeyJsonPath={onChangeGcsApiKeyJsonPathHandler}
         onChangeGcsBucket={onChangeGcsBucketHandler}
         onChangeGcsUploadNamespace={onChangeGcsUploadNamespaceHandler}
+        azureReferenceFileWithRelayMode={azureReferenceFileWithRelayMode}
+        azureUseOnlyEnvVars={azureUseOnlyEnvVars}
+        azureTenantId={azureTenantId}
+        azureClientId={azureClientId}
+        azureClientSecret={azureClientSecret}
+        azureStorageAccountName={azureStorageAccountName}
+        azureStorageContainerName={azureStorageContainerName}
+        envAzureTenantId={envAzureTenantId}
+        envAzureClientId={envAzureClientId}
+        envAzureClientSecret={envAzureClientSecret}
+        envAzureStorageAccountName={envAzureStorageAccountName}
+        envAzureStorageContainerName={envAzureStorageContainerName}
+        onChangeAzureReferenceFileWithRelayMode={onChangeAzureReferenceFileWithRelayModeHandler}
+        onChangeAzureTenantId={onChangeAzureTenantIdHandler}
+        onChangeAzureClientId={onChangeAzureClientIdHandler}
+        onChangeAzureClientSecret={onChangeAzureClientSecretHandler}
+        onChangeAzureStorageAccountName={onChangeAzureStorageAccountNameHandler}
+        onChangeAzureStorageContainerName={onChangeAzureStorageContainerNameHandler}
       />
       <AdminUpdateButtonRow onClick={submitHandler} disabled={retrieveError != null} />
     </>

+ 12 - 0
apps/app/src/components/Admin/App/MaskedInput.module.scss

@@ -0,0 +1,12 @@
+.MaskedInput {
+  position: relative;
+  display: flex;
+}
+
+.PasswordReveal {
+  position: absolute;
+  top: 0rem;
+  right: 0.5rem;
+  left: auto;
+  font-size: 1.4rem;
+}

+ 43 - 0
apps/app/src/components/Admin/App/MaskedInput.tsx

@@ -0,0 +1,43 @@
+import { useState } from 'react';
+
+import styles from './MaskedInput.module.scss';
+
+type Props = {
+  name: string
+  readOnly: boolean
+  defaultValue: string
+  onChange?: (e: any) => void
+  tabIndex?: number | undefined
+};
+
+export default function MaskedInput(props: Props): JSX.Element {
+  const [passwordShown, setPasswordShown] = useState(false);
+  const togglePassword = () => {
+    setPasswordShown(!passwordShown);
+  };
+
+  const {
+    name, readOnly, defaultValue, onChange, tabIndex,
+  } = props;
+
+  return (
+    <div className={styles.MaskedInput}>
+      <input
+        className="form-control"
+        type={passwordShown ? 'text' : 'password'}
+        name={name}
+        readOnly={readOnly}
+        defaultValue={defaultValue}
+        onChange={onChange}
+        tabIndex={tabIndex}
+      />
+      <span onClick={togglePassword} className={styles.PasswordReveal}>
+        {passwordShown ? (
+          <i className="fa fa-eye" />
+        ) : (
+          <i className="fa fa-eye-slash" />
+        )}
+      </span>
+    </div>
+  );
+}

+ 3 - 2
apps/app/src/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

@@ -3,6 +3,7 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
+import { SocketEventName } from '~/interfaces/websocket';
 import { useAdminSocket } from '~/stores/socket-io';
 
 import LabeledProgressBar from '../Common/LabeledProgressBar';
@@ -27,7 +28,7 @@ class RebuildIndexControls extends React.Component {
     const { socket } = this.props;
 
     if (socket != null) {
-      socket.on('addPageProgress', (data) => {
+      socket.on(SocketEventName.AddPageProgress, (data) => {
         this.setState({
           total: data.totalCount,
           current: data.count,
@@ -35,7 +36,7 @@ class RebuildIndexControls extends React.Component {
         });
       });
 
-      socket.on('finishAddPage', (data) => {
+      socket.on(SocketEventName.FinishAddPage, (data) => {
         this.setState({
           total: data.totalCount,
           current: data.count,

+ 2 - 2
apps/app/src/components/Admin/Security/LdapSecuritySettingContents.jsx

@@ -235,7 +235,7 @@ class LdapSecuritySettingContents extends React.Component {
             </div>
 
             <h3 className="alert-anchor border-bottom">
-              Attribute Mapping ({t('security_settings.optional')})
+              Attribute Mapping ({t('optional')})
             </h3>
 
             <div className="row">
@@ -325,7 +325,7 @@ class LdapSecuritySettingContents extends React.Component {
 
 
             <h3 className="alert-anchor border-bottom">
-              {t('security_settings.ldap.group_search_filter')} ({t('security_settings.optional')})
+              {t('security_settings.ldap.group_search_filter')} ({t('optional')})
             </h3>
 
             <div className="row">

+ 1 - 1
apps/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx

@@ -296,7 +296,7 @@ class OidcSecurityManagementContents extends React.Component {
             </div>
 
             <h3 className="alert-anchor border-bottom">
-              Attribute Mapping ({t('security_settings.optional')})
+              Attribute Mapping ({t('optional')})
             </h3>
 
             <div className="row mb-5">

+ 23 - 11
apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -1,4 +1,6 @@
-import React, { FC, useCallback, useState } from 'react';
+import React, {
+  FC, useCallback, useEffect, useState,
+} from 'react';
 
 import type { IUserGroupHasId } from '@growi/core';
 import dateFnsFormat from 'date-fns/format';
@@ -10,6 +12,7 @@ type Props = {
   selectableParentUserGroups?: IUserGroupHasId[],
   submitButtonLabel: string;
   onSubmit: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void>
+  isExternalGroup?: boolean
 };
 
 export const UserGroupForm: FC<Props> = (props: Props) => {
@@ -17,14 +20,14 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
   const { t } = useTranslation('admin');
 
   const {
-    userGroup, parentUserGroup, selectableParentUserGroups, submitButtonLabel, onSubmit,
+    userGroup, parentUserGroup, selectableParentUserGroups, submitButtonLabel, onSubmit, isExternalGroup = false,
   } = props;
   /*
    * State
    */
   const [currentName, setName] = useState<string>(userGroup.name);
   const [currentDescription, setDescription] = useState<string>(userGroup.description);
-  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>(parentUserGroup);
+  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>();
   /*
    * Function
    */
@@ -36,12 +39,16 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
     setDescription(e.target.value);
   }, []);
 
-  const onChangeParerentButtonHandler = useCallback((userGroup: IUserGroupHasId) => {
+  const onChangeParentButtonHandler = useCallback((userGroup: IUserGroupHasId) => {
     if (userGroup._id !== selectedParent?._id) {
       setSelectedParent(userGroup);
     }
   }, [selectedParent, setSelectedParent]);
 
+  useEffect(() => {
+    setSelectedParent(parentUserGroup);
+  }, [parentUserGroup]);
+
   const isSelectableParentUserGroups = selectableParentUserGroups != null && selectableParentUserGroups.length > 0;
 
   const isChildUserGroup = parentUserGroup !== undefined;
@@ -60,7 +67,12 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
 
       <fieldset>
         <h2 className="admin-setting-header">{t('user_group_management.basic_info')}</h2>
-
+        {isExternalGroup
+        && (
+          <div className="mb-3">
+            <small className="text-muted">{t('external_user_group.only_description_edit_allowed')}</small>
+          </div>
+        )}
         {
           userGroup?.createdAt != null && (
             <div className="row">
@@ -74,7 +86,7 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
           <label htmlFor="name" className="col-md-2 col-form-label">
             {t('user_group_management.group_name')}
           </label>
-          <div className="col-md-4">
+          <div className="col-md-4 my-auto">
             <input
               className="form-control"
               type="text"
@@ -83,6 +95,7 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
               value={currentName}
               onChange={onChangeNameHandler}
               required
+              disabled={isExternalGroup}
             />
           </div>
         </div>
@@ -104,10 +117,9 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
             <button
               type="button"
               id="dropdownMenuButton"
-              data-bs-toggle="dropdown"
-              className={`
-                btn btn-outline-secondary dropdown-toggle mb-3 ${isSelectableParentUserGroups ? '' : 'disabled'}
-              `}
+              data-toggle="dropdown"
+              className="btn btn-outline-secondary dropdown-toggle mb-3"
+              disabled={isExternalGroup || !isSelectableParentUserGroups}
             >
               {selectedParent?.name ?? messageAtReleaseParentGroup}
             </button>
@@ -121,7 +133,7 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
                           key={userGroup._id}
                           type="button"
                           className={`dropdown-item ${selectedParent?._id === userGroup._id ? 'active' : ''}`}
-                          onClick={() => onChangeParerentButtonHandler(userGroup)}
+                          onClick={() => onChangeParentButtonHandler(userGroup)}
                         >
                           {userGroup.name}
                         </button>

+ 10 - 1
apps/app/src/components/Admin/UserGroup/UserGroupModal.tsx

@@ -14,6 +14,7 @@ type Props = {
   onClickSubmit?: (userGroupData: Partial<IUserGroupHasId>) => Promise<IUserGroupHasId | void>
   isShow?: boolean
   onHide?: () => Promise<void> | void
+  isExternalGroup?: boolean
 };
 
 export const UserGroupModal: FC<Props> = (props: Props) => {
@@ -21,7 +22,7 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
   const { t } = useTranslation('admin');
 
   const {
-    userGroup, buttonLabel, onClickSubmit, isShow, onHide,
+    userGroup, buttonLabel, onClickSubmit, isShow, onHide, isExternalGroup = false,
   } = props;
 
   /*
@@ -86,6 +87,7 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
               value={currentName}
               onChange={onChangeNameHandler}
               required
+              disabled={isExternalGroup}
             />
           </div>
 
@@ -94,6 +96,13 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
               {t('Description')}
             </label>
             <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
+            {isExternalGroup && (
+              <p className="form-text text-muted">
+                <small>
+                  {t('external_user_group.description_form_detail')}
+                </small>
+              </p>
+            )}
           </div>
 
           {/* TODO 90732: Add a drop-down to show selectable parents */}

+ 6 - 0
apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -6,9 +6,11 @@ import { useTranslation } from 'react-i18next';
 
 import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { ExternalGroupManagement } from '~/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement';
 import { useIsAclEnabled } from '~/stores/context';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 
+
 const UserGroupDeleteModal = dynamic(() => import('./UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
 const UserGroupModal = dynamic(() => import('./UserGroupModal').then(mod => mod.UserGroupModal), { ssr: false });
 const UserGroupTable = dynamic(() => import('./UserGroupTable').then(mod => mod.UserGroupTable), { ssr: false });
@@ -146,6 +148,7 @@ export const UserGroupPage: FC = () => {
 
   return (
     <div data-testid="admin-user-groups">
+      <h2 className="border-bottom">{t('admin:user_group_management.user_group_management')}</h2>
       {
         isAclEnabled ? (
           <div className="mb-3">
@@ -190,6 +193,9 @@ export const UserGroupPage: FC = () => {
         isShow={isDeleteModalShown}
         onHide={hideDeleteModal}
       />
+      <div className="mt-5">
+        <ExternalGroupManagement />
+      </div>
     </div>
   );
 };

+ 42 - 24
apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -7,6 +7,8 @@ import dateFnsFormat from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 
+import { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
+
 
 type Props = {
   headerLabel?: string,
@@ -17,6 +19,7 @@ type Props = {
   onEdit?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onRemove?: (userGroup: IUserGroupHasId) => void | Promise<void>,
   onDelete?: (userGroup: IUserGroupHasId) => void | Promise<void>,
+  isExternalGroup?: boolean
 };
 
 /*
@@ -53,27 +56,37 @@ const generateGroupIdToChildGroupsMap = (childUserGroups: IUserGroupHasId[]): Re
 };
 
 
-export const UserGroupTable: FC<Props> = (props: Props) => {
+export const UserGroupTable: FC<Props> = ({
+  headerLabel,
+  userGroups,
+  userGroupRelations,
+  childUserGroups,
+  isAclEnabled,
+  onEdit,
+  onRemove,
+  onDelete,
+  isExternalGroup = false,
+}: Props) => {
   const { t } = useTranslation('admin');
 
   /*
    * State
    */
-  const [groupIdToUsersMap, setGroupIdToUsersMap] = useState(generateGroupIdToUsersMap(props.userGroupRelations));
-  const [groupIdToChildGroupsMap, setGroupIdToChildGroupsMap] = useState(generateGroupIdToChildGroupsMap(props.childUserGroups));
+  const [groupIdToUsersMap, setGroupIdToUsersMap] = useState(generateGroupIdToUsersMap(userGroupRelations));
+  const [groupIdToChildGroupsMap, setGroupIdToChildGroupsMap] = useState(generateGroupIdToChildGroupsMap(childUserGroups));
 
   /*
    * Function
    */
   const findUserGroup = (e: React.ChangeEvent<HTMLInputElement>): IUserGroupHasId | undefined => {
     const groupId = e.target.getAttribute('data-user-group-id');
-    return props.userGroups.find((group) => {
+    return userGroups.find((group) => {
       return group._id === groupId;
     });
   };
 
   const onClickEdit = async(e) => {
-    if (props.onEdit == null) {
+    if (onEdit == null) {
       return;
     }
 
@@ -82,11 +95,11 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
       return;
     }
 
-    props.onEdit(userGroup);
+    onEdit(userGroup);
   };
 
   const onClickRemove = async(e) => {
-    if (props.onRemove == null) {
+    if (onRemove == null) {
       return;
     }
 
@@ -96,7 +109,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
     }
 
     try {
-      await props.onRemove(userGroup);
+      await onRemove(userGroup);
       userGroup.parent = null;
     }
     catch {
@@ -105,7 +118,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
   };
 
   const onClickDelete = (e) => { // no preventDefault
-    if (props.onDelete == null) {
+    if (onDelete == null) {
       return;
     }
 
@@ -114,24 +127,25 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
       return;
     }
 
-    props.onDelete(userGroup);
+    onDelete(userGroup);
   };
 
   /*
    * useEffect
    */
   useEffect(() => {
-    setGroupIdToUsersMap(generateGroupIdToUsersMap(props.userGroupRelations));
-    setGroupIdToChildGroupsMap(generateGroupIdToChildGroupsMap(props.childUserGroups));
-  }, [props.userGroupRelations, props.childUserGroups]);
+    setGroupIdToUsersMap(generateGroupIdToUsersMap(userGroupRelations));
+    setGroupIdToChildGroupsMap(generateGroupIdToChildGroupsMap(childUserGroups));
+  }, [userGroupRelations, childUserGroups]);
 
   return (
     <div data-testid="grw-user-group-table">
-      <h2>{props.headerLabel}</h2>
+      <h3>{headerLabel}</h3>
 
       <table className="table table-bordered table-user-list">
         <thead>
           <tr>
+            {isExternalGroup && <th>{t('external_user_group.provider')}</th>}
             <th>{t('Name')}</th>
             <th>{t('Description')}</th>
             <th>{t('User')}</th>
@@ -141,14 +155,15 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
           </tr>
         </thead>
         <tbody>
-          {props.userGroups.map((group) => {
+          {userGroups.map((group) => {
             const users = groupIdToUsersMap[group._id];
 
             return (
               <tr key={group._id}>
-                {props.isAclEnabled
+                {isExternalGroup && <td>{(group as IExternalUserGroupHasId).provider}</td>}
+                {isAclEnabled
                   ? (
-                    <td><Link href={`/admin/user-group-detail/${group._id}`}>{group.name}</Link></td>
+                    <td><Link href={`/admin/user-group-detail/${group._id}?isExternalGroup=${isExternalGroup}`}>{group.name}</Link></td>
                   )
                   : (
                     <td>{group.name}</td>
@@ -166,10 +181,10 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                   <ul className="list-inline">
                     {groupIdToChildGroupsMap[group._id] != null && groupIdToChildGroupsMap[group._id].map((group) => {
                       return (
-                        <li key={group._id} className="list-inline-item badge bg-success">
-                          {props.isAclEnabled
+                        <li key={group._id} className="list-inline-item badge badge-success">
+                          {isAclEnabled
                             ? (
-                              <Link href={`/admin/user-group-detail/${group._id}`}>{group.name}</Link>
+                              <Link href={`/admin/user-group-detail/${group._id}?isExternalGroup=${isExternalGroup}`}>{group.name}</Link>
                             )
                             : (
                               <p>{group.name}</p>
@@ -181,7 +196,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                   </ul>
                 </td>
                 <td>{dateFnsFormat(new Date(group.createdAt), 'yyyy-MM-dd')}</td>
-                {props.isAclEnabled
+                {isAclEnabled
                   ? (
                     <td>
                       <div className="btn-group admin-group-menu">
@@ -197,9 +212,12 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
                           <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={group._id}>
                             <i className="icon-fw icon-note"></i> {t('Edit')}
                           </button>
-                          <button className="dropdown-item" type="button" role="button" onClick={onClickRemove} data-user-group-id={group._id}>
-                            <i className="icon-fw fa fa-chain-broken"></i> {t('admin:user_group_management.remove_child_group')}
-                          </button>
+                          {onRemove != null
+                          && (
+                            <button className="dropdown-item" type="button" role="button" onClick={onClickRemove} data-user-group-id={group._id}>
+                              <i className="icon-fw fa fa-chain-broken"></i> {t('admin:user_group_management.remove_child_group')}
+                            </button>
+                          )}
                           <button className="dropdown-item" type="button" role="button" onClick={onClickDelete} data-user-group-id={group._id}>
                             <i className="icon-fw icon-fire text-danger"></i> {t('Delete')}
                           </button>

+ 1 - 1
apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.module.scss

@@ -1,2 +1,2 @@
-@use '@growi/ui/src/styles/molecules/page_list';
+@use '@growi/ui/scss/molecules/page_list';
 

+ 63 - 38
apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -13,22 +13,25 @@ import {
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
 } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import { SearchTypes, SearchType } from '~/interfaces/user-group';
 import Xss from '~/services/xss';
 import { useIsAclEnabled } from '~/stores/context';
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
-import {
-  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxUserGroup,
-  useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups, useSWRxUserGroupRelations,
-} from '~/stores/user-group';
+import { useSWRxUserGroupPages, useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups } from '~/stores/user-group';
 import loggerFactory from '~/utils/logger';
 
+import {
+  useAncestorUserGroups,
+  useChildUserGroupList, useUserGroup, useUserGroupRelationList, useUserGroupRelations,
+} from './use-user-group-resource';
+
 import styles from './UserGroupDetailPage.module.scss';
 
 const logger = loggerFactory('growi:services:AdminCustomizeContainer');
 
 const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
-const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable'), { ssr: false });
+const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable').then(mod => mod.UserGroupUserTable), { ssr: false });
 
 const UserGroupUserModal = dynamic(() => import('./UserGroupUserModal'), { ssr: false });
 
@@ -42,15 +45,16 @@ const UpdateParentConfirmModal = dynamic(() => import('./UpdateParentConfirmModa
 
 type Props = {
   userGroupId: string,
+  isExternalGroup: boolean,
 }
 
 const UserGroupDetailPage = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
   const router = useRouter();
   const xss = useMemo(() => new Xss(), []);
-  const { userGroupId: currentUserGroupId } = props;
+  const { userGroupId: currentUserGroupId, isExternalGroup } = props;
 
-  const { data: currentUserGroup } = useSWRxUserGroup(currentUserGroupId);
+  const { data: currentUserGroup } = useUserGroup(currentUserGroupId, isExternalGroup);
 
   const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
@@ -76,26 +80,36 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
    */
   const { data: userGroupPages } = useSWRxUserGroupPages(currentUserGroupId, 10, 0);
 
-  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelations(currentUserGroupId);
+  const { data: userGroupRelations, mutate: mutateUserGroupRelations } = useUserGroupRelations(currentUserGroupId, isExternalGroup);
 
-  const { data: childUserGroupsList, mutate: mutateChildUserGroups } = useSWRxChildUserGroupList(currentUserGroupId ? [currentUserGroupId] : [], true);
+  const { data: childUserGroupsList, mutate: mutateChildUserGroups, updateChild } = useChildUserGroupList(currentUserGroupId, isExternalGroup);
   const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
   const grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
   const childUserGroupIds = childUserGroups.map(group => group._id);
 
-  const { data: userGroupRelationList, mutate: mutateUserGroupRelationList } = useSWRxUserGroupRelationList(childUserGroupIds);
+  const { data: userGroupRelationList, mutate: mutateUserGroupRelationList } = useUserGroupRelationList(childUserGroupIds, isExternalGroup);
   const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
 
-  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(currentUserGroupId);
-  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(currentUserGroupId);
+  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(
+    isExternalGroup ? null : currentUserGroupId,
+  );
+  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(
+    isExternalGroup ? null : currentUserGroupId,
+  );
 
-  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useSWRxAncestorUserGroups(currentUserGroupId);
+  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useAncestorUserGroups(currentUserGroupId, isExternalGroup);
 
   const { data: isAclEnabled } = useIsAclEnabled();
 
   const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
 
-  const parentUserGroup = selectableParentUserGroups?.find(selectableParentUserGroup => selectableParentUserGroup._id === currentUserGroup?.parent);
+  const parentUserGroup = (() => {
+    if (isExternalGroup) {
+      return ancestorUserGroups != null && ancestorUserGroups.length > 1
+        ? ancestorUserGroups[ancestorUserGroups.length - 2] : undefined;
+    }
+    return selectableParentUserGroups?.find(selectableParentUserGroup => selectableParentUserGroup._id === currentUserGroup?.parent);
+  })();
   /*
    * Function
    */
@@ -113,19 +127,26 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
   const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => {
     const parentId = typeof update.parent === 'string' ? update.parent : update.parent?._id;
-    await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
-      name: update.name,
-      description: update.description,
-      parentId: parentId ?? null,
-      forceUpdateParents,
-    });
+    if (isExternalGroup) {
+      await apiv3Put<{ userGroup: IExternalUserGroupHasId }>(`/external-user-groups/${userGroup._id}`, {
+        description: update.description,
+      });
+    }
+    else {
+      await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
+        name: update.name,
+        description: update.description,
+        parentId: parentId ?? null,
+        forceUpdateParents,
+      });
+    }
 
     // mutate
     mutateChildUserGroups();
     mutateAncestorUserGroups();
     mutateSelectableChildUserGroups();
     mutateSelectableParentUserGroups();
-  }, [mutateAncestorUserGroups, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups]);
+  }, [mutateAncestorUserGroups, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, isExternalGroup]);
 
   const onSubmitUpdateGroup = useCallback(
     async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>, forceUpdateParents: boolean): Promise<void> => {
@@ -213,23 +234,16 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
   const updateChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
     try {
-      await apiv3Put(`/user-groups/${userGroupData._id}`, {
-        name: userGroupData.name,
-        description: userGroupData.description,
-        parentId: userGroupData.parent,
-      });
+      updateChild(userGroupData);
 
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup'), ns: 'commons' }));
 
-      // mutate
-      mutateChildUserGroups();
-
       hideUpdateModal();
     }
     catch (err) {
       toastError(err);
     }
-  }, [t, mutateChildUserGroups, hideUpdateModal]);
+  }, [t, updateChild, hideUpdateModal]);
 
   const onClickAddExistingUserGroupButtonHandler = useCallback(async(selectedChild: IUserGroupHasId): Promise<void> => {
     // show confirm modal before submiting
@@ -283,8 +297,9 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
   }, [setSelectedUserGroup, setDeleteModalShown]);
 
   const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+    const url = isExternalGroup ? `/external-user-groups/${deleteGroupId}` : `/user-groups/${deleteGroupId}`;
     try {
-      const res = await apiv3Delete(`/user-groups/${deleteGroupId}`, {
+      const res = await apiv3Delete(url, {
         actionName,
         transferToUserGroupId,
       });
@@ -300,7 +315,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
     catch (err) {
       toastError(new Error('Unable to delete the groups'));
     }
-  }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown]);
+  }, [mutateChildUserGroups, setSelectedUserGroup, setDeleteModalShown, isExternalGroup]);
 
   const removeChildUserGroup = useCallback(async(userGroupData: IUserGroupHasId) => {
     try {
@@ -348,7 +363,11 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
                 { ancestorUserGroup._id === currentUserGroupId ? (
                   <span>{ancestorUserGroup.name}</span>
                 ) : (
-                  <Link href={`/admin/user-group-detail/${ancestorUserGroup._id}`}>
+                  <Link href={{
+                    pathname: `/admin/user-group-detail/${ancestorUserGroup._id}`,
+                    query: { isExternalGroup: 'true' },
+                  }}
+                  >
                     {ancestorUserGroup.name}
                   </Link>
                 ) }
@@ -366,6 +385,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
           selectableParentUserGroups={selectableParentUserGroups}
           submitButtonLabel={t('Update')}
           onSubmit={onClickSubmitForm}
+          isExternalGroup={isExternalGroup}
         />
       </div>
       <h2 className="admin-setting-header mt-4">{t('user_group_management.user_list')}</h2>
@@ -373,6 +393,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
         userGroupRelations={userGroupRelations}
         onClickPlusBtn={() => setIsUserGroupUserModalShown(true)}
         onClickRemoveUserBtn={removeUserByUsername}
+        isExternalGroup={isExternalGroup}
       />
       <UserGroupUserModal
         isOpen={isUserGroupUserModalShown}
@@ -389,11 +410,13 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
       />
 
       <h2 className="admin-setting-header mt-4">{t('user_group_management.child_group_list')}</h2>
-      <UserGroupDropdown
-        selectableUserGroups={selectableChildUserGroups}
-        onClickAddExistingUserGroupButton={onClickAddExistingUserGroupButtonHandler}
-        onClickCreateUserGroupButton={showCreateModal}
-      />
+      {!isExternalGroup && (
+        <UserGroupDropdown
+          selectableUserGroups={selectableChildUserGroups}
+          onClickAddExistingUserGroupButton={onClickAddExistingUserGroupButtonHandler}
+          onClickCreateUserGroupButton={showCreateModal}
+        />
+      )}
 
       <UserGroupModal
         userGroup={selectedUserGroup}
@@ -401,6 +424,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
         onClickSubmit={updateChildUserGroup}
         isShow={isUpdateModalShown}
         onHide={hideUpdateModal}
+        isExternalGroup={isExternalGroup}
       />
 
       <UserGroupModal
@@ -420,6 +444,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
         onRemove={removeChildUserGroup}
         onDelete={showDeleteModal}
         userGroupRelations={childUserGroupRelations}
+        isExternalGroup={isExternalGroup}
       />
 
       <UserGroupDeleteModal

+ 35 - 36
apps/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx

@@ -10,15 +10,12 @@ type Props = {
   userGroupRelations: IUserGroupRelationHasIdPopulatedUser[] | undefined,
   onClickRemoveUserBtn: (username: string) => Promise<void>,
   onClickPlusBtn: () => void,
+  isExternalGroup?: boolean
 }
 
 export const UserGroupUserTable = (props: Props): JSX.Element => {
   const { t } = useTranslation('admin');
 
-  const {
-    userGroupRelations, onClickRemoveUserBtn, onClickPlusBtn,
-  } = props;
-
   return (
     <table className="table table-bordered table-user-list">
       <thead>
@@ -30,11 +27,11 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
           <th>{t('Name')}</th>
           <th style={{ width: '100px' }}>{t('Created')}</th>
           <th style={{ width: '160px' }}>{t('last_login')}</th>
-          <th style={{ width: '70px' }}></th>
+          {!props.isExternalGroup && <th style={{ width: '70px' }}></th>}
         </tr>
       </thead>
       <tbody>
-        {userGroupRelations != null && userGroupRelations.map((relation) => {
+        {props.userGroupRelations != null && props.userGroupRelations.map((relation) => {
           const { relatedUser } = relation;
 
           return (
@@ -48,47 +45,49 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
               <td>{relatedUser.name}</td>
               <td>{relatedUser.createdAt ? dateFnsFormat(new Date(relatedUser.createdAt), 'yyyy-MM-dd') : ''}</td>
               <td>{relatedUser.lastLoginAt ? dateFnsFormat(new Date(relatedUser.lastLoginAt), 'yyyy-MM-dd HH:mm:ss') : ''}</td>
-              <td>
-                <div className="btn-group admin-user-menu">
-                  <button
-                    type="button"
-                    id={`admin-group-menu-button-${relatedUser._id}`}
-                    className="btn btn-outline-secondary btn-sm dropdown-toggle"
-                    data-bs-toggle="dropdown"
-                  >
-                    <i className="icon-settings"></i>
-                  </button>
-                  <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${relatedUser._id}`}>
+              {!props.isExternalGroup && (
+                <td>
+                  <div className="btn-group admin-user-menu">
                     <button
-                      className="dropdown-item"
                       type="button"
-                      onClick={() => onClickRemoveUserBtn(relatedUser.username)}
+                      id={`admin-group-menu-button-${relatedUser._id}`}
+                      className="btn btn-outline-secondary btn-sm dropdown-toggle"
+                      data-toggle="dropdown"
                     >
-                      <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_group_management.remove_from_group')}
+                      <i className="icon-settings"></i>
                     </button>
+                    <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${relatedUser._id}`}>
+                      <button
+                        className="dropdown-item"
+                        type="button"
+                        onClick={() => props.onClickRemoveUserBtn(relatedUser.username)}
+                      >
+                        <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_group_management.remove_from_group')}
+                      </button>
+                    </div>
                   </div>
-                </div>
-              </td>
+                </td>
+              )}
             </tr>
           );
         })}
 
-        <tr>
-          <td></td>
-          <td className="text-center">
-            <button className="btn btn-outline-secondary" type="button" onClick={onClickPlusBtn}>
-              <i className="ti ti-plus"></i>
-            </button>
-          </td>
-          <td></td>
-          <td></td>
-          <td></td>
-          <td></td>
-        </tr>
+        {!props.isExternalGroup && (
+          <tr>
+            <td></td>
+            <td className="text-center">
+              <button className="btn btn-outline-secondary" type="button" onClick={props.onClickPlusBtn}>
+                <i className="ti ti-plus"></i>
+              </button>
+            </td>
+            <td></td>
+            <td></td>
+            <td></td>
+            <td></td>
+          </tr>
+        )}
 
       </tbody>
     </table>
   );
 };
-
-export default UserGroupUserTable;

+ 50 - 0
apps/app/src/components/Admin/UserGroupDetail/use-user-group-resource.ts

@@ -0,0 +1,50 @@
+import {
+  useSWRxAncestorExternalUserGroups,
+  useSWRxChildExternalUserGroupList,
+  useSWRxExternalUserGroup,
+  useSWRxExternalUserGroupRelationList,
+  useSWRxExternalUserGroupRelations,
+} from '~/features/external-user-group/client/stores/external-user-group';
+import {
+  useSWRxAncestorUserGroups,
+  useSWRxChildUserGroupList, useSWRxUserGroup, useSWRxUserGroupRelationList, useSWRxUserGroupRelations,
+} from '~/stores/user-group';
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useUserGroup = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxUserGroup(isExternalGroup ? null : userGroupId);
+  const externalUserGroupRes = useSWRxExternalUserGroup(isExternalGroup ? userGroupId : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useUserGroupRelations = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxUserGroupRelations(isExternalGroup ? null : userGroupId);
+  const externalUserGroupRes = useSWRxExternalUserGroupRelations(isExternalGroup ? userGroupId : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useChildUserGroupList = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxChildUserGroupList(
+    !isExternalGroup ? [userGroupId] : [], true,
+  );
+  const externalUserGroupRes = useSWRxChildExternalUserGroupList(
+    isExternalGroup ? [userGroupId] : [], true,
+  );
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useUserGroupRelationList = (userGroupIds: string[], isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxUserGroupRelationList(isExternalGroup ? null : userGroupIds);
+  const externalUserGroupRes = useSWRxExternalUserGroupRelationList(isExternalGroup ? userGroupIds : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const useAncestorUserGroups = (userGroupId: string, isExternalGroup: boolean) => {
+  const userGroupRes = useSWRxAncestorUserGroups(isExternalGroup ? null : userGroupId);
+  const externalUserGroupRes = useSWRxAncestorExternalUserGroups(isExternalGroup ? userGroupId : null);
+  return isExternalGroup ? externalUserGroupRes : userGroupRes;
+};

+ 1 - 1
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -233,7 +233,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
                 onClick={loadChildFolder}
               >
                 <div className="d-flex justify-content-center">
-                  <span className="material-symbols-rounded">arrow_right</span>
+                  <span className="material-symbols-outlined">arrow_right</span>
                 </div>
               </button>
             )}

+ 3 - 3
apps/app/src/components/Common/CopyDropdown/CopyDropdown.jsx

@@ -110,10 +110,10 @@ export const CopyDropdown = (props) => {
 
   return (
     <>
-      <Dropdown className={`${styles['grw-copy-dropdown']} grw-copy-dropdown`} isOpen={dropdownOpen} toggle={toggleDropdown}>
+      <Dropdown className={`${styles['grw-copy-dropdown']} grw-copy-dropdown d-print-none`} isOpen={dropdownOpen} size="sm" toggle={toggleDropdown}>
         <DropdownToggle
-          caret
-          className={dropdownToggleClassName}
+          caret={isShareLinkMode}
+          className={`btn-copy ${dropdownToggleClassName}`}
         >
           <span id={dropdownToggleId}>{children}</span>
         </DropdownToggle>

+ 8 - 0
apps/app/src/components/Common/CopyDropdown/CopyDropdown.module.scss

@@ -1,4 +1,12 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+@use '@growi/ui/scss/atoms/btn-muted';
+
 .grw-copy-dropdown :global {
+  .btn.btn-copy {
+    @include btn-muted.colorize(bs.$gray-500);
+  }
+
   .dropdown-menu {
     min-width: 310px;
 

+ 16 - 0
apps/app/src/components/Common/DrawerToggler/DrawerToggler.module.scss

@@ -0,0 +1,16 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+@use '~/styles/variables' as var;
+
+
+.grw-drawer-toggler :global {
+  .btn {
+    --bs-btn-color: rgba(var(--bs-tertiary-color-rgb), 0.5);
+    --bs-btn-bg: transparent;
+
+    --bs-btn-hover-color: rgba(var(--bs-tertiary-color-rgb), 0.7);
+
+    width: var.$grw-sidebar-nav-width;
+    height: var.$grw-sidebar-nav-width;
+  }
+}

+ 36 - 0
apps/app/src/components/Common/DrawerToggler/DrawerToggler.tsx

@@ -0,0 +1,36 @@
+import { type ReactNode } from 'react';
+
+import { useDrawerOpened } from '~/stores/ui';
+
+
+import styles from './DrawerToggler.module.scss';
+
+const moduleClass = styles['grw-drawer-toggler'];
+
+
+type Props = {
+  className?: string,
+  children?: ReactNode,
+}
+
+export const DrawerToggler = (props: Props): JSX.Element => {
+
+  const { className, children } = props;
+
+  const { data: isOpened, mutate } = useDrawerOpened();
+
+  return (
+    <div className={`${moduleClass} ${className ?? ''}`}>
+      <button
+        className="btn d-flex align-items-center border-0"
+        type="button"
+        aria-expanded="false"
+        aria-label="Toggle navigation"
+        onClick={() => mutate(!isOpened)}
+      >
+        {children}
+      </button>
+    </div>
+  );
+
+};

+ 1 - 0
apps/app/src/components/Common/DrawerToggler/index.ts

@@ -0,0 +1 @@
+export * from './DrawerToggler';

+ 1 - 0
apps/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -250,6 +250,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
   return (
     <DropdownMenu
+      className="d-print-none"
       data-testid="page-item-control-menu"
       end={alignEnd}
       container="body"

+ 7 - 0
apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss

@@ -1,5 +1,7 @@
 @use '@growi/core/scss/bootstrap/init' as bs;
 
+@use '@growi/ui/scss/atoms/btn-muted';
+
 .grw-mr-02em {
   margin-right: 0.2em;
 }
@@ -19,3 +21,8 @@
   }
 }
 
+.grw-page-path-nav :global {
+  .btn-copy {
+    @include btn-muted.colorize(bs.$orange);
+  }
+}

+ 1 - 2
apps/app/src/components/Common/PagePathNav/PagePathNav.tsx

@@ -76,7 +76,6 @@ export const PagePathNav: FC<Props> = (props: Props) => {
   }
 
   const copyDropdownId = `copydropdown-${pageId}`;
-  const copyDropdownToggleClassName = 'd-block btn-outline-secondary btn-copy border-0 text-muted p-2';
 
   return (
     <div>
@@ -87,7 +86,7 @@ export const PagePathNav: FC<Props> = (props: Props) => {
         </h1>
         { pageId != null && !isNotFound && (
           <div className="mx-2">
-            <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName={copyDropdownToggleClassName}>
+            <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName="p-2">
               <i className="ti ti-clipboard"></i>
             </CopyDropdown>
           </div>

+ 37 - 0
apps/app/src/components/Common/PageViewLayout.module.scss

@@ -0,0 +1,37 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+@use '~/styles/variables' as var;
+
+
+.page-view-layout :global {
+  min-height: calc(100vh - 48px - 250px); // 100vh - subnavigation height - page-comments-row minimum height
+
+  .grw-side-contents-container {
+    margin-bottom: 1rem;
+
+    @include bs.media-breakpoint-up(lg) {
+      width: 250px;
+      min-width: 250px;
+      margin-left: 30px;
+    }
+  }
+}
+
+// md/lg layout padding
+.page-view-layout :global {
+  @include bs.media-breakpoint-between(md, xl) {
+    padding-left: var.$grw-sidebar-nav-width;
+  }
+}
+
+// sticky side contents
+.page-view-layout :global {
+  .grw-side-contents-sticky-container {
+    position: sticky;
+
+    $subnavigation-height: 50px;
+    $page-view-layout-margin-top: 32px;
+    $page-path-nav-height: 99px;
+    top: calc($subnavigation-height + $page-view-layout-margin-top + $page-path-nav-height + 4px);
+  }
+}

+ 3 - 3
apps/app/src/components/Layout/PageViewLayout.tsx → apps/app/src/components/Common/PageViewLayout.tsx

@@ -16,16 +16,16 @@ export const PageViewLayout = (props: Props): JSX.Element => {
 
   return (
     <>
-      <div id="main" className={`main page-view-layout ${styles['page-view-layout']}`}>
+      <div id="main" className={`main ${styles['page-view-layout']}`}>
         <div id="content-main" className="content-main container-lg grw-container-convertible">
           { headerContents != null && headerContents }
           { sideContents != null
             ? (
-              <div className="d-flex flex-column flex-column-reverse flex-lg-row">
+              <div className="d-flex gap-3">
                 <div className="flex-grow-1 flex-basis-0 mw-0">
                   {children}
                 </div>
-                <div className="grw-side-contents-container d-edit-none" data-vrt-blackout-side-contents>
+                <div className="grw-side-contents-container col-lg-3  d-edit-none d-print-none" data-vrt-blackout-side-contents>
                   <div className="grw-side-contents-sticky-container">
                     {sideContents}
                   </div>

+ 23 - 0
apps/app/src/components/FontFamily/GlobalFonts.tsx

@@ -0,0 +1,23 @@
+import { memo } from 'react';
+
+import { useLatoFontFamily } from './use-lato';
+import { useMaterialSymbolsOutlined } from './use-material-symbols-outlined';
+import { useSourceHanCodeJP } from './use-source-han-code-jp';
+
+/**
+ * Define prefixed by '--grw-font-family'
+ */
+export const GlobalFonts = memo((): JSX.Element => {
+
+  const latoFontFamily = useLatoFontFamily();
+  const sourceHanCodeJPFontFamily = useSourceHanCodeJP();
+  const materialSymbolsOutlinedFontFamily = useMaterialSymbolsOutlined();
+
+  return (
+    <>
+      {latoFontFamily}
+      {sourceHanCodeJPFontFamily}
+      {materialSymbolsOutlinedFontFamily}
+    </>
+  );
+});

+ 1 - 0
apps/app/src/components/FontFamily/types.d.ts

@@ -0,0 +1 @@
+export type DefineStyle = () => JSX.IntrinsicElements.style;

+ 20 - 0
apps/app/src/components/FontFamily/use-lato.tsx

@@ -0,0 +1,20 @@
+import { Lato } from 'next/font/google';
+
+import { DefineStyle } from './types';
+
+const lato = Lato({
+  weight: ['400', '700'],
+  style: ['normal', 'italic'],
+  subsets: ['latin'],
+  display: 'swap',
+});
+
+export const useLatoFontFamily: DefineStyle = () => (
+  <style jsx global>
+    {`
+      :root {
+        --grw-font-family-lato: ${lato.style.fontFamily};
+      }
+    `}
+  </style>
+);

+ 18 - 0
apps/app/src/components/FontFamily/use-material-symbols-outlined.tsx

@@ -0,0 +1,18 @@
+import localFont from 'next/font/local';
+
+import { DefineStyle } from './types';
+
+const materialSymbolsOutlined = localFont({
+  src: '../../../resource/fonts/MaterialSymbolsOutlined-opsz,wght,FILL@20..48,300,0..1.woff2',
+  adjustFontFallback: false,
+});
+
+export const useMaterialSymbolsOutlined: DefineStyle = () => (
+  <style jsx global>
+    {`
+      :root {
+        --grw-font-family-material-symbols-outlined: ${materialSymbolsOutlined.style.fontFamily};
+      }
+    `}
+  </style>
+);

+ 23 - 0
apps/app/src/components/FontFamily/use-source-han-code-jp.tsx

@@ -0,0 +1,23 @@
+import localFont from 'next/font/local';
+
+import { DefineStyle } from './types';
+
+const sourceHanCodeJPSubsetMain = localFont({
+  src: '../../../resource/fonts/SourceHanCodeJP-Regular-subset-main.woff2',
+  display: 'optional',
+});
+const sourceHanCodeJPSubsetJis2 = localFont({
+  src: '../../../resource/fonts/SourceHanCodeJP-Regular-subset-jis2.woff2',
+  display: 'optional',
+});
+
+export const useSourceHanCodeJP: DefineStyle = () => (
+  <style jsx global>
+    {`
+      :root {
+        --grw-font-family-source-han-code-jp-subset-main: ${sourceHanCodeJPSubsetMain.style.fontFamily};
+        --grw-font-family-source-han-code-jp-subset-jis2: ${sourceHanCodeJPSubsetJis2.style.fontFamily};
+      }
+    `}
+  </style>
+);

+ 1 - 6
apps/app/src/components/IdenticalPathPage.module.scss

@@ -1,6 +1 @@
-@use '@growi/ui/src/styles/molecules/page_list';
-@use '~/styles/molecules/page-accessories-control';
-
-.grw-page-accessories-control :global {
-  @extend %grw-page-accessories-control;
-}
+@use '@growi/ui/scss/molecules/page_list';

+ 11 - 102
apps/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -2,7 +2,7 @@ import React, {
   FC, useRef,
 } from 'react';
 
-import type { HasObjectId } from '@growi/core';
+import type { IUser, IPage, HasObjectId } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 import { DropdownItem } from 'reactstrap';
 
@@ -40,31 +40,6 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
     }
   };
 
-  const getActionUsers = () => {
-    if (notification.targetModel === SupportedTargetModel.MODEL_USER) {
-      return notification.target.username;
-    }
-
-    const latestActionUsers = notification.actionUsers.slice(0, 3);
-    const latestUsers = latestActionUsers.map((user) => {
-      return `@${user.name}`;
-    });
-
-    let actionedUsers = '';
-    const latestUsersCount = latestUsers.length;
-    if (latestUsersCount === 1) {
-      actionedUsers = latestUsers[0];
-    }
-    else if (notification.actionUsers.length >= 4) {
-      actionedUsers = `${latestUsers.slice(0, 2).join(', ')} and ${notification.actionUsers.length - 2} others`;
-    }
-    else {
-      actionedUsers = latestUsers.join(', ');
-    }
-
-    return actionedUsers;
-  };
-
   const renderActionUserPictures = (): JSX.Element => {
     const actionUsers = notification.actionUsers;
 
@@ -84,76 +59,16 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
     );
   };
 
-  const actionUsers = getActionUsers();
-
-  const actionType: string = notification.action;
-  let actionMsg: string;
-  let actionIcon: string;
-
-  switch (actionType) {
-    case 'PAGE_LIKE':
-      actionMsg = 'liked';
-      actionIcon = 'icon-like';
-      break;
-    case 'PAGE_BOOKMARK':
-      actionMsg = 'bookmarked on';
-      actionIcon = 'icon-star';
-      break;
-    case 'PAGE_UPDATE':
-      actionMsg = 'updated on';
-      actionIcon = 'ti ti-agenda';
-      break;
-    case 'PAGE_RENAME':
-      actionMsg = 'renamed';
-      actionIcon = 'icon-action-redo';
-      break;
-    case 'PAGE_DUPLICATE':
-      actionMsg = 'duplicated';
-      actionIcon = 'icon-docs';
-      break;
-    case 'PAGE_DELETE':
-      actionMsg = 'deleted';
-      actionIcon = 'icon-trash';
-      break;
-    case 'PAGE_DELETE_COMPLETELY':
-      actionMsg = 'completely deleted';
-      actionIcon = 'icon-fire';
-      break;
-    case 'PAGE_REVERT':
-      actionMsg = 'reverted';
-      actionIcon = 'icon-action-undo';
-      break;
-    case 'PAGE_RECURSIVELY_RENAME':
-      actionMsg = 'renamed under';
-      actionIcon = 'icon-action-redo';
-      break;
-    case 'PAGE_RECURSIVELY_DELETE':
-      actionMsg = 'deleted under';
-      actionIcon = 'icon-trash';
-      break;
-    case 'PAGE_RECURSIVELY_DELETE_COMPLETELY':
-      actionMsg = 'deleted completely under';
-      actionIcon = 'icon-fire';
-      break;
-    case 'PAGE_RECURSIVELY_REVERT':
-      actionMsg = 'reverted under';
-      actionIcon = 'icon-action-undo';
-      break;
-    case 'COMMENT_CREATE':
-      actionMsg = 'commented on';
-      actionIcon = 'icon-bubble';
-      break;
-    case 'USER_REGISTRATION_APPROVAL_REQUEST':
-      actionMsg = 'requested registration approval';
-      actionIcon = 'icon-bubble';
-      break;
-    default:
-      actionMsg = '';
-      actionIcon = '';
-  }
-
   const isDropdownItem = props.type === 'dropdown-item';
 
+  const isPageNotification = (notification: IInAppNotification): notification is IInAppNotification<IPage> => {
+    return notification.targetModel === SupportedTargetModel.MODEL_PAGE;
+  };
+
+  const isUserNotification = (notification: IInAppNotification): notification is IInAppNotification<IUser> => {
+    return notification.targetModel === SupportedTargetModel.MODEL_USER;
+  };
+
   // determine tag
   const TagElem = isDropdownItem
     ? DropdownItem
@@ -171,22 +86,16 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
         >
         </span>
         {renderActionUserPictures()}
-        {notification.targetModel === SupportedTargetModel.MODEL_PAGE && (
+        {isPageNotification(notification) && (
           <PageModelNotification
             ref={notificationRef}
             notification={notification}
-            actionMsg={actionMsg}
-            actionIcon={actionIcon}
-            actionUsers={actionUsers}
           />
         )}
-        {notification.targetModel === SupportedTargetModel.MODEL_USER && (
+        {isUserNotification(notification) && (
           <UserModelNotification
             ref={notificationRef}
             notification={notification}
-            actionMsg={actionMsg}
-            actionIcon={actionIcon}
-            actionUsers={actionUsers}
           />
         )}
       </div>

+ 1 - 1
apps/app/src/components/InAppNotification/InAppNotificationList.tsx

@@ -26,7 +26,7 @@ const InAppNotificationList: FC<Props> = (props: Props) => {
     );
   }
 
-  const notifications = inAppNotificationData.docs.filter((notification) => { return notification.parsedSnapshot != null });
+  const notifications = inAppNotificationData.docs;
 
   return (
     <>

+ 47 - 0
apps/app/src/components/InAppNotification/PageNotification/ModelNotification.tsx

@@ -0,0 +1,47 @@
+import React, { FC, useImperativeHandle } from 'react';
+
+import type { HasObjectId } from '@growi/core';
+import { PagePathLabel } from '@growi/ui/dist/components';
+
+import type { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
+import type { IInAppNotification } from '~/interfaces/in-app-notification';
+
+import FormattedDistanceDate from '../../FormattedDistanceDate';
+
+type Props = {
+  notification: IInAppNotification & HasObjectId
+  actionMsg: string
+  actionIcon: string
+  actionUsers: string
+  publishOpen:() => void
+  ref: React.ForwardedRef<IInAppNotificationOpenable>
+};
+
+export const ModelNotification: FC<Props> = (props) => {
+  const {
+    notification, actionMsg, actionIcon, actionUsers, publishOpen, ref,
+  } = props;
+
+  useImperativeHandle(ref, () => ({
+    open() {
+      publishOpen();
+    },
+  }));
+
+  return (
+    <div className="p-2 overflow-hidden">
+      <div className="text-truncate">
+        <b>{actionUsers}</b>
+        {actionMsg}
+        <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />
+      </div>
+      <i className={`${actionIcon} me-2`} />
+      <FormattedDistanceDate
+        id={notification._id}
+        date={notification.createdAt}
+        isShowTooltip={false}
+        differenceForAvoidingFormat={Number.POSITIVE_INFINITY}
+      />
+    </div>
+  );
+};

+ 51 - 33
apps/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx

@@ -1,57 +1,75 @@
 import React, {
-  forwardRef, ForwardRefRenderFunction, useImperativeHandle,
+  forwardRef, ForwardRefRenderFunction, useCallback,
 } from 'react';
 
-import type { HasObjectId } from '@growi/core';
-import { PagePathLabel } from '@growi/ui/dist/components/PagePath';
+import type { IPage, HasObjectId } from '@growi/core';
 import { useRouter } from 'next/router';
 
 import type { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
+import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
+
+import { ModelNotification } from './ModelNotification';
+import { useActionMsgAndIconForPageModelNotification } from './useActionAndMsg';
 
-import FormattedDistanceDate from '../../FormattedDistanceDate';
 
 interface Props {
-  notification: IInAppNotification & HasObjectId
-  actionMsg: string
-  actionIcon: string
-  actionUsers: string
+  notification: IInAppNotification<IPage> & HasObjectId
 }
 
 const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable, Props> = (props: Props, ref) => {
 
-  const {
-    notification, actionMsg, actionIcon, actionUsers,
-  } = props;
+  const { notification } = props;
+
+  const { actionMsg, actionIcon } = useActionMsgAndIconForPageModelNotification(notification);
 
   const router = useRouter();
 
+  const getActionUsers = useCallback(() => {
+    const latestActionUsers = notification.actionUsers.slice(0, 3);
+    const latestUsers = latestActionUsers.map((user) => {
+      return `@${user.name}`;
+    });
+
+    let actionedUsers = '';
+    const latestUsersCount = latestUsers.length;
+    if (latestUsersCount === 1) {
+      actionedUsers = latestUsers[0];
+    }
+    else if (notification.actionUsers.length >= 4) {
+      actionedUsers = `${latestUsers.slice(0, 2).join(', ')} and ${notification.actionUsers.length - 2} others`;
+    }
+    else {
+      actionedUsers = latestUsers.join(', ');
+    }
+
+    return actionedUsers;
+  }, [notification.actionUsers]);
+
+  const actionUsers = getActionUsers();
+
   // publish open()
-  useImperativeHandle(ref, () => ({
-    open() {
-      if (notification.target != null) {
-        // jump to target page
-        const targetPagePath = notification.target.path;
-        if (targetPagePath != null) {
-          router.push(targetPagePath);
-        }
+  const publishOpen = () => {
+    if (notification.target != null) {
+      // jump to target page
+      const targetPagePath = notification.target.path;
+      if (targetPagePath != null) {
+        router.push(targetPagePath);
       }
-    },
-  }));
+    }
+  };
+
+  notification.parsedSnapshot = pageSerializers.parseSnapshot(notification.snapshot);
 
   return (
-    <div className="p-2 overflow-hidden">
-      <div className="text-truncate">
-        <b>{actionUsers}</b> {actionMsg} <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />
-      </div>
-      <i className={`${actionIcon} me-2`} />
-      <FormattedDistanceDate
-        id={notification._id}
-        date={notification.createdAt}
-        isShowTooltip={false}
-        differenceForAvoidingFormat={Number.POSITIVE_INFINITY}
-      />
-    </div>
+    <ModelNotification
+      notification={notification}
+      actionMsg={actionMsg}
+      actionIcon={actionIcon}
+      actionUsers={actionUsers}
+      publishOpen={publishOpen}
+      ref={ref}
+    />
   );
 };
 

+ 27 - 28
apps/app/src/components/InAppNotification/PageNotification/UserModelNotification.tsx

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

+ 99 - 0
apps/app/src/components/InAppNotification/PageNotification/useActionAndMsg.ts

@@ -0,0 +1,99 @@
+import type { IUser, IPage, HasObjectId } from '@growi/core';
+
+import { SupportedAction } from '~/interfaces/activity';
+import type { IInAppNotification } from '~/interfaces/in-app-notification';
+
+export type ActionMsgAndIconType = {
+  actionMsg: string
+  actionIcon: string
+}
+
+export const useActionMsgAndIconForPageModelNotification = (notification: IInAppNotification<IPage> & HasObjectId): ActionMsgAndIconType => {
+  const actionType: string = notification.action;
+  let actionMsg: string;
+  let actionIcon: string;
+
+  switch (actionType) {
+    case SupportedAction.ACTION_PAGE_LIKE:
+      actionMsg = 'liked';
+      actionIcon = 'icon-like';
+      break;
+    case SupportedAction.ACTION_PAGE_BOOKMARK:
+      actionMsg = 'bookmarked on';
+      actionIcon = 'icon-star';
+      break;
+    case SupportedAction.ACTION_PAGE_UPDATE:
+      actionMsg = 'updated on';
+      actionIcon = 'ti ti-agenda';
+      break;
+    case SupportedAction.ACTION_PAGE_RENAME:
+      actionMsg = 'renamed';
+      actionIcon = 'icon-action-redo';
+      break;
+    case SupportedAction.ACTION_PAGE_DUPLICATE:
+      actionMsg = 'duplicated';
+      actionIcon = 'icon-docs';
+      break;
+    case SupportedAction.ACTION_PAGE_DELETE:
+      actionMsg = 'deleted';
+      actionIcon = 'icon-trash';
+      break;
+    case SupportedAction.ACTION_PAGE_DELETE_COMPLETELY:
+      actionMsg = 'completely deleted';
+      actionIcon = 'icon-fire';
+      break;
+    case SupportedAction.ACTION_PAGE_REVERT:
+      actionMsg = 'reverted';
+      actionIcon = 'icon-action-undo';
+      break;
+    case SupportedAction.ACTION_PAGE_RECURSIVELY_RENAME:
+      actionMsg = 'renamed under';
+      actionIcon = 'icon-action-redo';
+      break;
+    case SupportedAction.ACTION_PAGE_RECURSIVELY_DELETE:
+      actionMsg = 'deleted under';
+      actionIcon = 'icon-trash';
+      break;
+    case SupportedAction.ACTION_PAGE_RECURSIVELY_DELETE_COMPLETELY:
+      actionMsg = 'deleted completely under';
+      actionIcon = 'icon-fire';
+      break;
+    case SupportedAction.ACTION_PAGE_RECURSIVELY_REVERT:
+      actionMsg = 'reverted under';
+      actionIcon = 'icon-action-undo';
+      break;
+    case SupportedAction.ACTION_COMMENT_CREATE:
+      actionMsg = 'commented on';
+      actionIcon = 'icon-bubble';
+      break;
+    default:
+      actionMsg = '';
+      actionIcon = '';
+  }
+
+  return {
+    actionMsg,
+    actionIcon,
+  };
+};
+
+export const useActionMsgAndIconForUserModelNotification = (notification: IInAppNotification<IUser> & HasObjectId): ActionMsgAndIconType => {
+  const actionType: string = notification.action;
+  let actionMsg: string;
+  let actionIcon: string;
+
+  switch (actionType) {
+    case SupportedAction.ACTION_USER_REGISTRATION_APPROVAL_REQUEST:
+      actionMsg = 'requested registration approval';
+      actionIcon = 'icon-bubble';
+      break;
+    default:
+      actionMsg = '';
+      actionIcon = '';
+  }
+
+  return {
+    actionMsg,
+    actionIcon,
+  };
+};

+ 2 - 4
apps/app/src/components/InstallerForm.tsx

@@ -99,10 +99,8 @@ const InstallerForm = memo((): JSX.Element => {
       <div className="row">
         <form role="form" id="register-form" className="col-md-12" onSubmit={submitHandler}>
           <div className="dropdown mb-3">
-            <div className="input-group">
-              <div className=" dropdown-with-icon">
-                <i className="input-group-text icon-bubbles border-0 rounded-0" />
-              </div>
+            <div className="input-group dropdown-with-icon">
+              <span className="input-group-text"><i className="icon-bubbles" /></span>
               <button
                 type="button"
                 className="btn btn-secondary dropdown-toggle form-control text-end rounded-end"

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

@@ -33,7 +33,7 @@ type Props = {
 
 export const BasicLayout = ({ children, className }: Props): JSX.Element => {
   return (
-    <RawLayout className={className ?? ''}>
+    <RawLayout className={`${className ?? ''}`}>
       <DndProvider backend={HTML5Backend}>
 
         <div className="page-wrapper flex-row">

+ 0 - 3
apps/app/src/components/Layout/PageViewLayout.module.scss

@@ -1,3 +0,0 @@
-.page-view-layout :global {
-  min-height: calc(100vh - 48px - 250px); // 100vh - subnavigation height - page-comments-row minimum height
-}

+ 0 - 31
apps/app/src/components/Navbar/DrawerToggler.tsx

@@ -1,31 +0,0 @@
-import React from 'react';
-
-import { useDrawerOpened } from '~/stores/ui';
-
-type Props = {
-  iconClass?: string,
-}
-
-const DrawerToggler = (props: Props): JSX.Element => {
-
-  const { data: isOpened, mutate } = useDrawerOpened();
-
-  const iconClass = props.iconClass ?? isOpened
-    ? 'icon-arrow-left'
-    : 'icon-arrow-right';
-
-  return (
-    <button
-      className="grw-drawer-toggler btn btn-secondary"
-      type="button"
-      aria-expanded="false"
-      aria-label="Toggle navigation"
-      onClick={() => mutate(!isOpened)}
-    >
-      <i className={iconClass}></i>
-    </button>
-  );
-
-};
-
-export default DrawerToggler;

+ 22 - 55
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -202,51 +202,19 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToChangeEditorMode } = useIsAbleToChangeEditorMode();
 
-  // TODO: implement tags for editor
-  // refs: https://redmine.weseek.co.jp/issues/132125
-  // eslint-disable-next-line max-len
-  // const { data: tagsForEditors, mutate: mutatePageTagsForEditors, sync: syncPageTagsForEditors } = usePageTagsForEditors(!isSharedPage ? currentPage?._id : undefined);
-  // const { data: templateTagData } = useTemplateTagData();
-
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId);
 
   const path = currentPage?.path ?? currentPathname;
-  const grant = currentPage?.grant ?? grantData?.grant;
-  const grantUserGroupId = currentPage?.grantedGroup?._id ?? grantData?.grantedGroup?.id;
-
-  // TODO: implement tags for editor
-  // refs: https://redmine.weseek.co.jp/issues/132125
-  // useEffect(() => {
-  //   // Run only when tagsInfoData has been updated
-  //   if (templateTagData == null) {
-  //     syncPageTagsForEditors();
-  //   }
-  //   // eslint-disable-next-line react-hooks/exhaustive-deps
-  // }, [tagsInfoData?.tags]);
-
-  // TODO: implement tags for editor
-  // refs: https://redmine.weseek.co.jp/issues/132125
-  // useEffect(() => {
-  //   if (pageId === null && templateTagData != null) {
-  //     mutatePageTagsForEditors(templateTagData);
-  //   }
-  // }, [pageId, mutatePageTagsForEditors, templateTagData, mutateSWRTagsInfo]);
+  // const grant = currentPage?.grant ?? grantData?.grant;
+  // const grantUserGroupId = currentPage?.grantedGroup?._id ?? grantData?.grantedGroup?.id;
 
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
 
   const { isLinkSharingDisabled } = props;
 
-  // TODO: implement tags for editor
-  // refs: https://redmine.weseek.co.jp/issues/132125
-  // const tagsUpdatedHandlerForEditMode = useCallback((newTags: string[]): void => {
-  //   // It will not be reflected in the DB until the page is refreshed
-  //   mutatePageTagsForEditors(newTags);
-  //   return;
-  // }, [mutatePageTagsForEditors]);
-
   const duplicateItemClickedHandler = useCallback(async(page: IPageForPageDuplicateModal) => {
     const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
       router.push(toPath);
@@ -331,35 +299,34 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     <>
       <div
         className={`${styles['grw-contextual-sub-navigation']}
-          d-flex align-items-center justify-content-end px-2 py-1 gap-2 gap-md-4
+          d-flex align-items-center justify-content-end px-2 py-1 gap-2 gap-md-4 d-print-none
         `}
         data-testid="grw-contextual-sub-nav"
       >
-        <div className="h-50">
-          {pageId != null && (
-            <PageControls
-              pageId={pageId}
-              revisionId={revisionId}
-              shareLinkId={shareLinkId}
-              path={path ?? currentPathname} // If the page is empty, "path" is undefined
-              expandContentWidth={currentPage?.expandContentWidth ?? isContainerFluid}
-              disableSeenUserInfoPopover={isSharedUser}
-              showPageControlDropdown={isAbleToShowPageManagement}
-              additionalMenuItemRenderer={additionalMenuItemsRenderer}
-              onClickDuplicateMenuItem={duplicateItemClickedHandler}
-              onClickRenameMenuItem={renameItemClickedHandler}
-              onClickDeleteMenuItem={deleteItemClickedHandler}
-              onClickSwitchContentWidth={switchContentWidthHandler}
-            />
-          )}
-        </div>
+        {pageId != null && (
+          <PageControls
+            pageId={pageId}
+            revisionId={revisionId}
+            shareLinkId={shareLinkId}
+            path={path ?? currentPathname} // If the page is empty, "path" is undefined
+            expandContentWidth={currentPage?.expandContentWidth ?? isContainerFluid}
+            disableSeenUserInfoPopover={isSharedUser}
+            showPageControlDropdown={isAbleToShowPageManagement}
+            additionalMenuItemRenderer={additionalMenuItemsRenderer}
+            onClickDuplicateMenuItem={duplicateItemClickedHandler}
+            onClickRenameMenuItem={renameItemClickedHandler}
+            onClickDeleteMenuItem={deleteItemClickedHandler}
+            onClickSwitchContentWidth={switchContentWidthHandler}
+          />
+        )}
+
         {isAbleToChangeEditorMode && (
           <PageEditorModeManager
             editorMode={editorMode}
             isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
             path={path}
-            grant={grant}
-            grantUserGroupId={grantUserGroupId}
+            // grant={grant}
+            // grantUserGroupId={grantUserGroupId}
           />
         )}
       </div>

+ 21 - 5
apps/app/src/components/Navbar/GrowiNavbarBottom.module.scss

@@ -2,15 +2,31 @@
 @use '~/styles/mixins';
 
 .grw-navbar-bottom :global {
-  height: var.$grw-navbar-bottom-height;
-
   // apply transition
   transition-property: bottom;
   @include mixins.apply-navigation-transition();
+
+  .navbar {
+    height: var.$grw-navbar-bottom-height;
+  }
+}
+
+.grw-navbar-bottom-drawer-opened {
+  bottom: #{-1 * var.$grw-navbar-bottom-height};
 }
 
-.grw-navbar-bottom {
-  &:global(.grw-navbar-bottom-drawer-opened) {
-    bottom: #{-1 * var.$grw-navbar-bottom-height};
+// centering icons
+.grw-navbar-bottom :global {
+  .nav-link {
+    display: flex;
+    align-items: center;
+  }
+}
+
+// == Colors
+.grw-navbar-bottom :global {
+  .navbar {
+    background-color: rgba(var(--bs-body-bg-rgb), 0.7);
+    backdrop-filter: blur(35px);
   }
 }

+ 29 - 17
apps/app/src/components/Navbar/GrowiNavbarBottom.tsx

@@ -3,7 +3,7 @@ import React from 'react';
 import { useIsSearchPage } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import { useCurrentPagePath } from '~/stores/page';
-import { useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
+import { useIsDeviceLargerThanMd, useDrawerOpened } from '~/stores/ui';
 
 import { GlobalSearch } from './GlobalSearch';
 
@@ -13,20 +13,19 @@ import styles from './GrowiNavbarBottom.module.scss';
 export const GrowiNavbarBottom = (): JSX.Element => {
 
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
-  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
   const { open: openCreateModal } = usePageCreateModal();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isSearchPage } = useIsSearchPage();
 
-  const additionalClasses = ['grw-navbar-bottom', styles['grw-navbar-bottom']];
-  if (isDrawerOpened) {
-    additionalClasses.push('grw-navbar-bottom-drawer-opened');
-  }
-
   return (
-    <div className="d-md-none d-edit-none fixed-bottom">
+    <div className={`
+      ${styles['grw-navbar-bottom']}
+      ${isDrawerOpened ? styles['grw-navbar-bottom-drawer-opened'] : ''}
+      d-md-none d-edit-none d-print-none fixed-bottom`}
+    >
 
-      { isDeviceSmallerThanMd && !isSearchPage && (
+      { !isDeviceLargerThanMd && !isSearchPage && (
         <div id="grw-global-search-collapse" className="grw-global-search collapse bg-dark">
           <div className="p-3">
             <GlobalSearch dropup />
@@ -34,18 +33,29 @@ export const GrowiNavbarBottom = (): JSX.Element => {
         </div>
       ) }
 
-      <div className={`navbar navbar-expand navbar-dark bg-primary px-0 ${additionalClasses.join(' ')}`}>
+      <div className="navbar navbar-expand px-4 px-sm-5">
 
-        <ul className="navbar-nav w-100">
-          <li className="nav-item me-auto">
+        <ul className="navbar-nav flex-grow-1 d-flex align-items-center justify-content-between">
+          <li className="nav-item">
             <a
               role="button"
               className="nav-link btn-lg"
               onClick={() => mutateDrawerOpened(true)}
             >
-              <i className="icon-menu"></i>
+              <span className="material-symbols-outlined fs-2">reorder</span>
             </a>
           </li>
+
+          <li className="nav-item">
+            <a
+              role="button"
+              className="nav-link btn-lg"
+              onClick={() => openCreateModal(currentPagePath || '')}
+            >
+              <span className="material-symbols-outlined fs-2">edit</span>
+            </a>
+          </li>
+
           {
             !isSearchPage && (
               <li className="nav-item">
@@ -55,20 +65,22 @@ export const GrowiNavbarBottom = (): JSX.Element => {
                   data-bs-target="#grw-global-search-collapse"
                   data-bs-toggle="collapse"
                 >
-                  <i className="icon-magnifier"></i>
+                  <span className="material-symbols-outlined fs-2">search</span>
                 </a>
               </li>
             )
           }
-          <li className="nav-item ms-auto">
+
+          <li className="nav-item">
             <a
               role="button"
               className="nav-link btn-lg"
-              onClick={() => openCreateModal(currentPagePath || '')}
+              onClick={() => {}}
             >
-              <i className="icon-pencil"></i>
+              <span className="material-symbols-outlined fs-2">notifications</span>
             </a>
           </li>
+
         </ul>
       </div>
 

+ 11 - 10
apps/app/src/components/Navbar/PageEditorModeManager.module.scss

@@ -7,11 +7,12 @@
     --bs-btn-font-size: 13px;
     --bs-btn-border-width: 2px;
 
-    width: 70px;
-    height: 30px;
-    @include bs.media-breakpoint-down(sm) {
-      width: 90px;
-      height: 38px;
+    width: 90px;
+    height: 38px;
+
+    @include bs.media-breakpoint-up(md) {
+      width: 70px;
+      height: 30px;
     }
 
     @include mixins.border-vertical('before', 70%, 1, true);
@@ -19,11 +20,11 @@
 }
 
 .grw-page-editor-mode-manager-skeleton :global {
-  width: 179px;
-  height: 30px;
-  @include bs.media-breakpoint-down(sm) {
-    width: 90px;
-    height: 38px;
+  width: 90px;
+  height: 38px;
+  @include bs.media-breakpoint-up(md) {
+    width: 179px;
+    height: 30px;
   }
 }
 

+ 10 - 8
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -1,8 +1,9 @@
 import React, { type ReactNode, useCallback, useState } from 'react';
 
+import type { IGrantedGroup } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
-import { EditorMode, useIsDeviceSmallerThanMd } from '~/stores/ui';
+import { EditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 
 import { useOnPageEditorModeButtonClicked } from './hooks';
 
@@ -46,7 +47,8 @@ type Props = {
   isBtnDisabled: boolean,
   path?: string,
   grant?: number,
-  grantUserGroupId?: string
+  // grantUserGroupId?: string
+  grantUserGroupIds?: IGrantedGroup[]
 }
 
 export const PageEditorModeManager = (props: Props): JSX.Element => {
@@ -54,16 +56,16 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
     editorMode = EditorMode.View,
     isBtnDisabled,
     path,
-    grant,
-    grantUserGroupId,
+    // grant,
+    // grantUserGroupId,
   } = props;
 
   const { t } = useTranslation();
   const [isCreating, setIsCreating] = useState(false);
 
-  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
 
-  const onPageEditorModeButtonClicked = useOnPageEditorModeButtonClicked(setIsCreating, path, grant, grantUserGroupId);
+  const onPageEditorModeButtonClicked = useOnPageEditorModeButtonClicked(setIsCreating, path);
   const _isBtnDisabled = isCreating || isBtnDisabled;
 
   const pageEditorModeButtonClickedHandler = useCallback((viewType: EditorMode) => {
@@ -82,7 +84,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
         aria-label="page-editor-mode-manager"
         id="grw-page-editor-mode-manager"
       >
-        {(!isDeviceSmallerThanMd || editorMode !== EditorMode.View) && (
+        {(isDeviceLargerThanMd || editorMode !== EditorMode.View) && (
           <PageEditorModeButton
             currentEditorMode={editorMode}
             editorMode={EditorMode.View}
@@ -92,7 +94,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
             <span className="material-symbols-outlined fs-4">play_arrow</span>{t('View')}
           </PageEditorModeButton>
         )}
-        {(!isDeviceSmallerThanMd || editorMode === EditorMode.View) && (
+        {(isDeviceLargerThanMd || editorMode === EditorMode.View) && (
           <PageEditorModeButton
             currentEditorMode={editorMode}
             editorMode={EditorMode.Editor}

+ 7 - 7
apps/app/src/components/Navbar/hooks.tsx

@@ -14,8 +14,8 @@ const logger = loggerFactory('growi:Navbar:GrowiContextualSubNavigation');
 export const useOnPageEditorModeButtonClicked = (
     setIsCreating:React.Dispatch<React.SetStateAction<boolean>>,
     path?: string,
-    grant?: number,
-    grantUserGroupId?: string,
+    // grant?: number,
+    // grantUserGroupId?: string,
 ): (editorMode: EditorMode) => Promise<void> => {
   const router = useRouter();
   const { t } = useTranslation('commons');
@@ -23,7 +23,7 @@ export const useOnPageEditorModeButtonClicked = (
   const { mutate: mutateEditorMode } = useEditorMode();
 
   return useCallback(async(editorMode: EditorMode) => {
-    if (isNotFound == null || path == null || grant == null) {
+    if (isNotFound == null || path == null) {
       return;
     }
 
@@ -34,9 +34,9 @@ export const useOnPageEditorModeButtonClicked = (
         const params = {
           isSlackEnabled: false,
           slackChannels: '',
-          grant,
-          pageTags: [],
-          grantUserGroupId,
+          grant: 4,
+          // grant,
+          // grantUserGroupId,
         };
 
         const response = await createPage(path, '', params);
@@ -54,5 +54,5 @@ export const useOnPageEditorModeButtonClicked = (
     }
 
     mutateEditorMode(editorMode);
-  }, [grant, grantUserGroupId, isNotFound, mutateEditorMode, path, router, setIsCreating, t]);
+  }, [isNotFound, mutateEditorMode, path, router, setIsCreating, t]);
 };

+ 1 - 1
apps/app/src/components/Page/PageView.tsx

@@ -18,7 +18,7 @@ import { useIsMobile } from '~/stores/ui';
 
 import type { CommentsProps } from '../Comments';
 import { PagePathNavSticky } from '../Common/PagePathNav';
-import { PageViewLayout } from '../Layout/PageViewLayout';
+import { PageViewLayout } from '../Common/PageViewLayout';
 import { PageAlerts } from '../PageAlert/PageAlerts';
 import { PageContentFooter } from '../PageContentFooter';
 import type { PageSideContentsProps } from '../PageSideContents';

+ 66 - 32
apps/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -1,6 +1,6 @@
 import React, { useEffect, useState, useCallback } from 'react';
 
-import { PageGrant } from '@growi/core';
+import { PageGrant, GroupType } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -9,7 +9,7 @@ import {
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { IPageGrantData } from '~/interfaces/page';
-import { IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
+import { ApplicableGroup, IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
 import { useCurrentUser } from '~/stores/context';
 import { useSWRxApplicableGrant, useSWRxIsGrantNormalized, useSWRxCurrentPage } from '~/stores/page';
 
@@ -29,7 +29,9 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   } = props;
 
   const [selectedGrant, setSelectedGrant] = useState<PageGrant>(PageGrant.GRANT_RESTRICTED);
-  const [selectedGroup, setSelectedGroup] = useState<{_id: string, name: string} | undefined>(undefined); // TODO: Typescriptize model
+
+  const [isGroupSelectModalShown, setIsGroupSelectModalShown] = useState(false);
+  const [selectedGroups, setSelectedGroups] = useState<ApplicableGroup[]>([]);
 
   // Alert message state
   const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
@@ -40,14 +42,23 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   useEffect(() => {
     if (isOpen) {
       setSelectedGrant(PageGrant.GRANT_RESTRICTED);
-      setSelectedGroup(undefined);
+      setSelectedGroups([]);
       setShowModalAlert(false);
     }
   }, [isOpen]);
 
+  const groupListItemClickHandler = (group: ApplicableGroup) => {
+    if (selectedGroups.find(g => g.item._id === group.item._id) != null) {
+      setSelectedGroups(selectedGroups.filter(g => g.item._id !== group.item._id));
+    }
+    else {
+      setSelectedGroups([...selectedGroups, group]);
+    }
+  };
+
   const submit = async() => {
     // Validate input values
-    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroup == null) {
+    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroups.length === 0) {
       setShowModalAlert(true);
       return;
     }
@@ -57,7 +68,9 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     try {
       await apiv3Put(`/page/${pageId}/grant`, {
         grant: selectedGrant,
-        grantedGroup: selectedGroup?._id,
+        grantedGroups: selectedGroups.length !== 0 ? selectedGroups.map((g) => {
+          return { item: g.item._id, type: g.type };
+        }) : null,
       });
 
       toastSuccess(t('Successfully updated'));
@@ -86,10 +99,10 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     }
 
     if (grantData.grant === 5) {
-      if (grantData.grantedGroup == null) {
+      if (grantData.grantedGroups == null || grantData.grantedGroups.length === 0) {
         return t('fix_page_grant.modal.grant_label.isForbidden');
       }
-      return `${t('fix_page_grant.modal.radio_btn.grant_group')}: (${grantData.grantedGroup.name})`;
+      return `${t('fix_page_grant.modal.radio_btn.grant_group')} (${grantData.grantedGroups.map(g => g.name).join(', ')})`;
     }
 
     throw Error('cannot get grant label'); // this error can't be throwed
@@ -177,32 +190,18 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
                 <div className="dropdown ms-2">
                   <button
                     type="button"
-                    className="btn btn-secondary dropdown-toggle text-end w-100 border-0 shadow-none"
-                    data-bs-toggle="dropdown"
+                    className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
                     disabled={selectedGrant !== PageGrant.GRANT_USER_GROUP} // disable when its radio input is not selected
+                    onClick={() => setIsGroupSelectModalShown(true)}
                   >
                     <span className="float-start ms-2">
                       {
-                        selectedGroup == null
+                        selectedGroups.length === 0
                           ? t('fix_page_grant.modal.select_group_default_text')
-                          : selectedGroup.name
+                          : selectedGroups.map(g => g.item.name).join(', ')
                       }
                     </span>
                   </button>
-                  <div className="dropdown-menu">
-                    {
-                      applicableGroups != null && applicableGroups.map(g => (
-                        <button
-                          key={g._id}
-                          className="dropdown-item"
-                          type="button"
-                          onClick={() => setSelectedGroup(g)}
-                        >
-                          {g.name}
-                        </button>
-                      ))
-                    }
-                  </div>
                 </div>
               </div>
               {
@@ -225,12 +224,47 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   };
 
   return (
-    <Modal size="lg" isOpen={isOpen} toggle={close}>
-      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
-        { t('fix_page_grant.modal.title') }
-      </ModalHeader>
-      {renderModalBodyAndFooter()}
-    </Modal>
+    <>
+      <Modal size="lg" isOpen={isOpen} toggle={close}>
+        <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+          { t('fix_page_grant.modal.title') }
+        </ModalHeader>
+        {renderModalBodyAndFooter()}
+      </Modal>
+      {applicableGroups != null && (
+        <Modal
+          isOpen={isGroupSelectModalShown}
+          toggle={() => setIsGroupSelectModalShown(false)}
+        >
+          <ModalHeader tag="h4" toggle={() => setIsGroupSelectModalShown(false)} className="bg-purple text-light">
+            {t('user_group.select_group')}
+          </ModalHeader>
+          <ModalBody>
+            <>
+              { applicableGroups.map((group) => {
+                const groupIsGranted = selectedGroups?.find(g => g.item._id === group.item._id) != null;
+                const activeClass = groupIsGranted ? 'active' : '';
+
+                return (
+                  <button
+                    className={`btn btn-outline-primary w-100 d-flex justify-content-start mb-3 align-items-center p-3 ${activeClass}`}
+                    type="button"
+                    key={group.item._id}
+                    onClick={() => groupListItemClickHandler(group)}
+                  >
+                    <span className="align-middle"><input type="checkbox" checked={groupIsGranted} /></span>
+                    <h5 className="d-inline-block ml-3">{group.item.name}</h5>
+                    {group.type === GroupType.externalUserGroup && <span className="ml-2 badge badge-pill badge-info">{group.item.provider}</span>}
+                    {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
+                  </button>
+                );
+              }) }
+              <button type="button" className="btn btn-primary mt-2 float-right" onClick={() => setIsGroupSelectModalShown(false)}>{t('Done')}</button>
+            </>
+          </ModalBody>
+        </Modal>
+      )}
+    </>
   );
 };
 

+ 10 - 1
apps/app/src/components/PageAlert/PageGrantAlert.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 
+import { isPopulated } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 
 import { useSWRxCurrentPage } from '~/stores/page';
@@ -13,6 +14,10 @@ export const PageGrantAlert = (): JSX.Element => {
     return <></>;
   }
 
+  const populatedGrantedGroups = () => {
+    return pageData.grantedGroups.filter(group => isPopulated(group.item));
+  };
+
   const renderAlertContent = () => {
     const getGrantLabel = () => {
       if (pageData.grant === 2) {
@@ -32,7 +37,11 @@ export const PageGrantAlert = (): JSX.Element => {
       if (pageData.grant === 5) {
         return (
           <>
-            <i className="icon-fw icon-organization"></i><strong>{pageData.grantedGroup.name}</strong>
+            <i className="icon-fw icon-organization"></i>
+            <strong>{
+              populatedGrantedGroups().map(g => g.item.name).join(', ')
+            }
+            </strong>
           </>
         );
       }

+ 10 - 13
apps/app/src/components/PageComment/Comment.tsx

@@ -1,6 +1,6 @@
 import React, { useEffect, useMemo, useState } from 'react';
 
-import type { IUser } from '@growi/core';
+import { isPopulated, type IUser } from '@growi/core';
 import * as pathUtils from '@growi/core/dist/utils/path-utils';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format, parseISO } from 'date-fns';
@@ -51,8 +51,7 @@ export const Comment = (props: CommentProps): JSX.Element => {
   const [isReEdit, setIsReEdit] = useState(false);
 
   const commentId = comment._id;
-  const creator = comment.creator;
-  const isMarkdown = comment.isMarkdown;
+  const creator = isPopulated(comment.creator) ? comment.creator : undefined;
   const createdAt = new Date(comment.createdAt);
   const updatedAt = new Date(comment.updatedAt);
   const isEdited = createdAt < updatedAt;
@@ -122,16 +121,14 @@ export const Comment = (props: CommentProps): JSX.Element => {
       return <></>;
     }
 
-    return isMarkdown
-      ? (
-        <RevisionRenderer
-          rendererOptions={rendererOptions}
-          markdown={markdown}
-          additionalClassName="comment"
-        />
-      )
-      : renderText(comment.comment);
-  }, [comment, isMarkdown, markdown, rendererOptions]);
+    return (
+      <RevisionRenderer
+        rendererOptions={rendererOptions}
+        markdown={markdown}
+        additionalClassName="comment"
+      />
+    );
+  }, [markdown, rendererOptions]);
 
   const rootClassName = getRootClassName(comment);
   const revHref = `?revisionId=${comment.revision}`;

+ 11 - 5
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -2,6 +2,7 @@ import React, {
   useCallback, useState, useRef, useEffect,
 } from 'react';
 
+import { useResolvedThemeForEditor } from '@growi/editor';
 import { UserPicture } from '@growi/ui/dist/components';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
@@ -15,10 +16,11 @@ import { IEditorMethods } from '~/interfaces/editor-methods';
 import { useSWRxPageComment, useSWRxEditingCommentsNum } from '~/stores/comment';
 import {
   useCurrentUser, useIsSlackConfigured,
-  useIsUploadableFile, useIsUploadableImage,
+  useIsUploadAllFileAllowed, useIsUploadEnabled,
 } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled, useIsEnabledUnsavedWarning } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
+import { useNextThemes } from '~/stores/use-next-themes';
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import { NotAvailableForGuest } from '../NotAvailableForGuest';
@@ -69,13 +71,17 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackConfigured } = useIsSlackConfigured();
-  const { data: isUploadableFile } = useIsUploadableFile();
-  const { data: isUploadableImage } = useIsUploadableImage();
+  const { data: isUploadAllFileAllowed } = useIsUploadAllFileAllowed();
+  const { data: isUploadEnabled } = useIsUploadEnabled();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const {
     increment: incrementEditingCommentsNum,
     decrement: decrementEditingCommentsNum,
   } = useSWRxEditingCommentsNum();
+  const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
+
+  const { resolvedTheme } = useNextThemes();
+  mutateResolvedTheme(resolvedTheme);
 
   const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment);
   const [comment, setComment] = useState(commentBody ?? '');
@@ -297,7 +303,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
       </Button>
     );
 
-    const isUploadable = isUploadableImage || isUploadableFile;
+    const isUploadable = isUploadEnabled || isUploadAllFileAllowed;
 
     return (
       <>
@@ -309,7 +315,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
                 ref={editorRef}
                 value={commentBody ?? ''} // DO NOT use state
                 isUploadable={isUploadable}
-                isUploadableFile={isUploadableFile}
+                isUploadAllFileAllowed={isUploadAllFileAllowed}
                 onChange={onChangeHandler}
                 onUpload={uploadHandler}
                 onCtrlEnter={ctrlEnterHandler}

+ 4 - 1
apps/app/src/components/PageComment/DeleteCommentModal.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 
+import { isPopulated } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format } from 'date-fns';
 import {
@@ -47,6 +48,8 @@ export const DeleteCommentModal = (props: DeleteCommentModalProps): JSX.Element
 
     const commentDate = format(new Date(comment.createdAt), 'yyyy/MM/dd HH:mm');
 
+    const creator = isPopulated(comment.creator) ? comment.creator : undefined;
+
     let commentBody = comment.comment;
     if (commentBody.length > OMIT_BODY_THRES) { // omit
       commentBody = `${commentBody.substr(0, OMIT_BODY_THRES)}...`;
@@ -55,7 +58,7 @@ export const DeleteCommentModal = (props: DeleteCommentModalProps): JSX.Element
 
     return (
       <>
-        <UserPicture user={comment.creator} size="xs" /> <strong><Username user={comment.creator}></Username></strong> wrote on {commentDate}:
+        <UserPicture user={creator} size="xs" /> <strong><Username user={creator}></Username></strong> wrote on {commentDate}:
         <p className="card custom-card comment-body mt-2 p-2">{commentBodyElement}</p>
       </>
     );

+ 3 - 1
apps/app/src/components/PageControls/BookmarkButtons.module.scss

@@ -1,5 +1,7 @@
 @use '@growi/core/scss/bootstrap/init' as bs;
 
+@use '@growi/ui/scss/atoms/btn-muted';
+
 @use './button-styles';
 
 .btn-group-bookmark :global {
@@ -18,7 +20,7 @@
 // == Colors
 .btn-group-bookmark :global {
   .btn-bookmark {
-    @include button-styles.btn-color(bs.$orange);
+    @include btn-muted.colorize(bs.$orange);
   }
 }
 

+ 3 - 1
apps/app/src/components/PageControls/LikeButtons.module.scss

@@ -1,5 +1,7 @@
 @use '@growi/core/scss/bootstrap/init' as bs;
 
+@use '@growi/ui/scss/atoms/btn-muted';
+
 @use './button-styles';
 
 .btn-group-like :global {
@@ -18,6 +20,6 @@
 // == Colors
 .btn-group-like :global {
   .btn-like {
-    @include button-styles.btn-color(bs.$red);
+    @include btn-muted.colorize(bs.$red);
   }
 }

+ 3 - 1
apps/app/src/components/PageControls/PageControls.module.scss

@@ -1,5 +1,7 @@
 @use '@growi/core/scss/bootstrap/init' as bs;
 
+@use '@growi/ui/scss/atoms/btn-muted';
+
 @use './button-styles';
 
 // PageItemControl styles
@@ -13,6 +15,6 @@
 // PageItemControl colors
 .grw-page-controls :global {
   .btn-page-item-control {
-    @include button-styles.btn-color(bs.$gray-500);
+    @include btn-muted.colorize(bs.$gray-500);
   }
 }

+ 44 - 4
apps/app/src/components/PageControls/PageControls.tsx

@@ -14,9 +14,10 @@ import {
 } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
-import type { IPageForPageDuplicateModal } from '~/stores/modal';
+import { useTagEditModal, type IPageForPageDuplicateModal } from '~/stores/modal';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
-import { useSWRxPageInfo } from '../../stores/page';
+import { useSWRxPageInfo, useSWRxTagsInfo } from '../../stores/page';
 import { useSWRxUsersList } from '../../stores/user';
 import {
   AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType,
@@ -31,6 +32,26 @@ import SubscribeButton from './SubscribeButton';
 
 import styles from './PageControls.module.scss';
 
+type TagsProps = {
+  onClickEditTagsButton: () => void,
+}
+
+const Tags = (props: TagsProps): JSX.Element => {
+  const { onClickEditTagsButton } = props;
+
+  return (
+    <div className="grw-taglabels-container d-flex align-items-center">
+      <button
+        type="button"
+        className="btn btn-link btn-edit-tags text-muted border border-secondary p-1 d-flex align-items-center"
+        onClick={onClickEditTagsButton}
+      >
+        <i className="icon-tag me-2" />
+        Tags
+      </button>
+    </div>
+  );
+};
 
 type WideViewMenuItemProps = AdditionalMenuItemsRendererProps & {
   onClickMenuItem: (newValue: boolean) => void,
@@ -84,6 +105,7 @@ type PageControlsSubstanceProps = CommonProps & {
   path?: string | null,
   pageInfo: IPageInfoForOperation,
   expandContentWidth?: boolean,
+  onClickEditTagsButton: () => void,
 }
 
 const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element => {
@@ -91,11 +113,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     pageInfo,
     pageId, revisionId, path, shareLinkId, expandContentWidth,
     disableSeenUserInfoPopover, showPageControlDropdown, forceHideMenuItems, additionalMenuItemRenderer,
-    onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickSwitchContentWidth,
+    onClickEditTagsButton, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickSwitchContentWidth,
   } = props;
 
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
+  const { data: editorMode } = useEditorMode();
 
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
@@ -214,8 +237,15 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     MenuItemType.REVERT,
   ];
 
+  const isViewMode = editorMode === EditorMode.View;
+
   return (
     <div className={`grw-page-controls ${styles['grw-page-controls']} d-flex`} style={{ gap: '2px' }}>
+      {revisionId != null && !isViewMode && (
+        <Tags
+          onClickEditTagsButton={onClickEditTagsButton}
+        />
+      )}
       {revisionId != null && (
         <SubscribeButton
           status={pageInfo.subscriptionStatus}
@@ -266,7 +296,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 type PageControlsProps = CommonProps & {
   pageId: string,
   shareLinkId?: string | null,
-  revisionId?: string | null,
+  revisionId?: string,
   path?: string | null,
   expandContentWidth?: boolean,
 };
@@ -278,6 +308,15 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
   } = props;
 
   const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
+  const { data: tagsInfoData } = useSWRxTagsInfo(pageId);
+  const { open: openTagEditModal } = useTagEditModal();
+
+  const onClickEditTagsButton = useCallback(() => {
+    if (tagsInfoData == null || revisionId == null) {
+      return;
+    }
+    openTagEditModal(tagsInfoData.tags, pageId, revisionId);
+  }, [pageId, revisionId, tagsInfoData, openTagEditModal]);
 
   if (error != null) {
     return <></>;
@@ -294,6 +333,7 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
       pageId={pageId}
       revisionId={revisionId ?? null}
       path={path}
+      onClickEditTagsButton={onClickEditTagsButton}
       onClickDuplicateMenuItem={onClickDuplicateMenuItem}
       onClickRenameMenuItem={onClickRenameMenuItem}
       onClickDeleteMenuItem={onClickDeleteMenuItem}

+ 3 - 1
apps/app/src/components/PageControls/SeenUserInfo.module.scss

@@ -1,5 +1,7 @@
 @use '@growi/core/scss/bootstrap/init' as bs;
 
+@use '@growi/ui/scss/atoms/btn-muted';
+
 @use './button-styles';
 
 .grw-seen-user-info :global {
@@ -18,6 +20,6 @@
   $color: #549c79;
 
   .btn-seen-user {
-    @include button-styles.btn-color($color);
+    @include btn-muted.colorize($color);
   }
 }

+ 3 - 1
apps/app/src/components/PageControls/SubscribeButton.module.scss

@@ -1,5 +1,7 @@
 @use '@growi/core/scss/bootstrap/init' as bs;
 
+@use '@growi/ui/scss/atoms/btn-muted';
+
 @use './button-styles';
 
 .btn-subscribe :global {
@@ -12,5 +14,5 @@
 
 // == Colors
 .btn-subscribe {
-  @include button-styles.btn-color(bs.$success);
+  @include btn-muted.colorize(bs.$success);
 }

+ 0 - 17
apps/app/src/components/PageControls/_button-styles.scss

@@ -15,20 +15,3 @@
 %text-total-counts-basis {
   font-size: 13px;
 }
-
-@mixin btn-color($color) {
-  $color-rgb: #{bs.to-rgb($color)};
-
-  --bs-btn-color: var(--bs-tertiary-color);
-  --bs-btn-bg: transparent;
-
-  --bs-btn-hover-color: #{$color};
-  --bs-btn-hover-bg: rgba(#{$color-rgb}, 0.2);
-
-  --bs-btn-active-color: #{$color};
-  --bs-btn-active-bg: transparent;
-
-  &:hover {
-    --bs-btn-active-bg: rgba(#{$color-rgb}, 0.2);
-  }
-}

+ 9 - 9
apps/app/src/components/PageDeleteModal.tsx

@@ -32,12 +32,12 @@ const logger = loggerFactory('growi:cli:PageDeleteModal');
 const deleteIconAndKey = {
   completely: {
     color: 'danger',
-    icon: 'fire',
+    icon: 'delete_forever',
     translationKey: 'completely',
   },
   temporary: {
-    color: 'primary',
-    icon: 'trash',
+    color: 'warning',
+    icon: 'delete',
     translationKey: 'page',
   },
 };
@@ -245,10 +245,10 @@ const PageDeleteModal: FC = () => {
     }
 
     return (
-      <>
-        <i className={`icon-fw icon-${deleteIconAndKey[deleteMode].icon}`}></i>
-        { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
-      </>
+      <span className={`text-${deleteIconAndKey[deleteMode].color} d-flex align-items-center`}>
+        <span className="material-symbols-outlined me-1">{deleteIconAndKey[deleteMode].icon}</span>
+        <b>{ t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }</b>
+      </span>
     );
   };
 
@@ -280,7 +280,7 @@ const PageDeleteModal: FC = () => {
         <ApiErrorMessageList errs={errs} />
         <button
           type="button"
-          className={`btn btn-${deleteIconAndKey[deleteMode].color}`}
+          className={`btn btn-outline-${deleteIconAndKey[deleteMode].color}`}
           disabled={!isDeletable}
           onClick={deleteButtonHandler}
           data-testid="delete-page-button"
@@ -294,7 +294,7 @@ const PageDeleteModal: FC = () => {
 
   return (
     <Modal size="lg" isOpen={isOpened} toggle={closeDeleteModal} data-testid="page-delete-modal">
-      <ModalHeader tag="h4" toggle={closeDeleteModal} className={`bg-${deleteIconAndKey[deleteMode].color} text-light`}>
+      <ModalHeader toggle={closeDeleteModal}>
         {headerContent()}
       </ModalHeader>
       <ModalBody>

+ 0 - 106
apps/app/src/components/PageEditor/Editor.module.scss

@@ -4,112 +4,6 @@
 
 
 .editor-container :global {
-  // overlay in .editor-container
-  .overlay {
-    position: absolute;
-    top: 0;
-    right: 0;
-    bottom: 0;
-    left: 0;
-    z-index: 7; // forward than .CodeMirror-vscrollbar
-    display: flex;
-    align-items: center;
-    justify-content: center;
-  }
-
-  // loading keymap
-  @include ms.overlay-processing-style(overlay-loading-keymap, 2.5em, 0.3em);
-
-  // for Dropzone
-  .dropzone {
-    position: relative; // against .overlay position: absolute
-
-    @include ms.overlay-processing-style(overlay-dropzone-active, 2.5em, 0.5em);
-
-    // unuploadable or rejected
-    &.dropzone-unuploadable,
-    &.dropzone-rejected {
-      .overlay.overlay-dropzone-active {
-        background: rgba(200, 200, 200, 0.8);
-
-        .overlay-content {
-          color: bs.$gray-300;
-        }
-      }
-    }
-
-    // uploading
-    &.dropzone-uploading {
-      @include ms.overlay-processing-style(overlay-dropzone-active, 2.5em, 0.5em);
-    }
-
-    // unuploadable
-    &.dropzone-unuploadable {
-      .overlay.overlay-dropzone-active {
-        .overlay-content {
-          // insert content
-          @include ms.insertSimpleLineIcons('\e617'); // icon-exclamation
-
-          &:after {
-            content: 'File uploading is disabled';
-          }
-        }
-      }
-    }
-
-    // uploadable
-    &.dropzone-uploadable {
-      // accepted
-      &.dropzone-accepted:not(.dropzone-rejected) {
-        .overlay.overlay-dropzone-active {
-          border: 4px dashed bs.$gray-300;
-
-          .overlay-content {
-            // insert content
-            @include ms.insertSimpleLineIcons('\e084'); // icon-cloud-upload
-
-            &:after {
-              content: 'Drop here to upload';
-            }
-
-            // style
-            color: bs.$secondary;
-            background: rgba(200, 200, 200, 0.8);
-          }
-        }
-      }
-
-      // file type mismatch
-      &.dropzone-rejected:not(.dropzone-uploadablefile) {
-        .overlay.overlay-dropzone-active {
-          .overlay-content {
-            // insert content
-            @include ms.insertSimpleLineIcons('\e032'); // icon-picture
-
-            &:after {
-              content: 'Only an image file is allowed';
-            }
-          }
-        }
-      }
-
-      // multiple files
-      &.dropzone-accepted.dropzone-rejected {
-        .overlay.overlay-dropzone-active {
-          .overlay-content {
-            // insert content
-            @include ms.insertSimpleLineIcons('\e617'); // icon-exclamation
-
-            &:after {
-              content: 'Only 1 file is allowed';
-            }
-          }
-        }
-      }
-    }
-
-    /* end of.dropzone */
-  }
 
   .btn.btn-open-dropzone {
     z-index: 2;

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

@@ -32,7 +32,7 @@ export type EditorPropsType = {
   isGfmMode?: boolean,
   noCdn?: boolean,
   isUploadable?: boolean,
-  isUploadableFile?: boolean,
+  isUploadAllFileAllowed?: boolean,
   onChange?: (newValue: string, isClean?: boolean) => void,
   onUpload?: (file) => void,
   editorSettings?: IEditorSettings,
@@ -54,7 +54,7 @@ type DropzoneRef = {
 
 const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props, ref): JSX.Element => {
   const {
-    onUpload, isUploadable, isUploadableFile, indentSize, isGfmMode = true,
+    onUpload, isUploadable, isUploadAllFileAllowed, indentSize, isGfmMode = true,
   } = props;
 
   const [dropzoneActive, setDropzoneActive] = useState(false);
@@ -121,7 +121,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
   const getAcceptableType = useCallback(() => {
     let accept = 'null'; // reject all
     if (isUploadable) {
-      if (!isUploadableFile) {
+      if (!isUploadAllFileAllowed) {
         accept = 'image/*'; // image only
       }
       else {
@@ -130,7 +130,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
     }
 
     return accept;
-  }, [isUploadable, isUploadableFile]);
+  }, [isUploadable, isUploadAllFileAllowed]);
 
   const pasteFilesHandler = useCallback((event) => {
     const items = event.clipboardData.items || event.clipboardData.files || [];
@@ -191,7 +191,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
     else {
       className += ' dropzone-uploadable';
 
-      if (isUploadableFile) {
+      if (isUploadAllFileAllowed) {
         className += ' dropzone-uploadablefile';
       }
     }
@@ -210,7 +210,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
     }
 
     return className;
-  }, [isUploadable, isUploading, isUploadableFile]);
+  }, [isUploadable, isUploading, isUploadAllFileAllowed]);
 
   const renderDropzoneOverlay = useCallback(() => {
     return (

+ 7 - 18
apps/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -9,7 +9,7 @@ import { useIsSlackConfigured } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
 import {
-  useDrawerOpened, useEditorMode, useIsDeviceSmallerThanMd,
+  useDrawerOpened, useEditorMode, useIsDeviceLargerThanLg, useIsDeviceLargerThanMd,
 } from '~/stores/ui';
 
 
@@ -31,8 +31,8 @@ const EditorNavbarBottom = (): JSX.Element => {
 
   const { data: editorMode } = useEditorMode();
   const { data: isSlackConfigured } = useIsSlackConfigured();
-  const { mutate: mutateDrawerOpened } = useDrawerOpened();
-  const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
+  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
+  const { data: isDeviceLargerThanLg } = useIsDeviceLargerThanLg();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
 
@@ -58,16 +58,6 @@ const EditorNavbarBottom = (): JSX.Element => {
   }, []);
 
 
-  const renderDrawerButton = () => (
-    <button
-      type="button"
-      className="btn btn-outline-secondary border-0"
-      onClick={() => mutateDrawerOpened(true)}
-    >
-      <i className="icon-menu"></i>
-    </button>
-  );
-
   const renderExpandButton = () => (
     <div className="d-md-none ms-2">
       <button
@@ -80,13 +70,13 @@ const EditorNavbarBottom = (): JSX.Element => {
     </div>
   );
 
-  const isCollapsedOptionsSelectorEnabled = isDeviceSmallerThanMd;
+  const isCollapsedOptionsSelectorEnabled = !isDeviceLargerThanLg;
 
   return (
     <div className={`${isCollapsedOptionsSelectorEnabled ? 'fixed-bottom' : ''} `}>
       {/* Collapsed SlackNotification */}
       {isSlackConfigured && (
-        <Collapse isOpen={isSlackExpanded && isDeviceSmallerThanMd === true}>
+        <Collapse isOpen={isSlackExpanded && !isDeviceLargerThanLg}>
           <nav className={`navbar navbar-expand-lg border-top ${moduleClass}`}>
             {isSlackEnabled != null
             && (
@@ -105,13 +95,12 @@ const EditorNavbarBottom = (): JSX.Element => {
       }
       <div className={`flex-expand-horiz align-items-center border-top px-2 px-md-3 ${moduleClass}`}>
         <form>
-          { isDeviceSmallerThanMd && renderDrawerButton() }
-          { !isDeviceSmallerThanMd && <OptionsSelector /> }
+          { isDeviceLargerThanMd && <OptionsSelector /> }
         </form>
         <form className="flex-nowrap ms-auto">
           {/* Responsive Design for the SlackNotification */}
           {/* Button or the normal Slack banner */}
-          {isSlackConfigured && (isDeviceSmallerThanMd ? (
+          {isSlackConfigured && (!isDeviceLargerThanMd ? (
             <Button
               className="grw-btn-slack border me-2"
               onClick={() => (setSlackExpanded(!isSlackExpanded))}

+ 24 - 26
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -8,7 +8,8 @@ import nodePath from 'path';
 import type { IPageHasId } from '@growi/core';
 import { pathUtils } from '@growi/core/dist/utils';
 import {
-  CodeMirrorEditorMain, GlobalCodeMirrorEditorKey, useCodeMirrorEditorIsolated, AcceptedUploadFileType,
+  CodeMirrorEditorMain, GlobalCodeMirrorEditorKey, AcceptedUploadFileType,
+  useCodeMirrorEditorIsolated, useResolvedThemeForEditor,
 } from '@growi/editor';
 import detectIndent from 'detect-indent';
 import { useTranslation } from 'next-i18next';
@@ -23,7 +24,7 @@ import { SocketEventName } from '~/interfaces/websocket';
 import {
   useDefaultIndentSize, useCurrentUser,
   useCurrentPathname, useIsEnabledAttachTitleHeader,
-  useIsEditable, useIsUploadableFile, useIsUploadableImage, useIsIndentSizeForced,
+  useIsEditable, useIsUploadAllFileAllowed, useIsUploadEnabled, useIsIndentSizeForced,
 } from '~/stores/context';
 import {
   useCurrentIndentSize, useIsSlackEnabled, usePageTagsForEditors,
@@ -48,6 +49,7 @@ import {
   EditorMode,
   useEditorMode, useSelectedGrant,
 } from '~/stores/ui';
+import { useNextThemes } from '~/stores/use-next-themes';
 import { useGlobalSocket } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 
@@ -96,7 +98,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: currentPage } = useSWRxCurrentPage();
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { data: grantData } = useSelectedGrant();
-  const { data: pageTags, sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);
+  const { sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { data: editingMarkdown, mutate: mutateEditingMarkdown } = useEditingMarkdown();
   const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
@@ -108,8 +110,8 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { data: defaultIndentSize } = useDefaultIndentSize();
-  const { data: isUploadableFile } = useIsUploadableFile();
-  const { data: isUploadableImage } = useIsUploadableImage();
+  const { data: isUploadAllFileAllowed } = useIsUploadAllFileAllowed();
+  const { data: isUploadEnabled } = useIsUploadEnabled();
   const { data: conflictDiffModalStatus, close: closeConflictDiffModal } = useConflictDiffModal();
   const { mutate: mutateIsLatestRevision } = useIsLatestRevision();
   const { mutate: mutateRemotePageId } = useRemoteRevisionId();
@@ -124,9 +126,13 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { mutate: mutateIsConflict } = useIsConflict();
 
+  const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
+
   const saveOrUpdate = useSaveOrUpdate();
   const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
 
+  const { resolvedTheme } = useNextThemes();
+  mutateResolvedTheme(resolvedTheme);
 
   // TODO: remove workaround
   // for https://redmine.weseek.co.jp/issues/125923
@@ -210,16 +216,18 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     if (grantData == null) {
       return;
     }
+    const grantedGroups = grantData.grantedGroups?.map((group) => {
+      return { item: group.id, type: group.type };
+    });
     const optionsToSave = {
       isSlackEnabled: isSlackEnabled ?? false,
       slackChannels: '', // set in save method by opts in SavePageControlls.tsx
       grant: grantData.grant,
-      pageTags: pageTags ?? [],
-      grantUserGroupId: grantData.grantedGroup?.id,
-      grantUserGroupName: grantData.grantedGroup?.name,
+      // pageTags: pageTags ?? [],
+      grantUserGroupIds: grantedGroups,
     };
     return optionsToSave;
-  }, [grantData, isSlackEnabled, pageTags]);
+  }, [grantData, isSlackEnabled]);
 
 
   const save = useCallback(async(opts?: {slackChannels: string, overwriteScopesOfDescendants?: boolean}): Promise<IPageHasId | null> => {
@@ -338,16 +346,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
         // refs: https://redmine.weseek.co.jp/issues/126528
         // editorRef.current.insertText(insertText);
         codeMirrorEditor?.insertText(insertText);
-
-        // when if created newly
-        // Not using 'mutateGrant' to inherit the grant of the parent page
-        if (resAdd.pageCreated) {
-          logger.info('Page is created', resAdd.page._id);
-          mutateIsLatestRevision(true);
-          setCreatedPageRevisionIdWithAttachment(resAdd.page.revision);
-          await mutateCurrentPageId(resAdd.page._id);
-          await mutateCurrentPage();
-        }
       }
       catch (e) {
         logger.error('failed to upload', e);
@@ -360,17 +358,17 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
       }
     });
 
-  }, [codeMirrorEditor, currentPagePath, mutateCurrentPage, mutateCurrentPageId, mutateIsLatestRevision, pageId]);
+  }, [codeMirrorEditor, currentPagePath, pageId]);
 
   const acceptedFileType = useMemo(() => {
-    if (!isUploadableFile) {
+    if (!isUploadEnabled) {
       return AcceptedUploadFileType.NONE;
     }
-    if (isUploadableImage) {
-      return AcceptedUploadFileType.IMAGE;
+    if (isUploadAllFileAllowed) {
+      return AcceptedUploadFileType.ALL;
     }
-    return AcceptedUploadFileType.ALL;
-  }, [isUploadableFile, isUploadableImage]);
+    return AcceptedUploadFileType.IMAGE;
+  }, [isUploadAllFileAllowed, isUploadEnabled]);
 
   const scrollPreviewByEditorLine = useCallback((line: number) => {
     if (previewRef.current == null) {
@@ -570,7 +568,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             ref={editorRef}
             value={initialValue}
             isUploadable={isUploadable}
-            isUploadableFile={isUploadableFile}
+            isUploadAllFileAllowed={isUploadAllFileAllowed}
             indentSize={currentIndentSize}
             onScroll={editorScrolledHandler}
             onScrollCursorIntoView={editorScrollCursorIntoViewHandler}

+ 1 - 1
apps/app/src/components/PageList/PageList.module.scss

@@ -1 +1 @@
-@use '@growi/ui/src/styles/molecules/page_list';
+@use '@growi/ui/scss/molecules/page_list';

+ 7 - 8
apps/app/src/components/PageList/PageListItemL.tsx

@@ -9,8 +9,7 @@ import type {
 import { isIPageInfoForListing, isIPageInfoForEntity } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pathUtils } from '@growi/core/dist/utils';
-import { UserPicture } from '@growi/ui/dist/components';
-import { PageListMeta } from '@growi/ui/dist/components/PagePath';
+import { UserPicture, PageListMeta } from '@growi/ui/dist/components';
 import { format } from 'date-fns';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
@@ -29,7 +28,7 @@ import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import {
   usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
 } from '~/stores/modal';
-import { useIsDeviceSmallerThanLg } from '~/stores/ui';
+import { useIsDeviceLargerThanLg } from '~/stores/ui';
 
 import { useSWRMUTxPageInfo, useSWRxPageInfo } from '../../stores/page';
 import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItemControl';
@@ -83,7 +82,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     },
   }));
 
-  const { data: isDeviceSmallerThanLg } = useIsDeviceSmallerThanLg();
+  const { data: isDeviceLargerThanLg } = useIsDeviceLargerThanLg();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
@@ -117,14 +116,14 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   // click event handler
   const clickHandler = useCallback(() => {
     // do nothing if mobile
-    if (isDeviceSmallerThanLg) {
+    if (!isDeviceLargerThanLg) {
       return;
     }
 
     if (onClickItem != null) {
       onClickItem(pageData._id);
     }
-  }, [isDeviceSmallerThanLg, onClickItem, pageData._id]);
+  }, [isDeviceLargerThanLg, onClickItem, pageData._id]);
 
   const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
     const bookmarkOperation = _newValue ? bookmark : unbookmark;
@@ -174,9 +173,9 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
     openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
   }, [onPagePutBacked, openPutBackPageModal, pageData]);
 
-  const styleListGroupItem = (!isDeviceSmallerThanLg && onClickItem != null) ? 'list-group-item-action' : '';
+  const styleListGroupItem = (isDeviceLargerThanLg && onClickItem != null) ? 'list-group-item-action' : '';
   // background color of list item changes when class "active" exists under 'list-group-item'
-  const styleActive = !isDeviceSmallerThanLg && isSelected ? 'active' : '';
+  const styleActive = isDeviceLargerThanLg && isSelected ? 'active' : '';
 
   const shouldDangerouslySetInnerHTMLForPaths = elasticSearchResult != null && elasticSearchResult.highlightedPath != null;
 

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

@@ -1,8 +1,7 @@
 import React from 'react';
 
 import type { IPageHasId } from '@growi/core';
-import { UserPicture } from '@growi/ui/dist/components';
-import { PageListMeta, PagePathLabel } from '@growi/ui/dist/components/PagePath';
+import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui/dist/components';
 import Link from 'next/link';
 import Clamp from 'react-multiline-clamp';
 

+ 2 - 2
apps/app/src/components/PageRenameModal.tsx

@@ -152,12 +152,12 @@ const PageRenameModal = (): JSX.Element => {
   }, [checkExistPaths]);
 
   const checkIsUsersHomepageDebounce = useMemo(() => {
-    const checkIsPagePathRenameable = () => {
+    const checkIsPagePathRenameable = (pageNameInput: string) => {
       setIsMatchedWithUserHomepagePath(isUsersHomepage(pageNameInput));
     };
 
     return debounce(1000, checkIsPagePathRenameable);
-  }, [isUsersHomepage, pageNameInput]);
+  }, [isUsersHomepage]);
 
   useEffect(() => {
     if (isOpened && page != null && pageNameInput !== page.data.path) {

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