Преглед изворни кода

Merge branch 'support/apply-nextjs-2' of https://github.com/weseek/growi into feat/101884-set-env-var-to-display-audit-log

Shun Miyazawa пре 3 година
родитељ
комит
79c83ae39d
100 измењених фајлова са 1550 додато и 1405 уклоњено
  1. 4 0
      .github/workflows/ci-app-prod.yml
  2. 12 4
      .github/workflows/ci-app.yml
  3. 0 4
      .vscode/launch.json
  4. 4 2
      package.json
  5. 3 3
      packages/app/_obsolete/src/client/admin.jsx
  6. 0 56
      packages/app/bin/generate-plugin-definitions-source.ts
  7. 0 1
      packages/app/docker/Dockerfile
  8. 8 1
      packages/app/jest.config.js
  9. 3 0
      packages/app/next.config.js
  10. 3 3
      packages/app/package.json
  11. 0 3
      packages/app/resource/locales/en_US/sandbox.md
  12. 0 3
      packages/app/resource/locales/ja_JP/sandbox.md
  13. 0 3
      packages/app/resource/locales/zh_CN/sandbox.md
  14. 0 202
      packages/app/src/client/services/AdminUserGroupDetailContainer.js
  15. 0 4
      packages/app/src/client/services/AdminUsersContainer.js
  16. 11 0
      packages/app/src/components/Admin/NotFoundPage.tsx
  17. 0 151
      packages/app/src/components/Admin/Security/LdapAuthTest.jsx
  18. 129 0
      packages/app/src/components/Admin/Security/LdapAuthTest.tsx
  19. 1 1
      packages/app/src/components/Admin/Security/LdapAuthTestModal.jsx
  20. 1 3
      packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  21. 2 3
      packages/app/src/components/Admin/UserGroup/UserGroupDropdown.tsx
  22. 1 3
      packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  23. 1 3
      packages/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  24. 5 7
      packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  25. 1 3
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  26. 2 3
      packages/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx
  27. 98 57
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  28. 0 97
      packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.jsx
  29. 69 0
      packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.tsx
  30. 16 26
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx
  31. 0 97
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.jsx
  32. 109 0
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.tsx
  33. 0 131
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.jsx
  34. 98 0
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  35. 1 1
      packages/app/src/components/InstallerForm.jsx
  36. 4 2
      packages/app/src/components/Layout/AdminLayout.tsx
  37. 14 0
      packages/app/src/components/Layout/Invited.module.scss
  38. 27 0
      packages/app/src/components/Layout/Login.module.scss
  39. 21 62
      packages/app/src/components/Layout/NoLoginLayout.module.scss
  40. 48 0
      packages/app/src/components/Layout/NoLoginLayout.tsx
  41. 16 2
      packages/app/src/components/Layout/RawLayout.tsx
  42. 1 1
      packages/app/src/components/LoginForm.jsx
  43. 56 36
      packages/app/src/components/Me/AssociateModal.tsx
  44. 22 17
      packages/app/src/components/Me/BasicInfoSettings.tsx
  45. 41 19
      packages/app/src/components/Page.tsx
  46. 3 3
      packages/app/src/components/Page/RevisionRenderer.tsx
  47. 1 1
      packages/app/src/components/ReactMarkdownComponents/NextLink.tsx
  48. 12 1
      packages/app/src/components/Theme/ThemeAntarctic.module.scss
  49. 9 0
      packages/app/src/components/Theme/ThemeAntarctic.tsx
  50. 14 3
      packages/app/src/components/Theme/ThemeChristmas.module.scss
  51. 9 0
      packages/app/src/components/Theme/ThemeChristmas.tsx
  52. 12 5
      packages/app/src/components/Theme/ThemeHalloween.module.scss
  53. 9 0
      packages/app/src/components/Theme/ThemeHalloween.tsx
  54. 17 6
      packages/app/src/components/Theme/ThemeHufflepuff.module.scss
  55. 13 0
      packages/app/src/components/Theme/ThemeHufflepuff.tsx
  56. 8 1
      packages/app/src/components/Theme/ThemeIsland.module.scss
  57. 9 0
      packages/app/src/components/Theme/ThemeIsland.tsx
  58. 15 3
      packages/app/src/components/Theme/ThemeSpring.module.scss
  59. 9 0
      packages/app/src/components/Theme/ThemeSpring.tsx
  60. 15 3
      packages/app/src/components/Theme/ThemeWood.module.scss
  61. 9 0
      packages/app/src/components/Theme/ThemeWood.tsx
  62. 34 0
      packages/app/src/components/Theme/utils/ThemeImageProvider.tsx
  63. 7 0
      packages/app/src/interfaces/ldap.ts
  64. 15 1
      packages/app/src/interfaces/user-group-response.ts
  65. 7 0
      packages/app/src/interfaces/user-group.ts
  66. 1 1
      packages/app/src/pages/[[...path]].page.tsx
  67. 27 16
      packages/app/src/pages/admin/[[...path]].page.tsx
  68. 7 30
      packages/app/src/pages/installer.page.tsx
  69. 8 15
      packages/app/src/pages/login.page.tsx
  70. 4 2
      packages/app/src/pages/me.page.tsx
  71. 6 5
      packages/app/src/server/crowi/index.js
  72. 0 40
      packages/app/src/server/plugins/plugin-utils-v2.js
  73. 0 38
      packages/app/src/server/plugins/plugin-utils-v4.ts
  74. 1 44
      packages/app/src/server/plugins/plugin-utils.js
  75. 0 72
      packages/app/src/server/plugins/plugin.service.js
  76. 7 4
      packages/app/src/server/routes/admin.js
  77. 0 1
      packages/app/src/server/routes/apiv3/user-group.js
  78. 7 5
      packages/app/src/server/routes/ogp.ts
  79. 2 1
      packages/app/src/server/service/s2s-messaging/nchan.ts
  80. 3 2
      packages/app/src/server/views/admin/not_found.html
  81. 27 0
      packages/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.ts
  82. 25 8
      packages/app/src/services/renderer/rehype-plugins/relative-links.ts
  83. 80 0
      packages/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.ts
  84. 134 42
      packages/app/src/services/renderer/renderer.tsx
  85. 6 6
      packages/app/src/stores/context.tsx
  86. 18 7
      packages/app/src/stores/renderer.tsx
  87. 4 3
      packages/app/src/stores/user-group.tsx
  88. 1 1
      packages/app/src/styles/style-next.scss
  89. 2 2
      packages/app/src/styles/theme/_apply-colors-dark.scss
  90. 2 2
      packages/app/src/styles/theme/_apply-colors-light.scss
  91. 5 2
      packages/app/src/utils/download.ts
  92. 92 0
      packages/app/test/unit/services/renderer/pukiwiki-like-linker.test.ts
  93. 1 0
      packages/app/tsconfig.build.client.json
  94. 10 0
      packages/app/tsconfig.build.server-tsc-alias.json
  95. 2 1
      packages/app/tsconfig.build.server.json
  96. 1 0
      packages/app/tsconfig.json
  97. 2 3
      packages/core/src/index.ts
  98. 2 0
      packages/core/src/interfaces/user.ts
  99. 4 0
      packages/core/src/plugin/interfaces/option-parser.ts
  100. 0 11
      packages/core/src/plugin/interfaces/plugin-definition-v4.ts

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

@@ -13,7 +13,9 @@ on:
       - yarn.lock
       - yarn.lock
       - packages/app/**
       - packages/app/**
       - '!packages/app/docker/**'
       - '!packages/app/docker/**'
+      - packages/codemirror-textlint/**
       - packages/core/**
       - packages/core/**
+      - packages/remark-growi-plugin/**
       - packages/slack/**
       - packages/slack/**
       - packages/ui/**
       - packages/ui/**
       - packages/plugin-**
       - packages/plugin-**
@@ -30,7 +32,9 @@ on:
       - yarn.lock
       - yarn.lock
       - packages/app/**
       - packages/app/**
       - '!packages/app/docker/**'
       - '!packages/app/docker/**'
+      - packages/codemirror-textlint/**
       - packages/core/**
       - packages/core/**
+      - packages/remark-growi-plugin/**
       - packages/slack/**
       - packages/slack/**
       - packages/ui/**
       - packages/ui/**
       - packages/plugin-**
       - packages/plugin-**

+ 12 - 4
.github/workflows/ci-app.yml

@@ -14,7 +14,9 @@ on:
       - yarn.lock
       - yarn.lock
       - packages/app/**
       - packages/app/**
       - '!packages/app/docker/**'
       - '!packages/app/docker/**'
+      - packages/codemirror-textlint/**
       - packages/core/**
       - packages/core/**
+      - packages/remark-growi-plugin/**
       - packages/slack/**
       - packages/slack/**
       - packages/ui/**
       - packages/ui/**
       - packages/plugin-*/**
       - packages/plugin-*/**
@@ -53,10 +55,10 @@ jobs:
 
 
       - name: lerna run lint for plugins
       - name: lerna run lint for plugins
         run: |
         run: |
-          yarn lerna run lint --scope @growi/plugin-*
+          yarn lerna run lint --scope @growi/remark-growi-plugin --scope @growi/plugin-*
       - name: lerna run lint for app
       - name: lerna run lint for app
         run: |
         run: |
-          yarn lerna run lint --scope @growi/app --scope @growi/codemirror-textlint --scope @growi/core --scope @growi/ui
+          yarn lerna run lint --scope @growi/app --scope @growi/codemirror-textlint --scope @growi/core --scope @growi/slack --scope @growi/ui
 
 
       - name: Slack Notification
       - name: Slack Notification
         uses: weseek/ghaction-slack-notification@master
         uses: weseek/ghaction-slack-notification@master
@@ -105,7 +107,11 @@ jobs:
         run: |
         run: |
           npx lerna bootstrap -- --frozen-lockfile
           npx lerna bootstrap -- --frozen-lockfile
 
 
-      - name: yarn test
+      - name: lerna run test for plugins
+        run: |
+          yarn lerna run test --scope @growi/remark-growi-plugin --scope @growi/plugin-*
+
+      - name: Test app
         working-directory: ./packages/app
         working-directory: ./packages/app
         run: |
         run: |
           yarn test:ci --selectProjects unit server ; yarn test:ci --selectProjects server-v5
           yarn test:ci --selectProjects unit server ; yarn test:ci --selectProjects server-v5
@@ -116,7 +122,9 @@ jobs:
         uses: actions/upload-artifact@v3
         uses: actions/upload-artifact@v3
         with:
         with:
           name: Coverage Report
           name: Coverage Report
-          path: packages/app/coverage
+          path: |
+            packages/app/coverage
+            packages/remark-growi-plugin/coverage
 
 
       - name: Slack Notification
       - name: Slack Notification
         uses: weseek/ghaction-slack-notification@master
         uses: weseek/ghaction-slack-notification@master

+ 0 - 4
.vscode/launch.json

@@ -76,10 +76,6 @@
             "url": "webpack://_n_e/plugin-attachment-refs",
             "url": "webpack://_n_e/plugin-attachment-refs",
             "path": "${workspaceFolder}/packages/plugin-attachment-refs"
             "path": "${workspaceFolder}/packages/plugin-attachment-refs"
           },
           },
-          {
-            "url": "webpack://_n_e/plugin-pukiwiki-like-linker",
-            "path": "${workspaceFolder}/packages/plugin-pukiwiki-like-linker"
-          },
           {
           {
             "url": "webpack://_n_e/plugin-lsx",
             "url": "webpack://_n_e/plugin-lsx",
             "path": "${workspaceFolder}/packages/plugin-lsx"
             "path": "${workspaceFolder}/packages/plugin-lsx"

+ 4 - 2
package.json

@@ -48,10 +48,12 @@
     "cross-env": "^7.0.0",
     "cross-env": "^7.0.0",
     "dotenv-flow": "^3.2.0",
     "dotenv-flow": "^3.2.0",
     "npm-run-all": "^4.1.5",
     "npm-run-all": "^4.1.5",
+    "ts-deepmerge": "^3.0.0",
     "tslib": "^2.3.1"
     "tslib": "^2.3.1"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@testing-library/cypress": "^8.0.2",
     "@testing-library/cypress": "^8.0.2",
+    "@types/css-modules": "^1.0.2",
     "@types/jest": "^26.0.22",
     "@types/jest": "^26.0.22",
     "@types/node": "^17.0.43",
     "@types/node": "^17.0.43",
     "@types/rewire": "^2.5.28",
     "@types/rewire": "^2.5.28",
@@ -66,7 +68,7 @@
     "eslint-plugin-jest": "^26.5.3",
     "eslint-plugin-jest": "^26.5.3",
     "eslint-plugin-react": "^7.30.1",
     "eslint-plugin-react": "^7.30.1",
     "eslint-plugin-react-hooks": "^4.6.0",
     "eslint-plugin-react-hooks": "^4.6.0",
-    "jest": "^27.0.6",
+    "jest": "^28.1.3",
     "jest-date-mock": "^1.0.8",
     "jest-date-mock": "^1.0.8",
     "jest-localstorage-mock": "^2.4.14",
     "jest-localstorage-mock": "^2.4.14",
     "lerna": "^4.0.0",
     "lerna": "^4.0.0",
@@ -81,7 +83,7 @@
     "shipjs": "^0.24.1",
     "shipjs": "^0.24.1",
     "stylelint": "^14.2.0",
     "stylelint": "^14.2.0",
     "stylelint-config-recess-order": "^3.0.0",
     "stylelint-config-recess-order": "^3.0.0",
-    "ts-jest": "^27.0.4",
+    "ts-jest": "^28.0.7",
     "ts-node": "^10.9.1",
     "ts-node": "^10.9.1",
     "tsconfig-paths": "^3.9.0",
     "tsconfig-paths": "^3.9.0",
     "typescript": "~4.7",
     "typescript": "~4.7",

+ 3 - 3
packages/app/_obsolete/src/client/admin.jsx

@@ -23,7 +23,7 @@ import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityConta
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
 import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
 import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
 import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
+// import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import ContextExtractor from '~/client/services/ContextExtractor';
 import ContextExtractor from '~/client/services/ContextExtractor';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -67,7 +67,7 @@ const adminExternalAccountsContainer = new AdminExternalAccountsContainer(appCon
 const adminNotificationContainer = new AdminNotificationContainer(appContainer);
 const adminNotificationContainer = new AdminNotificationContainer(appContainer);
 const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer(appContainer);
 const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer(appContainer);
 const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
 const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
-const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appContainer);
+// const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appContainer);
 const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 const injectableContainers = [
 const injectableContainers = [
   appContainer,
   appContainer,
@@ -81,7 +81,7 @@ const injectableContainers = [
   adminNotificationContainer,
   adminNotificationContainer,
   adminSlackIntegrationLegacyContainer,
   adminSlackIntegrationLegacyContainer,
   adminMarkDownContainer,
   adminMarkDownContainer,
-  adminUserGroupDetailContainer,
+  // adminUserGroupDetailContainer,
   socketIoContainer,
   socketIoContainer,
 ];
 ];
 
 

+ 0 - 56
packages/app/bin/generate-plugin-definitions-source.ts

@@ -1,56 +0,0 @@
-/**
- * the tool for genetion of plugin definitions source code
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- */
-import fs from 'graceful-fs';
-import normalize from 'normalize-path';
-import swig from 'swig-templates';
-
-import { PluginDefinitionV4 } from '@growi/core';
-
-import PluginUtils from '../src/server/plugins/plugin-utils';
-import loggerFactory from '../src/utils/logger';
-import { resolveFromRoot } from '../src/utils/project-dir-utils';
-
-const logger = loggerFactory('growi:bin:generate-plugin-definitions-source');
-
-
-const pluginUtils = new PluginUtils();
-
-const TEMPLATE = resolveFromRoot('bin/templates/plugin-definitions.js.swig');
-const OUT = resolveFromRoot('tmp/plugins/plugin-definitions.js');
-
-// list plugin names
-const pluginNames: string[] = pluginUtils.listPluginNames();
-logger.info('Detected plugins: ', pluginNames);
-
-async function main(): Promise<void> {
-
-  // get definitions
-  const definitions: PluginDefinitionV4[] = [];
-  for (const pluginName of pluginNames) {
-    // eslint-disable-next-line no-await-in-loop
-    const definition = await pluginUtils.generatePluginDefinition(pluginName, true);
-    if (definition != null) {
-      definitions.push(definition);
-    }
-  }
-
-  definitions.map((definition) => {
-    // convert backslash to slash
-    definition.entries = definition.entries.map((entryPath) => {
-      return normalize(entryPath);
-    });
-    return definition;
-  });
-
-  const compiledTemplate = swig.compileFile(TEMPLATE);
-  const code = compiledTemplate({ definitions });
-
-  // write
-  fs.writeFileSync(OUT, code);
-
-}
-
-main();

+ 0 - 1
packages/app/docker/Dockerfile

@@ -101,7 +101,6 @@ COPY packages/core packages/core
 COPY packages/codemirror-textlint packages/codemirror-textlint
 COPY packages/codemirror-textlint packages/codemirror-textlint
 COPY packages/plugin-attachment-refs packages/plugin-attachment-refs
 COPY packages/plugin-attachment-refs packages/plugin-attachment-refs
 COPY packages/plugin-lsx packages/plugin-lsx
 COPY packages/plugin-lsx packages/plugin-lsx
-COPY packages/plugin-pukiwiki-like-linker packages/plugin-pukiwiki-like-linker
 COPY packages/slack packages/slack
 COPY packages/slack packages/slack
 COPY packages/ui packages/ui
 COPY packages/ui packages/ui
 
 

+ 8 - 1
packages/app/jest.config.js

@@ -5,7 +5,8 @@
 const MODULE_NAME_MAPPING = {
 const MODULE_NAME_MAPPING = {
   '^\\^/(.+)$': '<rootDir>/$1',
   '^\\^/(.+)$': '<rootDir>/$1',
   '^~/(.+)$': '<rootDir>/src/$1',
   '^~/(.+)$': '<rootDir>/src/$1',
-  '^@growi/(.+)$': '<rootDir>/../$1/src',
+  '^@growi/([^/]+)$': '<rootDir>/../$1/src',
+  '^@growi/([^/]+)/(.+)$': '<rootDir>/../$1/src/$2',
 };
 };
 
 
 module.exports = {
 module.exports = {
@@ -20,6 +21,11 @@ module.exports = {
 
 
       preset: 'ts-jest/presets/js-with-ts',
       preset: 'ts-jest/presets/js-with-ts',
 
 
+      // transform ESM to CJS
+      transformIgnorePatterns: [
+        '/node_modules/(?!remark-gfm)/',
+      ],
+
       rootDir: '.',
       rootDir: '.',
       roots: ['<rootDir>'],
       roots: ['<rootDir>'],
       testMatch: ['<rootDir>/test/unit/**/*.test.ts', '<rootDir>/test/unit/**/*.test.js'],
       testMatch: ['<rootDir>/test/unit/**/*.test.ts', '<rootDir>/test/unit/**/*.test.js'],
@@ -29,6 +35,7 @@ module.exports = {
       // Automatically clear mock calls and instances between every test
       // Automatically clear mock calls and instances between every test
       clearMocks: true,
       clearMocks: true,
       moduleNameMapper: MODULE_NAME_MAPPING,
       moduleNameMapper: MODULE_NAME_MAPPING,
+
     },
     },
     {
     {
       displayName: 'server',
       displayName: 'server',

+ 3 - 0
packages/app/next.config.js

@@ -25,6 +25,7 @@ const setupTranspileModules = () => {
     // listing ESM packages until experimental.esmExternals works correctly to avoid ERR_REQUIRE_ESM
     // listing ESM packages until experimental.esmExternals works correctly to avoid ERR_REQUIRE_ESM
     'react-markdown',
     'react-markdown',
     'unified',
     'unified',
+    'character-entities-html4',
     'comma-separated-tokens',
     'comma-separated-tokens',
     'decode-named-character-reference',
     'decode-named-character-reference',
     'hastscript',
     'hastscript',
@@ -33,7 +34,9 @@ const setupTranspileModules = () => {
     'longest-streak',
     'longest-streak',
     'property-information',
     'property-information',
     'space-separated-tokens',
     'space-separated-tokens',
+    'stringify-entities',
     'trim-lines',
     'trim-lines',
+    'trough',
     'web-namespaces',
     'web-namespaces',
     'vfile',
     'vfile',
     'zwitch',
     'zwitch',

+ 3 - 3
packages/app/package.json

@@ -8,8 +8,8 @@
     "start": "yarn next start",
     "start": "yarn next start",
     "build:client": "yarn next build",
     "build:client": "yarn next build",
     "prebuild:client": "tsc -p tsconfig.build.next.config.json",
     "prebuild:client": "tsc -p tsconfig.build.next.config.json",
-    "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
-    "postbuild:server": "npx -y shx mv transpiled/src dist && npx -y shx cp -r transpiled/config/* config && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
+    "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server-tsc-alias.json",
+    "postbuild:server": "npx -y shx echo \"Listing files under transpiled\" && npx -y shx ls transpiled && npx -y shx mv transpiled/src dist && npx -y shx cp -r transpiled/config/* config && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
     "clean": "npx -y shx rm -rf dist transpiled",
     "clean": "npx -y shx rm -rf dist transpiled",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
     "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
     "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
@@ -67,7 +67,6 @@
     "@growi/core": "^5.1.3-RC.0",
     "@growi/core": "^5.1.3-RC.0",
     "@growi/plugin-attachment-refs": "^5.1.3-RC.0",
     "@growi/plugin-attachment-refs": "^5.1.3-RC.0",
     "@growi/plugin-lsx": "^5.1.3-RC.0",
     "@growi/plugin-lsx": "^5.1.3-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.1.3-RC.0",
     "@growi/slack": "^5.1.3-RC.0",
     "@growi/slack": "^5.1.3-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@promster/server": "^7.0.4",
@@ -169,6 +168,7 @@
     "remark-emoji": "^3.0.2",
     "remark-emoji": "^3.0.2",
     "remark-gfm": "^3.0.1",
     "remark-gfm": "^3.0.1",
     "remark-math": "^5.1.1",
     "remark-math": "^5.1.1",
+    "remark-wiki-link": "^1.0.4",
     "rimraf": "^3.0.0",
     "rimraf": "^3.0.0",
     "socket.io": "^4.2.0",
     "socket.io": "^4.2.0",
     "stream-to-promise": "^3.0.0",
     "stream-to-promise": "^3.0.0",

+ 0 - 3
packages/app/resource/locales/en_US/sandbox.md

@@ -245,9 +245,6 @@ You can create links using `[Display text](URL)`.
 
 
 ## Pukiwiki like linker
 ## Pukiwiki like linker
 
 
-(available by [weseek/growi-plugin-pukiwiki-like-linker
-](https://github.com/weseek/growi-plugin-pukiwiki-like-linker) )
-
 This is the most flexible linker.
 This is the most flexible linker.
 Both the page description and link address can be displayed on the page.
 Both the page description and link address can be displayed on the page.
 
 

+ 0 - 3
packages/app/resource/locales/ja_JP/sandbox.md

@@ -244,9 +244,6 @@ ___
 
 
 ## Pukiwiki like linker
 ## Pukiwiki like linker
 
 
-(available by [weseek/growi-plugin-pukiwiki-like-linker
-](https://github.com/weseek/growi-plugin-pukiwiki-like-linker) )
-
 最も柔軟な Linker です。
 最も柔軟な Linker です。
 記述中のページを基点とした相対リンクと、表示テキストに対するリンクを同時に実現できます。
 記述中のページを基点とした相対リンクと、表示テキストに対するリンクを同時に実現できます。
 
 

+ 0 - 3
packages/app/resource/locales/zh_CN/sandbox.md

@@ -245,9 +245,6 @@ You can create links using `[Display text](URL)`.
 
 
 ## Pukiwiki like linker
 ## Pukiwiki like linker
 
 
-(available by [weseek/growi-plugin-pukiwiki-like-linker
-](https://github.com/weseek/growi-plugin-pukiwiki-like-linker) )
-
 This is the most flexible linker.
 This is the most flexible linker.
 Both the page description and link address can be displayed on the page.
 Both the page description and link address can be displayed on the page.
 
 

+ 0 - 202
packages/app/src/client/services/AdminUserGroupDetailContainer.js

@@ -1,202 +0,0 @@
-/*
- * TODO 85062: AdminUserGroupDetailContainer is under transplantation to UserGroupDetailPage.tsx
- */
-
-import { isServer } from '@growi/core';
-import { Container } from 'unstated';
-
-import {
-  apiv3Get, apiv3Delete, apiv3Put, apiv3Post,
-} from '~/client/util/apiv3-client';
-import loggerFactory from '~/utils/logger';
-
-import { toastError } from '../util/apiNotification';
-
-
-// eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
-
-/**
- * Service container for admin user group detail page (UserGroupDetailPage.jsx)
- * @extends {Container} unstated Container
- */
-export default class AdminUserGroupDetailContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    if (isServer()) {
-      return;
-    }
-
-    this.appContainer = appContainer;
-
-    const rootElem = document.getElementById('admin-user-group-detail');
-
-    if (rootElem == null) {
-      return;
-    }
-
-    this.state = {
-      // TODO: [SPA] get userGroup from props
-      userGroup: JSON.parse(rootElem.getAttribute('data-user-group')),
-      userGroupRelations: [], // For user list
-
-      // TODO 85062: /_api/v3/user-groups/children?include_grand_child=boolean
-      childUserGroups: [], // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
-      grandChildUserGroups: [], // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
-
-      childUserGroupRelations: [], // TODO 85062: fetch data on init (findRelationsByGroupIds) For child group list users
-      relatedPages: [], // For page list
-      isUserGroupUserModalOpen: false,
-      searchType: 'partial',
-      isAlsoMailSearched: false,
-      isAlsoNameSearched: false,
-    };
-
-    this.init();
-
-    this.switchIsAlsoMailSearched = this.switchIsAlsoMailSearched.bind(this);
-    this.switchIsAlsoNameSearched = this.switchIsAlsoNameSearched.bind(this);
-    this.openUserGroupUserModal = this.openUserGroupUserModal.bind(this);
-    this.closeUserGroupUserModal = this.closeUserGroupUserModal.bind(this);
-    this.addUserByUsername = this.addUserByUsername.bind(this);
-    this.removeUserByUsername = this.removeUserByUsername.bind(this);
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'AdminUserGroupDetailContainer';
-  }
-
-  /**
-   * retrieve user group data
-   */
-  async init() {
-    try {
-      const [
-        userGroupRelations,
-        relatedPages,
-      ] = await Promise.all([
-        apiv3Get(`/user-groups/${this.state.userGroup._id}/user-group-relations`).then((res) => { return res.data.userGroupRelations }),
-        apiv3Get(`/user-groups/${this.state.userGroup._id}/pages`).then((res) => { return res.data.pages }),
-      ]);
-
-      await this.setState({
-        userGroupRelations,
-        relatedPages,
-      });
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(new Error('Failed to fetch data'));
-    }
-  }
-
-  /**
-   * switch isAlsoMailSearched
-   */
-  switchIsAlsoMailSearched() {
-    this.setState({ isAlsoMailSearched: !this.state.isAlsoMailSearched });
-  }
-
-  /**
-   * switch isAlsoNameSearched
-   */
-  switchIsAlsoNameSearched() {
-    this.setState({ isAlsoNameSearched: !this.state.isAlsoNameSearched });
-  }
-
-  /**
-   * switch searchType
-   */
-  switchSearchType(searchType) {
-    this.setState({ searchType });
-  }
-
-  /**
-   * update user group
-   *
-   * @memberOf AdminUserGroupDetailContainer
-   * @param {object} param update param for user group
-   * @return {object} response object
-   */
-  async updateUserGroup(param) {
-    const res = await apiv3Put(`/user-groups/${this.state.userGroup._id}`, param);
-    const { userGroup } = res.data;
-
-    await this.setState({ userGroup });
-
-    return res;
-  }
-
-  /**
-   * open a modal
-   *
-   * @memberOf AdminUserGroupDetailContainer
-   */
-  async openUserGroupUserModal() {
-    await this.setState({ isUserGroupUserModalOpen: true });
-  }
-
-  /**
-   * close a modal
-   *
-   * @memberOf AdminUserGroupDetailContainer
-   */
-  async closeUserGroupUserModal() {
-    await this.setState({ isUserGroupUserModalOpen: false });
-  }
-
-  /**
-   * search user for invitation
-   * @param {string} username username of the user to be searched
-   */
-  async fetchApplicableUsers(searchWord) {
-    const res = await apiv3Get(`/user-groups/${this.state.userGroup._id}/unrelated-users`, {
-      searchWord,
-      searchType: this.state.searchType,
-      isAlsoMailSearched: this.state.isAlsoMailSearched,
-      isAlsoNameSearched: this.state.isAlsoNameSearched,
-    });
-
-    const { users } = res.data;
-
-    return users;
-  }
-
-
-  /**
-   * update user group
-   *
-   * @memberOf AdminUserGroupDetailContainer
-   * @param {string} username username of the user to be added to the group
-   */
-  async addUserByUsername(username) {
-    const res = await apiv3Post(`/user-groups/${this.state.userGroup._id}/users/${username}`);
-
-    // do not add users for ducaplicate
-    if (res.data.userGroupRelation == null) { return }
-
-    this.init();
-  }
-
-  /**
-   * update user group
-   *
-   * @memberOf AdminUserGroupDetailContainer
-   * @param {string} username username of the user to be removed from the group
-   */
-  async removeUserByUsername(username) {
-    const res = await apiv3Delete(`/user-groups/${this.state.userGroup._id}/users/${username}`);
-
-    this.setState((prevState) => {
-      return {
-        userGroupRelations: prevState.userGroupRelations.filter((u) => { return u._id !== res.data.userGroupRelation._id }),
-      };
-    });
-  }
-
-}

+ 0 - 4
packages/app/src/client/services/AdminUsersContainer.js

@@ -2,14 +2,10 @@ import { isServer } from '@growi/core';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
-import loggerFactory from '~/utils/logger';
-
 import {
 import {
   apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
   apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
 } from '../util/apiv3-client';
 } from '../util/apiv3-client';
 
 
-// eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
 
 
 /**
 /**
  * Service container for admin users page (Users.jsx)
  * Service container for admin users page (Users.jsx)

+ 11 - 0
packages/app/src/components/Admin/NotFoundPage.tsx

@@ -0,0 +1,11 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+export const AdminNotFoundPage = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  return (
+    <h1 className="title">{t('not_found_page.page_not_exist')}</h1>
+  );
+};

+ 0 - 151
packages/app/src/components/Admin/Security/LdapAuthTest.jsx

@@ -1,151 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-
-import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { apiPost } from '~/client/util/apiv1-client';
-import loggerFactory from '~/utils/logger';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-const logger = loggerFactory('growi:security:AdminLdapSecurityContainer');
-
-class LdapAuthTest extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      logs: '',
-      errorMessage: null,
-      successMessage: null,
-    };
-
-    this.addLogs = this.addLogs.bind(this);
-    this.testLdapCredentials = this.testLdapCredentials.bind(this);
-  }
-
-  /**
-   * add logs
-   */
-  addLogs(log) {
-    const newLog = `${new Date()} - ${log}\n\n`;
-    this.setState({
-      logs: `${newLog}${this.state.logs}`,
-    });
-  }
-
-  /**
-   * Test ldap auth
-   */
-  async testLdapCredentials() {
-    try {
-      const response = await apiPost('/login/testLdap', {
-        loginForm: {
-          username: this.props.username,
-          password: this.props.password,
-        },
-      });
-
-      // add logs
-      if (response.err) {
-        toastError(response.err);
-        this.addLogs(response.err);
-      }
-
-      if (response.status === 'warning') {
-        this.addLogs(response.message);
-        this.setState({ errorMessage: response.message, successMessage: null });
-      }
-
-      if (response.status === 'success') {
-        toastSuccess(response.message);
-        this.setState({ successMessage: response.message, errorMessage: null });
-      }
-
-      if (response.ldapConfiguration) {
-        const prettified = JSON.stringify(response.ldapConfiguration.server, undefined, 4);
-        this.addLogs(`LDAP Configuration : ${prettified}`);
-      }
-      if (response.ldapAccountInfo) {
-        const prettified = JSON.stringify(response.ldapAccountInfo, undefined, 4);
-        this.addLogs(`Retrieved LDAP Account : ${prettified}`);
-      }
-
-    }
-    // Catch server communication error
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  }
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <React.Fragment>
-        {this.state.successMessage != null && <div className="alert alert-success">{this.state.successMessage}</div>}
-        {this.state.errorMessage != null && <div className="alert alert-warning">{this.state.errorMessage}</div>}
-        <div className="form-group row">
-          <label htmlFor="username" className="col-3 col-form-label">{t('username')}</label>
-          <div className="col-6">
-            <input
-              className="form-control"
-              name="username"
-              value={this.props.username}
-              onChange={(e) => { this.props.onChangeUsername(e.target.value) }}
-              autoComplete="off"
-            />
-          </div>
-        </div>
-        <div className="form-group row">
-          <label htmlFor="password" className="col-3 col-form-label">{t('Password')}</label>
-          <div className="col-6">
-            <input
-              className="form-control"
-              type="password"
-              name="password"
-              value={this.props.password}
-              onChange={(e) => { this.props.onChangePassword(e.target.value) }}
-              autoComplete="off"
-            />
-          </div>
-        </div>
-
-        <div className="form-group">
-          <label><h5>Logs</h5></label>
-          <textarea id="taLogs" className="col form-control" rows="4" value={this.state.logs} readOnly />
-        </div>
-
-        <div>
-          <button type="button" className="btn btn-outline-secondary offset-5 col-2" onClick={this.testLdapCredentials}>Test</button>
-        </div>
-      </React.Fragment>
-
-    );
-  }
-
-}
-
-const LdapAuthTestFc = (props) => {
-  const { t } = useTranslation();
-  return <LdapAuthTest t={t} {...props} />;
-};
-
-
-LdapAuthTest.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminLdapSecurityContainer: PropTypes.instanceOf(AdminLdapSecurityContainer).isRequired,
-
-  username: PropTypes.string.isRequired,
-  password: PropTypes.string.isRequired,
-  onChangeUsername: PropTypes.func.isRequired,
-  onChangePassword: PropTypes.func.isRequired,
-};
-
-const LdapAuthTestWrapper = withUnstatedContainers(LdapAuthTestFc, [AdminLdapSecurityContainer]);
-
-export default LdapAuthTestWrapper;

+ 129 - 0
packages/app/src/components/Admin/Security/LdapAuthTest.tsx

@@ -0,0 +1,129 @@
+import React, { useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiPost } from '~/client/util/apiv1-client';
+import loggerFactory from '~/utils/logger';
+import { IResTestLdap } from '~/interfaces/ldap';
+
+const logger = loggerFactory('growi:security:AdminLdapSecurityContainer');
+
+type LdapAuthTestProps = {
+  username: string,
+  password: string,
+  onChangeUsername: (username: string) => void,
+  onChangePassword: (password: string) => void,
+}
+
+export const LdapAuthTest = (props: LdapAuthTestProps): JSX.Element => {
+  const {
+    username, password, onChangeUsername, onChangePassword,
+  } = props;
+  const { t } = useTranslation();
+  const [logs, setLogs] = useState('');
+  const [errorMessage, setErrorMessage] = useState('');
+  const [successMessage, setSuccessMessage] = useState('');
+
+  /**
+   * add logs
+   */
+  const addLogs = (log) => {
+    const newLog = `${new Date()} - ${log}\n\n`;
+    setLogs(`${newLog}${logs}`);
+  };
+
+  /**
+   * Test ldap auth
+   */
+  const testLdapCredentials = async() => {
+    try {
+      const response = await apiPost<IResTestLdap>('/login/testLdap', {
+        loginForm: {
+          username,
+          password,
+        },
+      });
+
+      const {
+        err, message, status, ldapConfiguration, ldapAccountInfo,
+      } = response;
+
+      // add logs
+      if (err) {
+        toastError(err);
+        addLogs(err);
+      }
+
+      if (status === 'warning') {
+        addLogs(message);
+        setErrorMessage(message);
+        setSuccessMessage('');
+      }
+
+      if (status === 'success') {
+        toastSuccess(message);
+        setSuccessMessage(message);
+        setErrorMessage('');
+      }
+
+      if (ldapConfiguration) {
+        const prettified = JSON.stringify(ldapConfiguration.server, undefined, 4);
+        addLogs(`LDAP Configuration : ${prettified}`);
+      }
+      if (ldapAccountInfo) {
+        const prettified = JSON.stringify(ldapAccountInfo, undefined, 4);
+        addLogs(`Retrieved LDAP Account : ${prettified}`);
+      }
+
+    }
+    // Catch server communication error
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  };
+
+
+  return (
+    <React.Fragment>
+      {successMessage !== '' && <div className="alert alert-success">{successMessage}</div>}
+      {errorMessage !== '' && <div className="alert alert-warning">{errorMessage}</div>}
+      <div className="form-group row">
+        <label htmlFor="username" className="col-3 col-form-label">{t('username')}</label>
+        <div className="col-6">
+          <input
+            className="form-control"
+            name="username"
+            value={username}
+            onChange={(e) => { onChangeUsername(e.target.value) }}
+            autoComplete="off"
+          />
+        </div>
+      </div>
+      <div className="form-group row">
+        <label htmlFor="password" className="col-3 col-form-label">{t('Password')}</label>
+        <div className="col-6">
+          <input
+            className="form-control"
+            type="password"
+            name="password"
+            value={password}
+            onChange={(e) => { onChangePassword(e.target.value) }}
+            autoComplete="off"
+          />
+        </div>
+      </div>
+
+      <div className="form-group">
+        <label><h5>Logs</h5></label>
+        <textarea id="taLogs" className="col form-control" rows={4} value={logs} readOnly />
+      </div>
+
+      <div>
+        <button type="button" className="btn btn-outline-secondary offset-5 col-2" onClick={testLdapCredentials}>Test</button>
+      </div>
+    </React.Fragment>
+
+  );
+};

+ 1 - 1
packages/app/src/components/Admin/Security/LdapAuthTestModal.jsx

@@ -9,7 +9,7 @@ import {
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
-import LdapAuthTest from './LdapAuthTest';
+import { LdapAuthTest } from './LdapAuthTest';
 
 
 
 
 class LdapAuthTestModal extends React.Component {
 class LdapAuthTestModal extends React.Component {

+ 1 - 3
packages/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx

@@ -40,7 +40,7 @@ const actionForPages = {
   transfer: 'transfer',
   transfer: 'transfer',
 };
 };
 
 
-const UserGroupDeleteModal: FC<Props> = (props: Props) => {
+export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -209,5 +209,3 @@ const UserGroupDeleteModal: FC<Props> = (props: Props) => {
     </Modal>
     </Modal>
   );
   );
 };
 };
-
-export default UserGroupDeleteModal;

+ 2 - 3
packages/app/src/components/Admin/UserGroup/UserGroupDropdown.tsx

@@ -1,4 +1,5 @@
 import React, { FC, useCallback } from 'react';
 import React, { FC, useCallback } from 'react';
+
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { IUserGroupHasId } from '~/interfaces/user';
 import { IUserGroupHasId } from '~/interfaces/user';
@@ -9,7 +10,7 @@ type Props = {
   onClickCreateUserGroupButton?(): void
   onClickCreateUserGroupButton?(): void
 };
 };
 
 
-const UserGroupDropdown: FC<Props> = (props: Props) => {
+export const UserGroupDropdown: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const { selectableUserGroups, onClickAddExistingUserGroupButton, onClickCreateUserGroupButton } = props;
   const { selectableUserGroups, onClickAddExistingUserGroupButton, onClickCreateUserGroupButton } = props;
@@ -66,5 +67,3 @@ const UserGroupDropdown: FC<Props> = (props: Props) => {
     </>
     </>
   );
   );
 };
 };
-
-export default UserGroupDropdown;

+ 1 - 3
packages/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -13,7 +13,7 @@ type Props = {
   onSubmit?: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void> | void
   onSubmit?: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void> | void
 };
 };
 
 
-const UserGroupForm: FC<Props> = (props: Props) => {
+export const UserGroupForm: FC<Props> = (props: Props) => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -152,5 +152,3 @@ const UserGroupForm: FC<Props> = (props: Props) => {
     </form>
     </form>
   );
   );
 };
 };
-
-export default UserGroupForm;

+ 1 - 3
packages/app/src/components/Admin/UserGroup/UserGroupModal.tsx

@@ -19,7 +19,7 @@ type Props = {
   onHide?: () => Promise<void> | void
   onHide?: () => Promise<void> | void
 };
 };
 
 
-const UserGroupModal: FC<Props> = (props: Props) => {
+export const UserGroupModal: FC<Props> = (props: Props) => {
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -116,5 +116,3 @@ const UserGroupModal: FC<Props> = (props: Props) => {
     </Modal>
     </Modal>
   );
   );
 };
 };
-
-export default UserGroupModal;

+ 5 - 7
packages/app/src/components/Admin/UserGroup/UserGroupPage.tsx

@@ -1,19 +1,19 @@
 import React, { FC, useState, useCallback } from 'react';
 import React, { FC, useState, useCallback } from 'react';
 
 
+import dynamic from 'next/dynamic';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import { useIsAclEnabled } from '~/stores/context';
 import { useIsAclEnabled } from '~/stores/context';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 
 
-import UserGroupDeleteModal from './UserGroupDeleteModal';
-import UserGroupModal from './UserGroupModal';
-import UserGroupTable from './UserGroupTable';
+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 });
 
 
-const UserGroupPage: FC = () => {
+export const UserGroupPage: FC = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: isAclEnabled } = useIsAclEnabled();
@@ -193,5 +193,3 @@ const UserGroupPage: FC = () => {
     </div>
     </div>
   );
   );
 };
 };
-
-export default UserGroupPage;

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

@@ -53,7 +53,7 @@ const generateGroupIdToChildGroupsMap = (childUserGroups: IUserGroupHasId[]): Re
 };
 };
 
 
 
 
-const UserGroupTable: FC<Props> = (props: Props) => {
+export const UserGroupTable: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   /*
   /*
@@ -219,5 +219,3 @@ const UserGroupTable: FC<Props> = (props: Props) => {
     </>
     </>
   );
   );
 };
 };
-
-export default UserGroupTable;

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

@@ -1,4 +1,5 @@
 import React, { FC, useState } from 'react';
 import React, { FC, useState } from 'react';
+
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -7,7 +8,7 @@ import {
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
 
 
 
 
-const UpdateParentConfirmModal: FC = () => {
+export const UpdateParentConfirmModal: FC = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const [isForceUpdate, setForceUpdate] = useState(false);
   const [isForceUpdate, setForceUpdate] = useState(false);
@@ -88,5 +89,3 @@ const UpdateParentConfirmModal: FC = () => {
     </Modal>
     </Modal>
   );
   );
 };
 };
-
-export default UpdateParentConfirmModal;

+ 98 - 57
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -1,56 +1,77 @@
 import React, {
 import React, {
-  FC, useState, useCallback,
+  useState, useCallback, useEffect, useMemo,
 } from 'react';
 } from 'react';
-import { useTranslation } from 'next-i18next';
 
 
-import UserGroupForm from '../UserGroup/UserGroupForm';
-import UserGroupTable from '../UserGroup/UserGroupTable';
-import UserGroupModal from '../UserGroup/UserGroupModal';
-import UserGroupDeleteModal from '../UserGroup/UserGroupDeleteModal';
-import UpdateParentConfirmModal from './UpdateParentConfirmModal';
-import UserGroupDropdown from '../UserGroup/UserGroupDropdown';
-import UserGroupUserTable from './UserGroupUserTable';
-import UserGroupUserModal from './UserGroupUserModal';
-import UserGroupPageList from './UserGroupPageList';
+import { objectIdUtils } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import { useRouter } from 'next/router';
 
 
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import {
 import {
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
 } from '~/client/util/apiv3-client';
 } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { IPageHasId } from '~/interfaces/page';
-import {
-  IUserGroup, IUserGroupHasId,
-} from '~/interfaces/user';
+import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
+import { SearchTypes, SearchType } from '~/interfaces/user-group';
+import Xss from '~/services/xss';
+import { useIsAclEnabled } from '~/stores/context';
+import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
 import {
 import {
-  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList,
+  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxUserGroup,
   useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups,
   useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups,
 } from '~/stores/user-group';
 } from '~/stores/user-group';
-import { useIsAclEnabled } from '~/stores/context';
-import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
 
 
-const UserGroupDetailPage: FC = () => {
+
+const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
+const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable'), { ssr: false });
+
+const UserGroupUserModal = dynamic(() => import('./UserGroupUserModal'), { ssr: false });
+
+const UserGroupDeleteModal = dynamic(() => import('../UserGroup/UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
+const UserGroupDropdown = dynamic(() => import('../UserGroup/UserGroupDropdown').then(mod => mod.UserGroupDropdown), { ssr: false });
+const UserGroupForm = dynamic(() => import('../UserGroup/UserGroupForm').then(mod => mod.UserGroupForm), { ssr: false });
+const UserGroupModal = dynamic(() => import('../UserGroup/UserGroupModal').then(mod => mod.UserGroupModal), { ssr: false });
+const UserGroupTable = dynamic(() => import('../UserGroup/UserGroupTable').then(mod => mod.UserGroupTable), { ssr: false });
+const UpdateParentConfirmModal = dynamic(() => import('./UpdateParentConfirmModal').then(mod => mod.UpdateParentConfirmModal), { ssr: false });
+
+
+type Props = {
+  userGroupId?: string,
+}
+
+const UserGroupDetailPage = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const adminUserGroupDetailElem = document.getElementById('admin-user-group-detail');
+  const router = useRouter();
+  const xss = useMemo(() => new Xss(), []);
+  const { userGroupId: currentUserGroupId } = props;
 
 
-  /*
-   * State (from AdminUserGroupDetailContainer)
-   */
-  const [currentUserGroup, setUserGroup] = useState<IUserGroupHasId>(JSON.parse(adminUserGroupDetailElem?.getAttribute('data-user-group') || 'null'));
-  const [relatedPages, setRelatedPages] = useState<IPageHasId[]>([]); // For page list
-  const [searchType, setSearchType] = useState<string>('partial');
+  const { data: currentUserGroup } = useSWRxUserGroup(currentUserGroupId);
+  const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
   const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
   const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
   const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
   const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
   const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
   const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
+  const [isUserGroupUserModalShown, setIsUserGroupUserModalShown] = useState<boolean>(false);
+
+  const isLoading = currentUserGroup === undefined;
+  const notExistsUerGroup = !isLoading && currentUserGroup == null;
+
+  useEffect(() => {
+    if (!objectIdUtils.isValidObjectId(currentUserGroupId) || notExistsUerGroup) {
+      router.push('/admin/user-groups');
+    }
+  }, [currentUserGroup, currentUserGroupId, notExistsUerGroup, router]);
+
 
 
   /*
   /*
    * Fetch
    * Fetch
    */
    */
-  const { data: userGroupPages } = useSWRxUserGroupPages(currentUserGroup._id, 10, 0);
+  const { data: userGroupPages } = useSWRxUserGroupPages(currentUserGroupId, 10, 0);
+
 
 
-  const { data: childUserGroupsList, mutate: mutateChildUserGroups } = useSWRxChildUserGroupList([currentUserGroup._id], true);
+  const { data: childUserGroupsList, mutate: mutateChildUserGroups } = useSWRxChildUserGroupList(currentUserGroupId ? [currentUserGroupId] : [], true);
   const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
   const childUserGroups = childUserGroupsList != null ? childUserGroupsList.childUserGroups : [];
   const grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
   const grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
   const childUserGroupIds = childUserGroups.map(group => group._id);
   const childUserGroupIds = childUserGroups.map(group => group._id);
@@ -58,10 +79,10 @@ const UserGroupDetailPage: FC = () => {
   const { data: userGroupRelationList, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelationList(childUserGroupIds);
   const { data: userGroupRelationList, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelationList(childUserGroupIds);
   const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
   const childUserGroupRelations = userGroupRelationList != null ? userGroupRelationList : [];
 
 
-  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(currentUserGroup._id);
-  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(currentUserGroup._id);
+  const { data: selectableParentUserGroups, mutate: mutateSelectableParentUserGroups } = useSWRxSelectableParentUserGroups(currentUserGroupId);
+  const { data: selectableChildUserGroups, mutate: mutateSelectableChildUserGroups } = useSWRxSelectableChildUserGroups(currentUserGroupId);
 
 
-  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useSWRxAncestorUserGroups(currentUserGroup._id);
+  const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useSWRxAncestorUserGroups(currentUserGroupId);
 
 
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: isAclEnabled } = useIsAclEnabled();
 
 
@@ -70,17 +91,15 @@ const UserGroupDetailPage: FC = () => {
   /*
   /*
    * Function
    * Function
    */
    */
-  // TODO 85062: old name: switchIsAlsoMailSearched
   const toggleIsAlsoMailSearched = useCallback(() => {
   const toggleIsAlsoMailSearched = useCallback(() => {
     setAlsoMailSearched(prev => !prev);
     setAlsoMailSearched(prev => !prev);
   }, []);
   }, []);
 
 
-  // TODO 85062: old name: switchIsAlsoNameSearched
   const toggleAlsoNameSearched = useCallback(() => {
   const toggleAlsoNameSearched = useCallback(() => {
     setAlsoNameSearched(prev => !prev);
     setAlsoNameSearched(prev => !prev);
   }, []);
   }, []);
 
 
-  const switchSearchType = useCallback((searchType) => {
+  const switchSearchType = useCallback((searchType: SearchType) => {
     setSearchType(searchType);
     setSearchType(searchType);
   }, []);
   }, []);
 
 
@@ -98,13 +117,11 @@ const UserGroupDetailPage: FC = () => {
     });
     });
     const { userGroup: updatedUserGroup } = res.data;
     const { userGroup: updatedUserGroup } = res.data;
 
 
-    setUserGroup(updatedUserGroup);
-
     // mutate
     // mutate
     mutateAncestorUserGroups();
     mutateAncestorUserGroups();
     mutateSelectableChildUserGroups();
     mutateSelectableChildUserGroups();
     mutateSelectableParentUserGroups();
     mutateSelectableParentUserGroups();
-  }, [setUserGroup, mutateAncestorUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups]);
+  }, [mutateAncestorUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups]);
 
 
   const onSubmitUpdateGroup = useCallback(
   const onSubmitUpdateGroup = useCallback(
     async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>, forceUpdateParents: boolean): Promise<void> => {
     async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>, forceUpdateParents: boolean): Promise<void> => {
@@ -142,8 +159,8 @@ const UserGroupDetailPage: FC = () => {
     }
     }
   }, [t, openUpdateParentConfirmModal, onSubmitUpdateGroup]);
   }, [t, openUpdateParentConfirmModal, onSubmitUpdateGroup]);
 
 
-  const fetchApplicableUsers = useCallback(async(searchWord) => {
-    const res = await apiv3Get(`/user-groups/${currentUserGroup._id}/unrelated-users`, {
+  const fetchApplicableUsers = useCallback(async(searchWord: string) => {
+    const res = await apiv3Get(`/user-groups/${currentUserGroupId}/unrelated-users`, {
       searchWord,
       searchWord,
       searchType,
       searchType,
       isAlsoMailSearched,
       isAlsoMailSearched,
@@ -153,18 +170,25 @@ const UserGroupDetailPage: FC = () => {
     const { users } = res.data;
     const { users } = res.data;
 
 
     return users;
     return users;
-  }, [searchType, isAlsoMailSearched, isAlsoNameSearched]);
+  }, [currentUserGroupId, searchType, isAlsoMailSearched, isAlsoNameSearched]);
 
 
-  // TODO 85062: will be used in UserGroupUserFormByInput
   const addUserByUsername = useCallback(async(username: string) => {
   const addUserByUsername = useCallback(async(username: string) => {
-    await apiv3Post(`/user-groups/${currentUserGroup._id}/users/${username}`);
+    await apiv3Post(`/user-groups/${currentUserGroupId}/users/${username}`);
+    setIsUserGroupUserModalShown(false);
     mutateUserGroupRelations();
     mutateUserGroupRelations();
-  }, [currentUserGroup, mutateUserGroupRelations]);
+  }, [currentUserGroupId, mutateUserGroupRelations]);
 
 
+  // Fix: invalid csrf token => https://redmine.weseek.co.jp/issues/102704
   const removeUserByUsername = useCallback(async(username: string) => {
   const removeUserByUsername = useCallback(async(username: string) => {
-    await apiv3Delete(`/user-groups/${currentUserGroup._id}/users/${username}`);
-    mutateUserGroupRelations();
-  }, [currentUserGroup, mutateUserGroupRelations]);
+    try {
+      await apiv3Delete(`/user-groups/${currentUserGroupId}/users/${username}`);
+      toastSuccess(`Removed "${xss.process(username)}" from "${xss.process(currentUserGroup?.name)}"`);
+      mutateUserGroupRelations();
+    }
+    catch (err) {
+      toastError(new Error(`Unable to remove "${xss.process(username)}" from "${xss.process(currentUserGroup?.name)}"`));
+    }
+  }, [currentUserGroup?.name, currentUserGroupId, mutateUserGroupRelations, xss]);
 
 
   const showUpdateModal = useCallback((group: IUserGroupHasId) => {
   const showUpdateModal = useCallback((group: IUserGroupHasId) => {
     setUpdateModalShown(true);
     setUpdateModalShown(true);
@@ -201,11 +225,11 @@ const UserGroupDetailPage: FC = () => {
     await openUpdateParentConfirmModal(
     await openUpdateParentConfirmModal(
       selectedChild,
       selectedChild,
       {
       {
-        parent: currentUserGroup._id,
+        parent: currentUserGroupId,
       },
       },
       onSubmitUpdateGroup,
       onSubmitUpdateGroup,
     );
     );
-  }, [openUpdateParentConfirmModal, onSubmitUpdateGroup, currentUserGroup]);
+  }, [openUpdateParentConfirmModal, currentUserGroupId, onSubmitUpdateGroup]);
 
 
   const showCreateModal = useCallback(() => {
   const showCreateModal = useCallback(() => {
     setCreateModalShown(true);
     setCreateModalShown(true);
@@ -220,7 +244,7 @@ const UserGroupDetailPage: FC = () => {
       await apiv3Post('/user-groups', {
       await apiv3Post('/user-groups', {
         name: userGroupData.name,
         name: userGroupData.name,
         description: userGroupData.description,
         description: userGroupData.description,
-        parentId: currentUserGroup._id,
+        parentId: currentUserGroupId,
       });
       });
 
 
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
@@ -235,7 +259,7 @@ const UserGroupDetailPage: FC = () => {
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }, [t, currentUserGroup, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, hideCreateModal]);
+  }, [currentUserGroupId, t, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, hideCreateModal]);
 
 
   const showDeleteModal = useCallback(async(group: IUserGroupHasId) => {
   const showDeleteModal = useCallback(async(group: IUserGroupHasId) => {
     setSelectedUserGroup(group);
     setSelectedUserGroup(group);
@@ -290,7 +314,7 @@ const UserGroupDetailPage: FC = () => {
   /*
   /*
    * Dependencies
    * Dependencies
    */
    */
-  if (currentUserGroup == null) {
+  if (currentUserGroup == null || currentUserGroupId == null) {
     return <></>;
     return <></>;
   }
   }
 
 
@@ -303,8 +327,8 @@ const UserGroupDetailPage: FC = () => {
             ancestorUserGroups != null && ancestorUserGroups.length > 0 && (
             ancestorUserGroups != null && ancestorUserGroups.length > 0 && (
               ancestorUserGroups.map((ancestorUserGroup: IUserGroupHasId) => (
               ancestorUserGroups.map((ancestorUserGroup: IUserGroupHasId) => (
                 // eslint-disable-next-line max-len
                 // eslint-disable-next-line max-len
-                <li key={ancestorUserGroup._id} className={`breadcrumb-item ${ancestorUserGroup._id === currentUserGroup._id ? 'active' : ''}`} aria-current="page">
-                  { ancestorUserGroup._id === currentUserGroup._id ? (
+                <li key={ancestorUserGroup._id} className={`breadcrumb-item ${ancestorUserGroup._id === currentUserGroupId ? 'active' : ''}`} aria-current="page">
+                  { ancestorUserGroup._id === currentUserGroupId ? (
                     <>{ancestorUserGroup.name}</>
                     <>{ancestorUserGroup.name}</>
                   ) : (
                   ) : (
                     <a href={`/admin/user-group-detail/${ancestorUserGroup._id}`}>{ancestorUserGroup.name}</a>
                     <a href={`/admin/user-group-detail/${ancestorUserGroup._id}`}>{ancestorUserGroup.name}</a>
@@ -325,8 +349,25 @@ const UserGroupDetailPage: FC = () => {
         />
         />
       </div>
       </div>
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.user_list')}</h2>
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.user_list')}</h2>
-      <UserGroupUserTable />
-      <UserGroupUserModal />
+      <UserGroupUserTable
+        userGroup={currentUserGroup}
+        userGroupRelations={childUserGroupRelations}
+        onClickPlusBtn={() => setIsUserGroupUserModalShown(true)}
+        onClickRemoveUserBtn={removeUserByUsername}
+      />
+      <UserGroupUserModal
+        isOpen={isUserGroupUserModalShown}
+        userGroup={currentUserGroup}
+        searchType={searchType}
+        isAlsoMailSearched={isAlsoMailSearched}
+        isAlsoNameSearched={isAlsoNameSearched}
+        onClickAddUserBtn={addUserByUsername}
+        onSearchApplicableUsers={fetchApplicableUsers}
+        onSwitchSearchType={switchSearchType}
+        onClose={() => setIsUserGroupUserModalShown(false)}
+        onToggleIsAlsoMailSearched={toggleIsAlsoMailSearched}
+        onToggleIsAlsoNameSearched={toggleAlsoNameSearched}
+      />
 
 
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.child_group_list')}</h2>
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.child_group_list')}</h2>
       <UserGroupDropdown
       <UserGroupDropdown
@@ -372,7 +413,7 @@ const UserGroupDetailPage: FC = () => {
 
 
       <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
       <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
       <div className="page-list">
       <div className="page-list">
-        <UserGroupPageList />
+        <UserGroupPageList userGroupId={currentUserGroupId} relatedPages={userGroupPages} />
       </div>
       </div>
     </div>
     </div>
   );
   );

+ 0 - 97
packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.jsx

@@ -1,97 +0,0 @@
-import React, { Fragment } from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-import AppContainer from '~/client/services/AppContainer';
-import { toastError } from '~/client/util/apiNotification';
-import { apiv3Get } from '~/client/util/apiv3-client';
-
-import PageListItemS from '../../PageList/PageListItemS';
-import PaginationWrapper from '../../PaginationWrapper';
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class UserGroupPageList extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      currentPages: [],
-      activePage: 1,
-      total: 0,
-      pagingLimit: 10,
-    };
-
-    this.handlePageChange = this.handlePageChange.bind(this);
-  }
-
-  async componentDidMount() {
-    await this.handlePageChange(this.state.activePage);
-  }
-
-  async handlePageChange(pageNum) {
-    const limit = this.state.pagingLimit;
-    const offset = (pageNum - 1) * limit;
-
-    try {
-      const res = await apiv3Get(`/user-groups/${this.props.adminUserGroupDetailContainer.state.userGroup._id}/pages`, {
-        limit,
-        offset,
-      });
-      const { total, pages } = res.data;
-
-      this.setState({
-        total,
-        activePage: pageNum,
-        currentPages: pages,
-      });
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, adminUserGroupDetailContainer } = this.props;
-    const { relatedPages } = adminUserGroupDetailContainer.state;
-
-    return (
-      <Fragment>
-        <ul className="page-list-ul page-list-ul-flat mb-3">
-          {this.state.currentPages.map(page => <li key={page._id}><PageListItemS page={page} /></li>)}
-        </ul>
-        {relatedPages.length === 0 ? <p>{t('admin:user_group_management.no_pages')}</p> : (
-          <PaginationWrapper
-            activePage={this.state.activePage}
-            changePage={this.handlePageChange}
-            totalItemsCount={this.state.total}
-            pagingLimit={this.state.pagingLimit}
-            align="center"
-            size="sm"
-          />
-        )}
-      </Fragment>
-    );
-  }
-
-}
-
-UserGroupPageList.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminUserGroupDetailContainer: PropTypes.instanceOf(AdminUserGroupDetailContainer).isRequired,
-};
-
-const UserGroupPageListWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <UserGroupPageList t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupPageListWrapper = withUnstatedContainers(UserGroupPageListWrapperFC, [AppContainer, AdminUserGroupDetailContainer]);
-
-export default UserGroupPageListWrapper;

+ 69 - 0
packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.tsx

@@ -0,0 +1,69 @@
+import React, { useEffect, useState, useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { toastError } from '~/client/util/apiNotification';
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { IPageHasId } from '~/interfaces/page';
+
+import PageListItemS from '../../PageList/PageListItemS';
+import PaginationWrapper from '../../PaginationWrapper';
+
+const pagingLimit = 10;
+
+type Props = {
+  userGroupId: string,
+  relatedPages?: IPageHasId[],
+}
+
+const UserGroupPageList = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+  const { userGroupId, relatedPages } = props;
+
+  const [currentPages, setCurrentPages] = useState<IPageHasId[]>([]);
+  const [activePage, setActivePage] = useState(1);
+  const [total, setTotal] = useState(0);
+
+  const handlePageChange = useCallback(async(pageNum) => {
+    const offset = (pageNum - 1) * pagingLimit;
+
+    try {
+      const res = await apiv3Get(`/user-groups/${userGroupId}/pages`, {
+        limit: pagingLimit,
+        offset,
+      });
+      const { total, pages } = res.data;
+
+      setTotal(total);
+      setActivePage(pageNum);
+      setCurrentPages(pages);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [userGroupId]);
+
+  useEffect(() => {
+    handlePageChange(activePage);
+  }, [activePage, handlePageChange]);
+
+  return (
+    <>
+      <ul className="page-list-ul page-list-ul-flat mb-3">
+        {currentPages.map(page => <li key={page._id}><PageListItemS page={page} /></li>)}
+      </ul>
+      {relatedPages != null && relatedPages.length === 0 ? <p>{t('admin:user_group_management.no_pages')}</p> : (
+        <PaginationWrapper
+          activePage={activePage}
+          changePage={handlePageChange}
+          totalItemsCount={total}
+          pagingLimit={pagingLimit}
+          align="center"
+          size="sm"
+        />
+      )}
+    </>
+  );
+};
+
+export default UserGroupPageList;

+ 16 - 26
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx

@@ -1,18 +1,14 @@
 import React from 'react';
 import React from 'react';
 
 
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
+import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
-import { useTranslation } from 'next-i18next';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import Xss from '~/services/xss';
 import Xss from '~/services/xss';
 
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
 class UserGroupUserFormByInput extends React.Component {
 class UserGroupUserFormByInput extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
@@ -39,16 +35,13 @@ class UserGroupUserFormByInput extends React.Component {
   }
   }
 
 
   async addUserBySubmit() {
   async addUserBySubmit() {
-    const { adminUserGroupDetailContainer } = this.props;
-    const { userGroup } = adminUserGroupDetailContainer.state;
+    const { userGroup, onClickAddUserBtn } = this.props;
 
 
     if (this.state.inputUser.length === 0) { return }
     if (this.state.inputUser.length === 0) { return }
     const userName = this.state.inputUser[0].username;
     const userName = this.state.inputUser[0].username;
 
 
     try {
     try {
-      await adminUserGroupDetailContainer.addUserByUsername(userName);
-      await adminUserGroupDetailContainer.init();
-      await adminUserGroupDetailContainer.closeUserGroupUserModal();
+      await onClickAddUserBtn(userName);
       toastSuccess(`Added "${this.xss.process(userName)}" to "${this.xss.process(userGroup.name)}"`);
       toastSuccess(`Added "${this.xss.process(userName)}" to "${this.xss.process(userGroup.name)}"`);
       this.setState({ inputUser: '' });
       this.setState({ inputUser: '' });
     }
     }
@@ -64,10 +57,10 @@ class UserGroupUserFormByInput extends React.Component {
   }
   }
 
 
   async searhApplicableUsers() {
   async searhApplicableUsers() {
-    const { adminUserGroupDetailContainer } = this.props;
+    const { onSearchApplicableUsers } = this.props;
 
 
     try {
     try {
-      const users = await adminUserGroupDetailContainer.fetchApplicableUsers(this.state.keyword);
+      const users = await onSearchApplicableUsers(this.state.keyword);
       this.setState({ applicableUsers: users, isLoading: false });
       this.setState({ applicableUsers: users, isLoading: false });
     }
     }
     catch (err) {
     catch (err) {
@@ -84,7 +77,6 @@ class UserGroupUserFormByInput extends React.Component {
   }
   }
 
 
   handleSearch(keyword) {
   handleSearch(keyword) {
-
     if (keyword === '') {
     if (keyword === '') {
       return;
       return;
     }
     }
@@ -101,15 +93,15 @@ class UserGroupUserFormByInput extends React.Component {
   }
   }
 
 
   renderMenuItemChildren(option) {
   renderMenuItemChildren(option) {
-    const { adminUserGroupDetailContainer } = this.props;
+    const { isAlsoNameSearched, isAlsoMailSearched } = this.props;
     const user = option;
     const user = option;
     return (
     return (
-      <React.Fragment>
+      <>
         <UserPicture user={user} size="sm" noLink noTooltip />
         <UserPicture user={user} size="sm" noLink noTooltip />
         <strong className="ml-2">{user.username}</strong>
         <strong className="ml-2">{user.username}</strong>
-        {adminUserGroupDetailContainer.state.isAlsoNameSearched && <span className="ml-2">{user.name}</span>}
-        {adminUserGroupDetailContainer.state.isAlsoMailSearched && <span className="ml-2">{user.email}</span>}
-      </React.Fragment>
+        {isAlsoNameSearched && <span className="ml-2">{user.name}</span>}
+        {isAlsoMailSearched && <span className="ml-2">{user.email}</span>}
+      </>
     );
     );
   }
   }
 
 
@@ -162,8 +154,11 @@ class UserGroupUserFormByInput extends React.Component {
 
 
 UserGroupUserFormByInput.propTypes = {
 UserGroupUserFormByInput.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminUserGroupDetailContainer: PropTypes.instanceOf(AdminUserGroupDetailContainer).isRequired,
+  isAlsoMailSearched: PropTypes.bool.isRequired,
+  isAlsoNameSearched: PropTypes.bool.isRequired,
+  onClickAddUserBtn: PropTypes.func,
+  onSearchApplicableUsers: PropTypes.func,
+  userGroup: PropTypes.object,
 };
 };
 
 
 const UserGroupUserFormByInputWrapperFC = (props) => {
 const UserGroupUserFormByInputWrapperFC = (props) => {
@@ -171,9 +166,4 @@ const UserGroupUserFormByInputWrapperFC = (props) => {
   return <UserGroupUserFormByInput t={t} {...props} />;
   return <UserGroupUserFormByInput t={t} {...props} />;
 };
 };
 
 
-/**
- * Wrapper component for using unstated
- */
-const UserGroupUserFormByInputWrapper = withUnstatedContainers(UserGroupUserFormByInputWrapperFC, [AppContainer, AdminUserGroupDetailContainer]);
-
-export default UserGroupUserFormByInputWrapper;
+export default UserGroupUserFormByInputWrapperFC;

+ 0 - 97
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.jsx

@@ -1,97 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody,
-} from 'reactstrap';
-
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-import AppContainer from '~/client/services/AppContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-import CheckBoxForSerchUserOption from './CheckBoxForSerchUserOption';
-import RadioButtonForSerchUserOption from './RadioButtonForSerchUserOption';
-import UserGroupUserFormByInput from './UserGroupUserFormByInput';
-
-class UserGroupUserModal extends React.Component {
-
-  render() {
-    const { t, adminUserGroupDetailContainer } = this.props;
-
-    return (
-      <Modal isOpen={adminUserGroupDetailContainer.state.isUserGroupUserModalOpen} toggle={adminUserGroupDetailContainer.closeUserGroupUserModal}>
-        <ModalHeader tag="h4" toggle={adminUserGroupDetailContainer.closeUserGroupUserModal} className="bg-info text-light">
-          {t('admin:user_group_management.add_modal.add_user') }
-        </ModalHeader>
-        <ModalBody>
-          <p className="card well">{t('admin:user_group_management.add_modal.description')}</p>
-          <div className="p-3">
-            <UserGroupUserFormByInput />
-          </div>
-          <h2 className="border-bottom">{t('admin:user_group_management.add_modal.search_option')}</h2>
-          <div className="row mt-4">
-            <div className="col-6">
-              <div className="mb-5">
-                <CheckBoxForSerchUserOption
-                  option="mail"
-                  checked={adminUserGroupDetailContainer.state.isAlsoMailSearched}
-                  onChange={adminUserGroupDetailContainer.switchIsAlsoMailSearched}
-                />
-              </div>
-              <div className="mb-5">
-                <CheckBoxForSerchUserOption
-                  option="name"
-                  checked={adminUserGroupDetailContainer.state.isAlsoNameSearched}
-                  onChange={adminUserGroupDetailContainer.switchIsAlsoNameSearched}
-                />
-              </div>
-            </div>
-            <div className="col-6">
-              <div className="mb-5">
-                <RadioButtonForSerchUserOption
-                  searchType="forward"
-                  checked={adminUserGroupDetailContainer.state.searchType === 'forward'}
-                  onChange={() => { adminUserGroupDetailContainer.switchSearchType('forward') }}
-                />
-              </div>
-              <div className="mb-5">
-                <RadioButtonForSerchUserOption
-                  searchType="partial"
-                  checked={adminUserGroupDetailContainer.state.searchType === 'partial'}
-                  onChange={() => { adminUserGroupDetailContainer.switchSearchType('partial') }}
-                />
-              </div>
-              <div className="mb-5">
-                <RadioButtonForSerchUserOption
-                  searchType="backward"
-                  checked={adminUserGroupDetailContainer.state.searchType === 'backword'}
-                  onChange={() => { adminUserGroupDetailContainer.switchSearchType('backword') }}
-                />
-              </div>
-            </div>
-          </div>
-        </ModalBody>
-      </Modal>
-    );
-  }
-
-}
-
-UserGroupUserModal.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminUserGroupDetailContainer: PropTypes.instanceOf(AdminUserGroupDetailContainer).isRequired,
-};
-
-const UserGroupUserModalWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <UserGroupUserModal t={t} {...props} />;
-};
-/**
- * Wrapper component for using unstated
- */
-const UserGroupUserModalWrapper = withUnstatedContainers(UserGroupUserModalWrapperFC, [AppContainer, AdminUserGroupDetailContainer]);
-
-export default UserGroupUserModalWrapper;

+ 109 - 0
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.tsx

@@ -0,0 +1,109 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal, ModalHeader, ModalBody,
+} from 'reactstrap';
+
+import { IUserGroupHasId } from '~/interfaces/user';
+import { SearchTypes, SearchType } from '~/interfaces/user-group';
+
+import CheckBoxForSerchUserOption from './CheckBoxForSerchUserOption';
+import RadioButtonForSerchUserOption from './RadioButtonForSerchUserOption';
+import UserGroupUserFormByInput from './UserGroupUserFormByInput';
+
+type Props = {
+  isOpen: boolean,
+  userGroup: IUserGroupHasId,
+  searchType: SearchType,
+  isAlsoMailSearched: boolean,
+  isAlsoNameSearched: boolean,
+  onClickAddUserBtn: (username: string) => Promise<void>,
+  onSearchApplicableUsers: (searchWord: string) => Promise<void>,
+  onSwitchSearchType: (searchType: SearchType) => void
+  onClose: () => void,
+  onToggleIsAlsoMailSearched: () => void,
+  onToggleIsAlsoNameSearched: () => void,
+}
+
+const UserGroupUserModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+  const {
+    isOpen,
+    userGroup,
+    searchType,
+    onClickAddUserBtn,
+    onSearchApplicableUsers,
+    onSwitchSearchType,
+    onClose,
+    isAlsoMailSearched,
+    isAlsoNameSearched,
+    onToggleIsAlsoMailSearched,
+    onToggleIsAlsoNameSearched,
+  } = props;
+
+  return (
+    <Modal isOpen={isOpen} toggle={onClose}>
+      <ModalHeader tag="h4" toggle={onClose} className="bg-info text-light">
+        {t('admin:user_group_management.add_modal.add_user') }
+      </ModalHeader>
+      <ModalBody>
+        <p className="card well">{t('admin:user_group_management.add_modal.description')}</p>
+        <div className="p-3">
+          <UserGroupUserFormByInput
+            userGroup={userGroup}
+            onClickAddUserBtn={onClickAddUserBtn}
+            onSearchApplicableUsers={onSearchApplicableUsers}
+            onClose={onClose}
+            isAlsoNameSearched={isAlsoNameSearched}
+            isAlsoMailSearched={isAlsoMailSearched}
+          />
+        </div>
+        <h2 className="border-bottom">{t('admin:user_group_management.add_modal.search_option')}</h2>
+        <div className="row mt-4">
+          <div className="col-6">
+            <div className="mb-5">
+              <CheckBoxForSerchUserOption
+                option="mail"
+                checked={isAlsoMailSearched}
+                onChange={onToggleIsAlsoMailSearched}
+              />
+            </div>
+            <div className="mb-5">
+              <CheckBoxForSerchUserOption
+                option="name"
+                checked={isAlsoNameSearched}
+                onChange={onToggleIsAlsoNameSearched}
+              />
+            </div>
+          </div>
+          <div className="col-6">
+            <div className="mb-5">
+              <RadioButtonForSerchUserOption
+                searchType="forward"
+                checked={searchType === SearchTypes.FORWARD}
+                onChange={() => onSwitchSearchType(SearchTypes.FORWARD)}
+              />
+            </div>
+            <div className="mb-5">
+              <RadioButtonForSerchUserOption
+                searchType="partial"
+                checked={searchType === SearchTypes.PARTIAL}
+                onChange={() => onSwitchSearchType(SearchTypes.PARTIAL)}
+              />
+            </div>
+            <div className="mb-5">
+              <RadioButtonForSerchUserOption
+                searchType="backward"
+                checked={searchType === SearchTypes.BACKWORD}
+                onChange={() => onSwitchSearchType(SearchTypes.BACKWORD)}
+              />
+            </div>
+          </div>
+        </div>
+      </ModalBody>
+    </Modal>
+  );
+};
+
+export default UserGroupUserModal;

+ 0 - 131
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.jsx

@@ -1,131 +0,0 @@
-import React from 'react';
-
-import { UserPicture } from '@growi/ui';
-import dateFnsFormat from 'date-fns/format';
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-import AppContainer from '~/client/services/AppContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import Xss from '~/services/xss';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class UserGroupUserTable extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.xss = new Xss();
-
-    this.removeUser = this.removeUser.bind(this);
-  }
-
-  async removeUser(username) {
-    try {
-      await this.props.adminUserGroupDetailContainer.removeUserByUsername(username);
-      toastSuccess(`Removed "${this.xss.process(username)}" from "${this.xss.process(this.props.adminUserGroupDetailContainer.state.userGroup.name)}"`);
-    }
-    catch (err) {
-      // eslint-disable-next-line max-len
-      toastError(new Error(`Unable to remove "${this.xss.process(username)}" from "${this.xss.process(this.props.adminUserGroupDetailContainer.state.userGroup.name)}"`));
-    }
-  }
-
-  render() {
-    const { t, adminUserGroupDetailContainer } = this.props;
-
-    return (
-      <table className="table table-bordered table-user-list">
-        <thead>
-          <tr>
-            <th width="100px">#</th>
-            <th>
-              {t('username')}
-            </th>
-            <th>{t('Name')}</th>
-            <th width="100px">{t('Created')}</th>
-            <th width="160px">{t('Last_Login')}</th>
-            <th width="70px"></th>
-          </tr>
-        </thead>
-        <tbody>
-          {adminUserGroupDetailContainer.state.userGroupRelations.map((sRelation) => {
-            const { relatedUser } = sRelation;
-
-            return (
-              <tr key={sRelation._id}>
-                <td>
-                  <UserPicture user={relatedUser} className="picture rounded-circle" />
-                </td>
-                <td>
-                  <strong>{relatedUser.username}</strong>
-                </td>
-                <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-toggle="dropdown"
-                    >
-                      <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={() => {
-                          return this.removeUser(relatedUser.username);
-                        }}
-                      >
-                        <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_group_management.remove_from_group')}
-                      </button>
-                    </div>
-                  </div>
-                </td>
-              </tr>
-            );
-          })}
-
-          <tr>
-            <td></td>
-            <td className="text-center">
-              <button className="btn btn-outline-secondary" type="button" onClick={adminUserGroupDetailContainer.openUserGroupUserModal}>
-                <i className="ti ti-plus"></i>
-              </button>
-            </td>
-            <td></td>
-            <td></td>
-            <td></td>
-            <td></td>
-          </tr>
-
-        </tbody>
-      </table>
-    );
-  }
-
-}
-
-UserGroupUserTable.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminUserGroupDetailContainer: PropTypes.instanceOf(AdminUserGroupDetailContainer).isRequired,
-};
-
-const UserGroupUserTableWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <UserGroupUserTable t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupUserTableWrapper = withUnstatedContainers(UserGroupUserTableWrapperFC, [AppContainer, AdminUserGroupDetailContainer]);
-
-export default UserGroupUserTableWrapper;

+ 98 - 0
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx

@@ -0,0 +1,98 @@
+import React from 'react';
+
+import { UserPicture } from '@growi/ui';
+import dateFnsFormat from 'date-fns/format';
+import { useTranslation } from 'next-i18next';
+
+import { IUserGroupHasId, IUserGroupRelation } from '~/interfaces/user';
+import { useSWRxUserGroupRelations } from '~/stores/user-group';
+
+type Props = {
+  userGroupRelations: IUserGroupRelation[],
+  userGroup: IUserGroupHasId,
+  onClickRemoveUserBtn: (username: string) => Promise<void>,
+  onClickPlusBtn: () => void,
+}
+
+export const UserGroupUserTable = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    userGroup, onClickRemoveUserBtn, onClickPlusBtn,
+  } = props;
+  const { data: userGroupRelations } = useSWRxUserGroupRelations(userGroup._id);
+
+
+  return (
+    <table className="table table-bordered table-user-list">
+      <thead>
+        <tr>
+          <th style={{ width: '100px' }}>#</th>
+          <th>
+            {t('username')}
+          </th>
+          <th>{t('Name')}</th>
+          <th style={{ width: '100px' }}>{t('Created')}</th>
+          <th style={{ width: '160px' }}>{t('Last_Login')}</th>
+          <th style={{ width: '70px' }}></th>
+        </tr>
+      </thead>
+      <tbody>
+        {userGroupRelations != null && userGroupRelations.map((relation) => {
+          const { relatedUser } = relation;
+
+          return (
+            <tr key={relation._id}>
+              <td>
+                <UserPicture user={relatedUser} className="picture rounded-circle" />
+              </td>
+              <td>
+                <strong>{relatedUser.username}</strong>
+              </td>
+              <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-toggle="dropdown"
+                  >
+                    <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={() => onClickRemoveUserBtn(relatedUser.username)}
+                    >
+                      <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_group_management.remove_from_group')}
+                    </button>
+                  </div>
+                </div>
+              </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>
+
+      </tbody>
+    </table>
+  );
+};
+
+export default UserGroupUserTable;

+ 1 - 1
packages/app/src/components/InstallerForm.jsx

@@ -53,7 +53,7 @@ class InstallerForm extends React.Component {
       : <span><i className="icon-fw icon-ban" />{ this.props.t('installer.unavaliable_user_id') }</span>;
       : <span><i className="icon-fw icon-ban" />{ this.props.t('installer.unavaliable_user_id') }</span>;
 
 
     return (
     return (
-      <div data-testid="installerForm" className={`login-dialog p-3 mx-auto${hasErrorClass}`}>
+      <div data-testid="installerForm" className={`noLogin-dialog p-3 mx-auto${hasErrorClass}`}>
         <div className="row">
         <div className="row">
           <div className="col-md-12">
           <div className="col-md-12">
             <p className="alert alert-success">
             <p className="alert alert-success">

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

@@ -8,6 +8,8 @@ import { RawLayout } from './RawLayout';
 
 
 import styles from './Admin.module.scss';
 import styles from './Admin.module.scss';
 
 
+const AdminNotFoundPage = dynamic(() => import('../Admin/NotFoundPage').then(mod => mod.AdminNotFoundPage), { ssr: false });
+
 
 
 type Props = {
 type Props = {
   title: string
   title: string
@@ -33,7 +35,7 @@ const AdminLayout = ({
       <div className={`admin-page ${styles['admin-page']}`}>
       <div className={`admin-page ${styles['admin-page']}`}>
         <GrowiNavbar />
         <GrowiNavbar />
 
 
-        <header className="py-0">
+        <header className="py-0 position-relative">
           <h1 className="title">{title}</h1>
           <h1 className="title">{title}</h1>
         </header>
         </header>
         <div id="main" className="main">
         <div id="main" className="main">
@@ -43,7 +45,7 @@ const AdminLayout = ({
                 <AdminNavigation selected={selectedNavOpt} />
                 <AdminNavigation selected={selectedNavOpt} />
               </div>
               </div>
               <div className="col-lg-9">
               <div className="col-lg-9">
-                {children}
+                {children || <AdminNotFoundPage />}
               </div>
               </div>
             </div>
             </div>
           </div>
           </div>

+ 14 - 0
packages/app/src/components/Layout/Invited.module.scss

@@ -0,0 +1,14 @@
+.invited,
+.nologin.error {
+  .main .row {
+    @media (min-width: 510px) {
+      .offset-sm-4 {
+        margin-left: calc(50% - 240px);
+      }
+
+      .col-sm-4 {
+        width: 480px;
+      }
+    }
+  }
+}

+ 27 - 0
packages/app/src/components/Layout/Login.module.scss

@@ -0,0 +1,27 @@
+@use '~/styles/bootstrap/init' as bs;
+
+
+.login-page {
+  // layout
+  .main .row .login-header,
+  .login-dialog {
+    width: 320px;
+  }
+
+  .link-growi-org {
+    position: absolute;
+    bottom: 9px;
+    z-index: 3;
+  }
+
+  // To adjust the behavior, this problem is not solved.
+  // See https://github.com/AaronCCWong/react-card-flip/issues/56
+  .react-card-front,
+  .react-card-back {
+    height: 0% !important;
+  }
+}
+
+.collapse-external-auth {
+  overflow: hidden;
+}

+ 21 - 62
packages/app/src/styles/_login.scss → packages/app/src/components/Layout/NoLoginLayout.module.scss

@@ -1,7 +1,7 @@
-@use '~/styles/bootstrap/init' as bs;
+@use '~/styles/bootstrap/init' as *;
 
 
 
 
-.nologin {
+.nologin :global {
   #page-wrapper {
   #page-wrapper {
     background: none;
     background: none;
   }
   }
@@ -24,7 +24,7 @@
           margin-left: 20px;
           margin-left: 20px;
         }
         }
 
 
-        .login-header {
+        .noLogin-header {
           display: flex;
           display: flex;
           flex-direction: column;
           flex-direction: column;
           align-items: center;
           align-items: center;
@@ -32,7 +32,7 @@
           padding-bottom: 10px;
           padding-bottom: 10px;
         }
         }
 
 
-        .login-form-errors {
+        .noLogin-form-errors {
           width: 100%;
           width: 100%;
 
 
           .alert {
           .alert {
@@ -56,7 +56,7 @@
   // #wrapper
   // #wrapper
 
 
   // styles
   // styles
-  .login-header {
+  .noLogin-header {
     h1 {
     h1 {
       font-size: 22px;
       font-size: 22px;
       line-height: 1em;
       line-height: 1em;
@@ -89,46 +89,42 @@
     }
     }
   }
   }
 
 
-  .collapse-external-auth {
-    overflow: hidden;
-  }
-
   $btn-fill-colors: (
   $btn-fill-colors: (
     'login': (
     'login': (
-      rgba(bs.$danger, 0.4),
+      rgba($danger, 0.4),
       rgba(#7e4153, 0.7),
       rgba(#7e4153, 0.7),
     ),
     ),
     'register': (
     'register': (
-      rgba(bs.$success, 0.4),
+      rgba($success, 0.4),
       rgba(#3f7263, 0.7),
       rgba(#3f7263, 0.7),
     ),
     ),
     'google': (
     'google': (
       rgba(#24292e, 0.4),
       rgba(#24292e, 0.4),
-      bs.$gray-700,
+      $gray-700,
     ),
     ),
     'github': (
     'github': (
       rgba(lighten(black, 20%), 0.4),
       rgba(lighten(black, 20%), 0.4),
-      bs.$gray-700,
+      $gray-700,
     ),
     ),
     'facebook': (
     'facebook': (
       rgba(#29487d, 0.4),
       rgba(#29487d, 0.4),
-      bs.$gray-700,
+      $gray-700,
     ),
     ),
     'twitter': (
     'twitter': (
       rgba(#1da1f2, 0.4),
       rgba(#1da1f2, 0.4),
-      bs.$gray-700,
+      $gray-700,
     ),
     ),
     'oidc': (
     'oidc': (
       rgba(#24292e, 0.4),
       rgba(#24292e, 0.4),
-      bs.$gray-700,
+      $gray-700,
     ),
     ),
     'saml': (
     'saml': (
       rgba(#55a79a, 0.4),
       rgba(#55a79a, 0.4),
-      bs.$gray-700,
+      $gray-700,
     ),
     ),
     'basic': (
     'basic': (
       rgba(#24292e, 0.4),
       rgba(#24292e, 0.4),
-      bs.$gray-700,
+      $gray-700,
     ),
     ),
   );
   );
 
 
@@ -154,57 +150,20 @@
       transition: color 0.8s;
       transition: color 0.8s;
     }
     }
   }
   }
-
-  .link-switch {
-    color: bs.$gray-200;
-
-    &:hover {
-      color: white;
-    }
-  }
-}
-
-.login-page {
-  // layout
-  .main .row .login-header,
-  .login-dialog {
-    width: 320px;
-  }
-
-  .link-growi-org {
-    position: absolute;
-    bottom: 9px;
-    z-index: 3;
-  }
-
-  // To adjust the behavior, this problem is not solved.
-  // See https://github.com/AaronCCWong/react-card-flip/issues/56
-  .react-card-front,
-  .react-card-back {
-    height: 0% !important;
+  .noLogin-header,
+  .noLogin-dialog {
+    max-width: 480px;
   }
   }
 }
 }
 
 
-.invited,
-.nologin.error {
-  .main .row {
-    @media (min-width: 510px) {
-      .offset-sm-4 {
-        margin-left: calc(50% - 240px);
-      }
+.link-switch {
+  color: $gray-200;
 
 
-      .col-sm-4 {
-        width: 480px;
-      }
-    }
+  &:hover {
+    color: white;
   }
   }
 }
 }
 
 
-.login-header,
-.login-dialog {
-  max-width: 480px;
-}
-
 .nologin.error {
 .nologin.error {
   .alert h2 {
   .alert h2 {
     line-height: 1em;
     line-height: 1em;

+ 48 - 0
packages/app/src/components/Layout/NoLoginLayout.tsx

@@ -0,0 +1,48 @@
+import React, { ReactNode } from 'react';
+
+import GrowiLogo from '../Icons/GrowiLogo';
+
+import { RawLayout } from './RawLayout';
+
+import commonStyles from './NoLoginLayout.module.scss';
+
+type Props = {
+  title: string,
+  className?: string,
+  children?: ReactNode,
+}
+
+export const NoLoginLayout = ({
+  children, title, className,
+}: Props): JSX.Element => {
+  const classNames: string[] = ['wrapper'];
+  if (className != null) {
+    classNames.push(className);
+  }
+  return (
+    <RawLayout title={title} className={`${commonStyles.nologin}`}>
+      <div className="nologin">
+        <div id="wrapper">
+          <div id="page-wrapper">
+            <div className="main container-fluid">
+
+              <div className="row">
+
+                <div className="col-md-12">
+                  <div className="noLogin-header mx-auto">
+                    <GrowiLogo />
+                    <h1 className="my-3">GROWI</h1>
+                    <div className="noLogin-form-errors px-3"></div>
+                  </div>
+                </div>
+
+                {children}
+
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </RawLayout>
+  );
+};

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

@@ -1,14 +1,18 @@
-import React, { ReactNode, useEffect, useState } from 'react';
+import React, {
+  ReactNode, useCallback, useEffect, useState,
+} from 'react';
 
 
 import Head from 'next/head';
 import Head from 'next/head';
+import Image from 'next/image';
 
 
 import { useGrowiTheme } from '~/stores/context';
 import { useGrowiTheme } from '~/stores/context';
 import { Themes, useNextThemes } from '~/stores/use-next-themes';
 import { Themes, useNextThemes } from '~/stores/use-next-themes';
 
 
+import { getBackgroundImageSrc } from '../Theme/utils/ThemeImageProvider';
 import { ThemeProvider } from '../Theme/utils/ThemeProvider';
 import { ThemeProvider } from '../Theme/utils/ThemeProvider';
 
 
 type Props = {
 type Props = {
-  title: string,
+  title?: string,
   className?: string,
   className?: string,
   children?: ReactNode,
   children?: ReactNode,
 }
 }
@@ -25,12 +29,19 @@ export const RawLayout = ({ children, title, className }: Props): JSX.Element =>
   const { resolvedTheme } = useNextThemes();
   const { resolvedTheme } = useNextThemes();
 
 
   const [colorScheme, setColorScheme] = useState<Themes|undefined>(undefined);
   const [colorScheme, setColorScheme] = useState<Themes|undefined>(undefined);
+  const [backgroundImageSrc, setBackgroundImageSrc] = useState<string | undefined>(undefined);
 
 
   // set colorScheme in CSR
   // set colorScheme in CSR
   useEffect(() => {
   useEffect(() => {
     setColorScheme(resolvedTheme as Themes);
     setColorScheme(resolvedTheme as Themes);
   }, [resolvedTheme]);
   }, [resolvedTheme]);
 
 
+  // set background image
+  useEffect(() => {
+    const imgSrc = getBackgroundImageSrc(growiTheme, colorScheme);
+    setBackgroundImageSrc(imgSrc);
+  }, [growiTheme, colorScheme]);
+
   return (
   return (
     <>
     <>
       <Head>
       <Head>
@@ -40,6 +51,9 @@ export const RawLayout = ({ children, title, className }: Props): JSX.Element =>
       </Head>
       </Head>
       <ThemeProvider theme={growiTheme}>
       <ThemeProvider theme={growiTheme}>
         <div className={classNames.join(' ')} data-color-scheme={colorScheme}>
         <div className={classNames.join(' ')} data-color-scheme={colorScheme}>
+          {backgroundImageSrc != null && <div className="grw-bg-image-wrapper">
+            <Image className='grw-bg-image' alt='background-image' src={backgroundImageSrc} layout='fill' quality="100" />
+          </div>}
           {children}
           {children}
         </div>
         </div>
       </ThemeProvider>
       </ThemeProvider>

+ 1 - 1
packages/app/src/components/LoginForm.jsx

@@ -291,7 +291,7 @@ class LoginForm extends React.Component {
     const isSomeExternalAuthEnabled = true;
     const isSomeExternalAuthEnabled = true;
 
 
     return (
     return (
-      <div className="login-dialog mx-auto" id="login-dialog">
+      <div className="noLogin-dialog mx-auto" id="noLogin-dialog">
         <div className="row mx-0">
         <div className="row mx-0">
           <div className="col-12">
           <div className="col-12">
             <ReactCardFlip isFlipped={this.state.isRegistering} flipDirection="horizontal" cardZIndex="3">
             <ReactCardFlip isFlipped={this.state.isRegistering} flipDirection="horizontal" cardZIndex="3">

+ 56 - 36
packages/app/src/components/Me/AssociateModal.tsx

@@ -6,12 +6,16 @@ import {
   ModalHeader,
   ModalHeader,
   ModalBody,
   ModalBody,
   ModalFooter,
   ModalFooter,
+  Nav,
+  NavLink,
+  TabContent,
+  TabPane,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { usePersonalSettings, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
 import { usePersonalSettings, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
 
 
-import LdapAuthTest from '../Admin/Security/LdapAuthTest';
+import { LdapAuthTest } from '../Admin/Security/LdapAuthTest';
 
 
 type Props = {
 type Props = {
   isOpen: boolean,
   isOpen: boolean,
@@ -22,6 +26,7 @@ const AssociateModal = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { mutate: mutatePersonalExternalAccounts } = useSWRxPersonalExternalAccounts();
   const { mutate: mutatePersonalExternalAccounts } = useSWRxPersonalExternalAccounts();
   const { associateLdapAccount } = usePersonalSettings();
   const { associateLdapAccount } = usePersonalSettings();
+  const [activeTab, setActiveTab] = useState(1);
   const { isOpen, onClose } = props;
   const { isOpen, onClose } = props;
 
 
   const [username, setUsername] = useState('');
   const [username, setUsername] = useState('');
@@ -55,46 +60,61 @@ const AssociateModal = (props: Props): JSX.Element => {
         { t('admin:user_management.create_external_account') }
         { t('admin:user_management.create_external_account') }
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
-        <ul className="nav nav-tabs passport-settings mb-2" role="tablist">
-          <li className="nav-item active">
-            <a href="#passport-ldap" className="nav-link active" data-toggle="tab" role="tab">
+        <div>
+          <Nav tabs className='mb-2'>
+            <NavLink
+              className={activeTab === 1 ? 'active' : ''}
+              onClick={() => setActiveTab(1)}
+            >
               <i className="fa fa-sitemap"></i> LDAP
               <i className="fa fa-sitemap"></i> LDAP
-            </a>
-          </li>
-          <li className="nav-item">
-            <a href="#github-tbd" className="nav-link" data-toggle="tab" role="tab">
+            </NavLink>
+            <NavLink
+              className={activeTab === 2 ? 'active' : ''}
+              onClick={() => setActiveTab(2)}
+            >
               <i className="fa fa-github"></i> (TBD) GitHub
               <i className="fa fa-github"></i> (TBD) GitHub
-            </a>
-          </li>
-          <li className="nav-item">
-            <a href="#google-tbd" className="nav-link" data-toggle="tab" role="tab">
+            </NavLink>
+            <NavLink
+              className={activeTab === 3 ? 'active' : ''}
+              onClick={() => setActiveTab(3)}
+            >
               <i className="fa fa-google"></i> (TBD) Google OAuth
               <i className="fa fa-google"></i> (TBD) Google OAuth
-            </a>
-          </li>
-          <li className="nav-item">
-            <a href="#facebook-tbd" className="nav-link" data-toggle="tab" role="tab">
+            </NavLink>
+            <NavLink
+              className={activeTab === 4 ? 'active' : ''}
+              onClick={() => setActiveTab(4)}
+            >
               <i className="fa fa-facebook"></i> (TBD) Facebook
               <i className="fa fa-facebook"></i> (TBD) Facebook
-            </a>
-          </li>
-          <li className="nav-item">
-            <a href="#twitter-tbd" className="nav-link" data-toggle="tab" role="tab">
+            </NavLink>
+            <NavLink
+              className={activeTab === 5 ? 'active' : ''}
+              onClick={() => setActiveTab(5)}
+            >
               <i className="fa fa-twitter"></i> (TBD) Twitter
               <i className="fa fa-twitter"></i> (TBD) Twitter
-            </a>
-          </li>
-        </ul>
-        <div className="tab-content">
-          <div id="passport-ldap" className="tab-pane active">
-            <LdapAuthTest
-              username={username}
-              password={password}
-              onChangeUsername={username => setUsername(username)}
-              onChangePassword={password => setPassword(password)}
-            />
-          </div>
-          <div id="github-tbd" className="tab-pane" role="tabpanel">TBD</div>
-          <div id="google-tbd" className="tab-pane" role="tabpanel">TBD</div>
-          <div id="facebook-tbd" className="tab-pane" role="tabpanel">TBD</div>
-          <div id="twitter-tbd" className="tab-pane" role="tabpanel">TBD</div>
+            </NavLink>
+          </Nav>
+          <TabContent activeTab={activeTab}>
+            <TabPane tabId={1}>
+              <LdapAuthTest
+                username={username}
+                password={password}
+                onChangeUsername={username => setUsername(username)}
+                onChangePassword={password => setPassword(password)}
+              />
+            </TabPane>
+            <TabPane tabId={2}>
+              TBD
+            </TabPane>
+            <TabPane tabId={3}>
+              TBD
+            </TabPane>
+            <TabPane tabId={4}>
+              TBD
+            </TabPane>
+            <TabPane tabId={5}>
+              TBD
+            </TabPane>
+          </TabContent>
         </div>
         </div>
       </ModalBody>
       </ModalBody>
       <ModalFooter className="border-top-0">
       <ModalFooter className="border-top-0">

+ 22 - 17
packages/app/src/components/Me/BasicInfoSettings.tsx

@@ -1,9 +1,10 @@
 import React from 'react';
 import React from 'react';
 
 
-import { useTranslation } from 'next-i18next';
+import { useTranslation, i18n } from 'next-i18next';
+
+import { i18n as i18nConfig } from '^/config/next-i18next.config';
 
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-// import { localeMetadatas } from '~/client/util/i18n';
 import { useRegistrationWhiteList } from '~/stores/context';
 import { useRegistrationWhiteList } from '~/stores/context';
 import { usePersonalSettings } from '~/stores/personal-settings';
 import { usePersonalSettings } from '~/stores/personal-settings';
 
 
@@ -104,23 +105,27 @@ export const BasicInfoSettings = (): JSX.Element => {
       <div className="form-group row">
       <div className="form-group row">
         <label className="text-left text-md-right col-md-3 col-form-label">{t('Language')}</label>
         <label className="text-left text-md-right col-md-3 col-form-label">{t('Language')}</label>
         <div className="col-md-6">
         <div className="col-md-6">
-          {/*
           {
           {
-            localeMetadatas.map(meta => (
-              <div key={meta.id} className="custom-control custom-radio custom-control-inline">
-                <input
-                  type="radio"
-                  id={`radioLang${meta.id}`}
-                  className="custom-control-input"
-                  name="userForm[lang]"
-                  checked={personalSettingsInfo?.lang === meta.id}
-                  onChange={() => changePersonalSettingsHandler({ lang: meta.id })}
-                />
-                <label className="custom-control-label" htmlFor={`radioLang${meta.id}`}>{meta.displayName}</label>
-              </div>
-            ))
+            i18nConfig.locales.map((locale) => {
+              if (i18n == null) { return }
+              const fixedT = i18n.getFixedT(locale);
+              i18n.loadLanguages(i18nConfig.locales);
+
+              return (
+                <div key={locale} className="custom-control custom-radio custom-control-inline">
+                  <input
+                    type="radio"
+                    id={`radioLang${locale}`}
+                    className="custom-control-input"
+                    name="userForm[lang]"
+                    checked={personalSettingsInfo?.lang === locale}
+                    onChange={() => changePersonalSettingsHandler({ lang: locale })}
+                  />
+                  <label className="custom-control-label" htmlFor={`radioLang${locale}`}>{fixedT('meta.display_name')}</label>
+                </div>
+              );
+            })
           }
           }
-          */}
         </div>
         </div>
       </div>
       </div>
       <div className="form-group row">
       <div className="form-group row">

+ 41 - 19
packages/app/src/components/Page.jsx → packages/app/src/components/Page.tsx

@@ -1,15 +1,15 @@
 import React, {
 import React, {
+  useCallback,
   useEffect, useRef, useState,
   useEffect, useRef, useState,
 } from 'react';
 } from 'react';
 
 
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
-import PropTypes from 'prop-types';
 // import { debounce } from 'throttle-debounce';
 // import { debounce } from 'throttle-debounce';
 
 
 import { blinkSectionHeaderAtBoot } from '~/client/util/blink-section-header';
 import { blinkSectionHeaderAtBoot } from '~/client/util/blink-section-header';
 // import { getOptionsToSave } from '~/client/util/editor';
 // import { getOptionsToSave } from '~/client/util/editor';
 import {
 import {
-  useIsGuestUser, useIsBlinkedHeaderAtBoot,
+  useIsGuestUser, useIsBlinkedHeaderAtBoot, useCurrentPageTocNode,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
@@ -22,6 +22,7 @@ import {
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import RevisionRenderer from './Page/RevisionRenderer';
 import RevisionRenderer from './Page/RevisionRenderer';
+import { HtmlElementNode } from 'rehype-toc';
 
 
 // TODO: import dynamically
 // TODO: import dynamically
 // import MarkdownTable from '~/client/models/MarkdownTable';
 // import MarkdownTable from '~/client/models/MarkdownTable';
@@ -30,9 +31,28 @@ import RevisionRenderer from './Page/RevisionRenderer';
 
 
 const logger = loggerFactory('growi:Page');
 const logger = loggerFactory('growi:Page');
 
 
-class PageSubstance extends React.Component {
+type PageSubstanceProps = {
+  rendererOptions: any,
+  page: any,
+  pageTags?: string[],
+  editorMode: string,
+  isGuestUser: boolean,
+  isMobile?: boolean,
+  isSlackEnabled: boolean,
+  slackChannels: string,
+};
+
+class PageSubstance extends React.Component<PageSubstanceProps> {
+
+  gridEditModal: any;
+
+  linkEditModal: any;
+
+  handsontableModal: any;
+
+  drawioModal: any;
 
 
-  constructor(props) {
+  constructor(props: PageSubstanceProps) {
     super(props);
     super(props);
 
 
     this.state = {
     this.state = {
@@ -138,7 +158,7 @@ class PageSubstance extends React.Component {
     // }
     // }
   }
   }
 
 
-  render() {
+  override render() {
     const {
     const {
       rendererOptions, page, isMobile, isGuestUser,
       rendererOptions, page, isMobile, isGuestUser,
     } = this.props;
     } = this.props;
@@ -171,29 +191,26 @@ class PageSubstance extends React.Component {
 
 
 }
 }
 
 
-PageSubstance.propTypes = {
-  rendererOptions: PropTypes.object.isRequired,
+export const Page = (props) => {
+  // Pass tocRef to generateViewOptions (=> rehypePlugin => customizeTOC) to call mutateCurrentPageTocNode when tocRef.current changes.
+  // The toc node passed by customizeTOC is assigned to tocRef.current.
+  const tocRef = useRef<HtmlElementNode>();
 
 
-  page: PropTypes.any.isRequired,
-  pageTags:  PropTypes.arrayOf(PropTypes.string),
-  editorMode: PropTypes.string.isRequired,
-  isGuestUser: PropTypes.bool.isRequired,
-  isMobile: PropTypes.bool,
-  isSlackEnabled: PropTypes.bool.isRequired,
-  slackChannels: PropTypes.string.isRequired,
-};
+  const storeTocNodeHandler = useCallback((toc: HtmlElementNode) => {
+    tocRef.current = toc;
+  }, []);
 
 
-export const Page = (props) => {
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();
   const { data: isMobile } = useIsMobile();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPage?.path);
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPage?.path);
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: pageTags } = usePageTagsForEditors();
-  const { data: rendererOptions } = useViewOptions();
+  const { data: pageTags } = usePageTagsForEditors(null); // TODO: pass pageId
+  const { data: rendererOptions } = useViewOptions(storeTocNodeHandler);
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { data: isBlinkedAtBoot, mutate: mutateBlinkedAtBoot } = useIsBlinkedHeaderAtBoot();
   const { data: isBlinkedAtBoot, mutate: mutateBlinkedAtBoot } = useIsBlinkedHeaderAtBoot();
+  const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
 
 
   const pageRef = useRef(null);
   const pageRef = useRef(null);
 
 
@@ -206,6 +223,11 @@ export const Page = (props) => {
     mutateBlinkedAtBoot(true);
     mutateBlinkedAtBoot(true);
   }, [isBlinkedAtBoot, mutateBlinkedAtBoot]);
   }, [isBlinkedAtBoot, mutateBlinkedAtBoot]);
 
 
+  useEffect(() => {
+    mutateCurrentPageTocNode(tocRef.current);
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [mutateCurrentPageTocNode, tocRef.current]); // include tocRef.current to call mutateCurrentPageTocNode when tocRef.current changes
+
   // // set handler to open DrawioModal
   // // set handler to open DrawioModal
   // useEffect(() => {
   // useEffect(() => {
   //   const handler = (beginLineNumber, endLineNumber) => {
   //   const handler = (beginLineNumber, endLineNumber) => {
@@ -253,7 +275,7 @@ export const Page = (props) => {
       isMobile={isMobile}
       isMobile={isMobile}
       isSlackEnabled={isSlackEnabled}
       isSlackEnabled={isSlackEnabled}
       pageTags={pageTags}
       pageTags={pageTags}
-      slackChannels={slackChannelsData.toString()}
+      slackChannels={slackChannelsData?.toString()}
       mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
       mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
     />
     />
   );
   );

+ 3 - 3
packages/app/src/components/Page/RevisionRenderer.tsx

@@ -8,7 +8,6 @@ import { CustomWindow } from '~/interfaces/global';
 import { RendererOptions } from '~/services/renderer/renderer';
 import { RendererOptions } from '~/services/renderer/renderer';
 import { useCurrentPathname, useInterceptorManager } from '~/stores/context';
 import { useCurrentPathname, useInterceptorManager } from '~/stores/context';
 import { useEditorSettings } from '~/stores/editor';
 import { useEditorSettings } from '~/stores/editor';
-import { useViewOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 // import RevisionBody from './RevisionBody';
 // import RevisionBody from './RevisionBody';
@@ -95,7 +94,7 @@ type Props = {
   additionalClassName?: string,
   additionalClassName?: string,
 }
 }
 
 
-const RevisionRenderer = (props: Props): JSX.Element => {
+const RevisionRenderer = React.memo((props: Props): JSX.Element => {
 
 
   const {
   const {
     rendererOptions, markdown, pagePath, highlightKeywords, additionalClassName,
     rendererOptions, markdown, pagePath, highlightKeywords, additionalClassName,
@@ -246,6 +245,7 @@ const RevisionRenderer = (props: Props): JSX.Element => {
   //   />
   //   />
   // );
   // );
 
 
-};
+});
+RevisionRenderer.displayName = 'RevisionRenderer';
 
 
 export default RevisionRenderer;
 export default RevisionRenderer;

+ 1 - 1
packages/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -17,7 +17,7 @@ type Props = Omit<LinkProps, 'href'> & {
   children: React.ReactNode,
   children: React.ReactNode,
   href?: string,
   href?: string,
   className?: string,
   className?: string,
-} ;
+};
 
 
 export const NextLink = ({
 export const NextLink = ({
   href, children, className, ...props
   href, children, className, ...props

+ 12 - 1
packages/app/src/components/Theme/ThemeAntarctic.module.scss

@@ -16,7 +16,6 @@
 
 
 .growi:not(.login-page) {
 .growi:not(.login-page) {
   // add background-image
   // add background-image
-  #page-wrapper,
   .page-editor-preview-container {
   .page-editor-preview-container {
     background-image: url('/images/themes/antarctic/bg.svg');
     background-image: url('/images/themes/antarctic/bg.svg');
     background-attachment: fixed;
     background-attachment: fixed;
@@ -34,6 +33,18 @@
   }
   }
 }
 }
 
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+
+  .grw-bg-image {
+    object-fit: cover;
+  }
+}
+
 $themecolor: #000080;
 $themecolor: #000080;
 $themelight: #f0f8ff;
 $themelight: #f0f8ff;
 $accentcolor: #ffd700;
 $accentcolor: #ffd700;

+ 9 - 0
packages/app/src/components/Theme/ThemeAntarctic.tsx

@@ -1,7 +1,16 @@
+import { Themes } from '~/stores/use-next-themes';
+
 import { ThemeInjector } from './utils/ThemeInjector';
 import { ThemeInjector } from './utils/ThemeInjector';
 
 
 import styles from './ThemeAntarctic.module.scss';
 import styles from './ThemeAntarctic.module.scss';
 
 
+export const getBackgroundImageSrc = (colorScheme: Themes): string => {
+  switch (colorScheme) {
+    default:
+      return '/images/themes/antarctic/bg.svg';
+  }
+};
+
 const ThemeAntarctic = ({ children }: { children: JSX.Element }): JSX.Element => {
 const ThemeAntarctic = ({ children }: { children: JSX.Element }): JSX.Element => {
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
 };
 };

+ 14 - 3
packages/app/src/components/Theme/ThemeChristmas.module.scss

@@ -27,7 +27,6 @@ $color-link-wiki-hover: lighten($color-link-wiki, 15%);
 
 
 .growi:not(.login-page) {
 .growi:not(.login-page) {
   // add background-image
   // add background-image
-  #page-wrapper,
   .page-editor-preview-container {
   .page-editor-preview-container {
     background-image: url('/images/themes/christmas/christmas.jpg');
     background-image: url('/images/themes/christmas/christmas.jpg');
     background-attachment: fixed;
     background-attachment: fixed;
@@ -36,6 +35,18 @@ $color-link-wiki-hover: lighten($color-link-wiki, 15%);
   }
   }
 }
 }
 
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+
+  .grw-bg-image {
+    object-fit: cover;
+  }
+}
+
 //== Light Mode
 //== Light Mode
 //
 //
 .theme :global {
 .theme :global {
@@ -135,8 +146,8 @@ $color-link-wiki-hover: lighten($color-link-wiki, 15%);
       }
       }
     }
     }
 
 
-    .login-header,
-    .login-dialog {
+    .noLogin-header,
+    .noLogin-dialog {
       background-color: rgba(#ccc, 0.5);
       background-color: rgba(#ccc, 0.5);
     }
     }
     .link-switch {
     .link-switch {

+ 9 - 0
packages/app/src/components/Theme/ThemeChristmas.tsx

@@ -1,7 +1,16 @@
+import { Themes } from '~/stores/use-next-themes';
+
 import { ThemeInjector } from './utils/ThemeInjector';
 import { ThemeInjector } from './utils/ThemeInjector';
 
 
 import styles from './ThemeChristmas.module.scss';
 import styles from './ThemeChristmas.module.scss';
 
 
+export const getBackgroundImageSrc = (colorScheme: Themes): string => {
+  switch (colorScheme) {
+    default:
+      return '/images/themes/christmas/christmas.jpg';
+  }
+};
+
 const ThemeChristmas = ({ children }: { children: JSX.Element }): JSX.Element => {
 const ThemeChristmas = ({ children }: { children: JSX.Element }): JSX.Element => {
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
 };
 };

+ 12 - 5
packages/app/src/components/Theme/ThemeHalloween.module.scss

@@ -21,17 +21,24 @@ $light: lighten($secondary, 10%);
 // $dark: #;
 // $dark: #;
 
 
 .growi:not(.login-page) {
 .growi:not(.login-page) {
-  #wrapper > .navbar {
-    background-image: url(/images/themes/halloween/halloween-navbar.jpg);
-  }
-
   // add background-image
   // add background-image
-  #page-wrapper,
   .page-editor-preview-container {
   .page-editor-preview-container {
     background-image: url('/images/themes/halloween/halloween.jpg');
     background-image: url('/images/themes/halloween/halloween.jpg');
   }
   }
 }
 }
 
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+
+  .grw-navbar {
+    background-image: url('/images/themes/halloween/halloween-navbar.jpg') !important;
+  }
+}
+
 //== Light Mode
 //== Light Mode
 //
 //
 .theme :global {
 .theme :global {

+ 9 - 0
packages/app/src/components/Theme/ThemeHalloween.tsx

@@ -1,7 +1,16 @@
+import { Themes } from '~/stores/use-next-themes';
+
 import { ThemeInjector } from './utils/ThemeInjector';
 import { ThemeInjector } from './utils/ThemeInjector';
 
 
 import styles from './ThemeHalloween.module.scss';
 import styles from './ThemeHalloween.module.scss';
 
 
+export const getBackgroundImageSrc = (colorScheme: Themes): string => {
+  switch (colorScheme) {
+    default:
+      return '/images/themes/halloween/halloween.jpg';
+  }
+};
+
 const ThemeHalloween = ({ children }: { children: JSX.Element }): JSX.Element => {
 const ThemeHalloween = ({ children }: { children: JSX.Element }): JSX.Element => {
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
 };
 };

+ 17 - 6
packages/app/src/components/Theme/ThemeHufflepuff.module.scss

@@ -18,6 +18,19 @@
 //   border-bottom: $accentcolor 4px solid;
 //   border-bottom: $accentcolor 4px solid;
 // }
 // }
 
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+
+  .grw-bg-image {
+    object-fit: cover;
+    object-position: bottom;
+  }
+}
+
 //== Light Mode
 //== Light Mode
 //
 //
 .theme[data-color-scheme='light'] :global {
 .theme[data-color-scheme='light'] :global {
@@ -107,7 +120,6 @@
 
 
   .growi:not(.login-page) {
   .growi:not(.login-page) {
     // add background-image
     // add background-image
-    #page-wrapper,
     .page-editor-preview-container {
     .page-editor-preview-container {
       background-image: url('/images/themes/hufflepuff/badger-light3.png');
       background-image: url('/images/themes/hufflepuff/badger-light3.png');
       background-attachment: fixed;
       background-attachment: fixed;
@@ -126,8 +138,8 @@
       background-size: cover;
       background-size: cover;
     }
     }
 
 
-    .login-header,
-    .login-dialog {
+    .noLogin-header,
+    .noLogin-dialog {
       background-color: rgba(black, 0.1);
       background-color: rgba(black, 0.1);
     }
     }
 
 
@@ -275,7 +287,6 @@
 
 
   .growi:not(.login-page) {
   .growi:not(.login-page) {
     // add background-image
     // add background-image
-    #page-wrapper,
     .page-editor-preview-container {
     .page-editor-preview-container {
       background-image: url('/images/themes/hufflepuff/badger-dark.jpg');
       background-image: url('/images/themes/hufflepuff/badger-dark.jpg');
       background-attachment: fixed;
       background-attachment: fixed;
@@ -294,8 +305,8 @@
       background-size: cover;
       background-size: cover;
     }
     }
 
 
-    .login-header,
-    .login-dialog {
+    .noLogin-header,
+    .noLogin-dialog {
       background-color: rgba(black, 0.1);
       background-color: rgba(black, 0.1);
     }
     }
 
 

+ 13 - 0
packages/app/src/components/Theme/ThemeHufflepuff.tsx

@@ -1,7 +1,20 @@
+import { Themes } from '~/stores/use-next-themes';
+
 import { ThemeInjector } from './utils/ThemeInjector';
 import { ThemeInjector } from './utils/ThemeInjector';
 
 
 import styles from './ThemeHufflepuff.module.scss';
 import styles from './ThemeHufflepuff.module.scss';
 
 
+export const getBackgroundImageSrc = (colorScheme: Themes): string => {
+  switch (colorScheme) {
+    case Themes.light:
+      return '/images/themes/hufflepuff/badger-light3.png';
+    case Themes.dark:
+      return '/images/themes/hufflepuff/badger-dark.jpg';
+    default:
+      return '/images/themes/hufflepuff/badger-light3.png';
+  }
+};
+
 const ThemeHufflepuff = ({ children }: { children: JSX.Element }): JSX.Element => {
 const ThemeHufflepuff = ({ children }: { children: JSX.Element }): JSX.Element => {
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
 };
 };

+ 8 - 1
packages/app/src/components/Theme/ThemeIsland.module.scss

@@ -6,6 +6,14 @@
 $color-primary: #97cbc3;
 $color-primary: #97cbc3;
 $color-themelight: rgba(183, 226, 219, 1);
 $color-themelight: rgba(183, 226, 219, 1);
 
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+}
+
 .theme :global {
 .theme :global {
   $primary: $color-primary;
   $primary: $color-primary;
   // Background colors
   // Background colors
@@ -89,7 +97,6 @@ $color-themelight: rgba(183, 226, 219, 1);
     background: lighten($color-themelight, 5%);
     background: lighten($color-themelight, 5%);
   }
   }
 
 
-  #wrapper > #page-wrapper,
   .page-editor-preview-container {
   .page-editor-preview-container {
     background-image: url('/images/themes/island/island.png');
     background-image: url('/images/themes/island/island.png');
     background-attachment: fixed;
     background-attachment: fixed;

+ 9 - 0
packages/app/src/components/Theme/ThemeIsland.tsx

@@ -1,7 +1,16 @@
+import { Themes } from '~/stores/use-next-themes';
+
 import { ThemeInjector } from './utils/ThemeInjector';
 import { ThemeInjector } from './utils/ThemeInjector';
 
 
 import styles from './ThemeIsland.module.scss';
 import styles from './ThemeIsland.module.scss';
 
 
+export const getBackgroundImageSrc = (colorScheme: Themes): string => {
+  switch (colorScheme) {
+    default:
+      return '/images/themes/island/island.png';
+  }
+};
+
 const ThemeIsland = ({ children }: { children: JSX.Element }): JSX.Element => {
 const ThemeIsland = ({ children }: { children: JSX.Element }): JSX.Element => {
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
 };
 };

+ 15 - 3
packages/app/src/components/Theme/ThemeSpring.module.scss

@@ -25,6 +25,19 @@ $accentcolor: #e08dbc;
   border-bottom: $accentcolor 4px solid;
   border-bottom: $accentcolor 4px solid;
 }
 }
 
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+
+  .grw-bg-image {
+    object-fit: cover;
+    object-position: bottom;
+  }
+}
+
 //== Light Mode
 //== Light Mode
 //
 //
 .theme :global {
 .theme :global {
@@ -107,7 +120,6 @@ $accentcolor: #e08dbc;
 
 
   .growi:not(.login-page) {
   .growi:not(.login-page) {
     // add background-image
     // add background-image
-    #page-wrapper,
     .page-editor-preview-container {
     .page-editor-preview-container {
       background-image: url('/images/themes/spring/spring02.svg');
       background-image: url('/images/themes/spring/spring02.svg');
       background-attachment: fixed;
       background-attachment: fixed;
@@ -126,8 +138,8 @@ $accentcolor: #e08dbc;
       background-size: cover;
       background-size: cover;
     }
     }
 
 
-    .login-header,
-    .login-dialog {
+    .noLogin-header,
+    .noLogin-dialog {
       background-color: rgba(black, 0.1);
       background-color: rgba(black, 0.1);
     }
     }
 
 

+ 9 - 0
packages/app/src/components/Theme/ThemeSpring.tsx

@@ -1,7 +1,16 @@
+import { Themes } from '~/stores/use-next-themes';
+
 import { ThemeInjector } from './utils/ThemeInjector';
 import { ThemeInjector } from './utils/ThemeInjector';
 
 
 import styles from './ThemeSpring.module.scss';
 import styles from './ThemeSpring.module.scss';
 
 
+export const getBackgroundImageSrc = (colorScheme: Themes): string => {
+  switch (colorScheme) {
+    default:
+      return '/images/themes/spring/spring02.svg';
+  }
+};
+
 const ThemeSpring = ({ children }: { children: JSX.Element }): JSX.Element => {
 const ThemeSpring = ({ children }: { children: JSX.Element }): JSX.Element => {
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
 };
 };

+ 15 - 3
packages/app/src/components/Theme/ThemeWood.module.scss

@@ -14,7 +14,6 @@
 
 
 .growi:not(.login-page) {
 .growi:not(.login-page) {
   // add background-image
   // add background-image
-  #page-wrapper,
   .page-editor-preview-container {
   .page-editor-preview-container {
     background-image: url('/images/themes/wood/wood.jpg');
     background-image: url('/images/themes/wood/wood.jpg');
     background-attachment: fixed;
     background-attachment: fixed;
@@ -35,6 +34,19 @@
 $themecolor: #b9b177;
 $themecolor: #b9b177;
 $themelight: #f5f3ee;
 $themelight: #f5f3ee;
 
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+
+  .grw-bg-image {
+    object-fit: cover;
+    object-position: center center;
+  }
+}
+
 //== Light Mode
 //== Light Mode
 //
 //
 .theme :global {
 .theme :global {
@@ -147,8 +159,8 @@ $themelight: #f5f3ee;
   .nologin {
   .nologin {
     background: white;
     background: white;
 
 
-    .login-header,
-    .login-dialog {
+    .noLogin-header,
+    .noLogin-dialog {
       background-color: rgba(black, 0.1);
       background-color: rgba(black, 0.1);
     }
     }
 
 

+ 9 - 0
packages/app/src/components/Theme/ThemeWood.tsx

@@ -1,7 +1,16 @@
+import { Themes } from '~/stores/use-next-themes';
+
 import { ThemeInjector } from './utils/ThemeInjector';
 import { ThemeInjector } from './utils/ThemeInjector';
 
 
 import styles from './ThemeWood.module.scss';
 import styles from './ThemeWood.module.scss';
 
 
+export const getBackgroundImageSrc = (colorScheme: Themes): string => {
+  switch (colorScheme) {
+    default:
+      return '/images/themes/wood/wood.jpg';
+  }
+};
+
 const ThemeWood = ({ children }: { children: JSX.Element }): JSX.Element => {
 const ThemeWood = ({ children }: { children: JSX.Element }): JSX.Element => {
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
   return <ThemeInjector className={styles.theme}>{children}</ThemeInjector>;
 };
 };

+ 34 - 0
packages/app/src/components/Theme/utils/ThemeImageProvider.tsx

@@ -0,0 +1,34 @@
+import { GrowiThemes } from '~/interfaces/theme';
+import { Themes } from '~/stores/use-next-themes';
+
+import { getBackgroundImageSrc as getAntarcticBackgroundImageSrc } from '../ThemeAntarctic';
+import { getBackgroundImageSrc as getChristmasBackgroundImageSrc } from '../ThemeChristmas';
+import { getBackgroundImageSrc as getHalloweenBackgroundImageSrc } from '../ThemeHalloween';
+import { getBackgroundImageSrc as getHuffulePuffBackgroundImageSrc } from '../ThemeHufflepuff';
+import { getBackgroundImageSrc as getIslandBackgroundImageSrc } from '../ThemeIsland';
+import { getBackgroundImageSrc as getSpringBackgroundImageSrc } from '../ThemeSpring';
+import { getBackgroundImageSrc as getWoodBackgroundImageSrc } from '../ThemeWood';
+
+export const getBackgroundImageSrc = (theme: GrowiThemes | undefined, colorScheme: Themes | undefined): string | undefined => {
+  if (theme == null || colorScheme == null) {
+    return undefined;
+  }
+  switch (theme) {
+    case GrowiThemes.ANTARCTIC:
+      return getAntarcticBackgroundImageSrc(colorScheme);
+    case GrowiThemes.CHRISTMAS:
+      return getChristmasBackgroundImageSrc(colorScheme);
+    case GrowiThemes.HALLOWEEN:
+      return getHalloweenBackgroundImageSrc(colorScheme);
+    case GrowiThemes.ISLAND:
+      return getIslandBackgroundImageSrc(colorScheme);
+    case GrowiThemes.HUFFLEPUFF:
+      return getHuffulePuffBackgroundImageSrc(colorScheme);
+    case GrowiThemes.SPRING:
+      return getSpringBackgroundImageSrc(colorScheme);
+    case GrowiThemes.WOOD:
+      return getWoodBackgroundImageSrc(colorScheme);
+    default:
+      return undefined;
+  }
+};

+ 7 - 0
packages/app/src/interfaces/ldap.ts

@@ -0,0 +1,7 @@
+export interface IResTestLdap {
+  err?: any,
+  message: string,
+  status: string,
+  ldapConfiguration?: any,
+  ldapAccountInfo?: any,
+}

+ 15 - 1
packages/app/src/interfaces/user-group-response.ts

@@ -1,5 +1,9 @@
-import { IUserGroupHasId, IUserGroupRelationHasId } from './user';
+import { HasObjectId, Ref } from '@growi/core';
+
 import { IPageHasId } from './page';
 import { IPageHasId } from './page';
+import {
+  IUser, IUserGroup, IUserGroupHasId, IUserGroupRelationHasId,
+} from './user';
 
 
 export type UserGroupResult = {
 export type UserGroupResult = {
   userGroup: IUserGroupHasId,
   userGroup: IUserGroupHasId,
@@ -18,6 +22,16 @@ export type UserGroupRelationListResult = {
   userGroupRelations: IUserGroupRelationHasId[],
   userGroupRelations: IUserGroupRelationHasId[],
 };
 };
 
 
+export type IUserGroupRelationHasIdPopulatedUser = {
+  relatedGroup: Ref<IUserGroup>,
+  relatedUser: IUser & HasObjectId,
+  createdAt: Date,
+} & HasObjectId;
+
+export type UserGroupRelationsResult = {
+  userGroupRelations: IUserGroupRelationHasIdPopulatedUser[],
+};
+
 export type UserGroupPagesResult = {
 export type UserGroupPagesResult = {
   pages: IPageHasId[],
   pages: IPageHasId[],
 }
 }

+ 7 - 0
packages/app/src/interfaces/user-group.ts

@@ -0,0 +1,7 @@
+export const SearchTypes = {
+  FORWARD: 'forward',
+  PARTIAL: 'partial',
+  BACKWORD: 'backword',
+} as const;
+
+export type SearchType = typeof SearchTypes[keyof typeof SearchTypes];

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

@@ -283,7 +283,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
       </Head>
       </Head>
       {/* <BasicLayout title={useCustomTitle(props, t('GROWI'))} className={classNames.join(' ')}> */}
       {/* <BasicLayout title={useCustomTitle(props, t('GROWI'))} className={classNames.join(' ')}> */}
       <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')} expandContainer={props.isContainerFluid}>
       <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')} expandContainer={props.isContainerFluid}>
-        <header className="py-0">
+        <header className="py-0 position-relative">
           <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
           <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
         </header>
         </header>
         <div className="d-edit-none">
         <div className="d-edit-none">

+ 27 - 16
packages/app/src/pages/admin/[[...path]].page.tsx

@@ -1,6 +1,6 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 
 
-import { isClient } from '@growi/core';
+import { isClient, objectIdUtils } from '@growi/core';
 import {
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 } from 'next';
@@ -27,7 +27,6 @@ import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityConta
 import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
 import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
 import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { SupportedActionType } from '~/interfaces/activity';
 import { SupportedActionType } from '~/interfaces/activity';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CrowiRequest } from '~/interfaces/crowi-request';
@@ -57,13 +56,12 @@ const SlackIntegration = dynamic(() => import('../../components/Admin/SlackInteg
 const LegacySlackIntegration = dynamic(() => import('../../components/Admin/LegacySlackIntegration/LegacySlackIntegration'), { ssr: false });
 const LegacySlackIntegration = dynamic(() => import('../../components/Admin/LegacySlackIntegration/LegacySlackIntegration'), { ssr: false });
 const UserManagement = dynamic(() => import('../../components/Admin/UserManagement'), { ssr: false });
 const UserManagement = dynamic(() => import('../../components/Admin/UserManagement'), { ssr: false });
 const ManageExternalAccount = dynamic(() => import('../../components/Admin/ManageExternalAccount'), { ssr: false });
 const ManageExternalAccount = dynamic(() => import('../../components/Admin/ManageExternalAccount'), { ssr: false });
-const UserGroupPage = dynamic(() => import('../../components/Admin/UserGroup/UserGroupPage'), { ssr: false });
 const ElasticsearchManagement = dynamic(() => import('../../components/Admin/ElasticsearchManagement/ElasticsearchManagement'), { ssr: false });
 const ElasticsearchManagement = dynamic(() => import('../../components/Admin/ElasticsearchManagement/ElasticsearchManagement'), { ssr: false });
-// named export
-const AuditLogManagement = dynamic(() => import('../../components/Admin/AuditLogManagement').then(module => module.AuditLogManagement));
-
-
+const UserGroupDetailPage = dynamic(() => import('../../components/Admin/UserGroupDetail/UserGroupDetailPage'), { ssr: false });
 const AdminLayout = dynamic(() => import('../../components/Layout/AdminLayout'), { ssr: false });
 const AdminLayout = dynamic(() => import('../../components/Layout/AdminLayout'), { ssr: false });
+// named export
+const UserGroupPage = dynamic(() => import('../../components/Admin/UserGroup/UserGroupPage').then(mod => mod.UserGroupPage), { ssr: false });
+const AuditLogManagement = dynamic(() => import('../../components/Admin/AuditLogManagement').then(mod => mod.AuditLogManagement), { ssr: false });
 
 
 const pluginUtils = new PluginUtils();
 const pluginUtils = new PluginUtils();
 
 
@@ -92,6 +90,17 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
   const { path } = router.query;
   const { path } = router.query;
   const pagePathKeys: string[] = Array.isArray(path) ? path : ['home'];
   const pagePathKeys: string[] = Array.isArray(path) ? path : ['home'];
 
 
+  /*
+  * Set userGroupId as a adminPagesMap key
+  * eg) In case that url is `/user-group-detail/62e8388a9a649bea5e703ef7`, userGroupId will be 62e8388a9a649bea5e703ef7
+  */
+  let userGroupId;
+  const [firstPath, secondPath] = pagePathKeys;
+  if (firstPath === 'user-group-detail') {
+    userGroupId = objectIdUtils.isValidObjectId(secondPath) ? secondPath : undefined;
+  }
+
+  // TODO: refactoring adminPagesMap => https://redmine.weseek.co.jp/issues/102694
   const adminPagesMap = {
   const adminPagesMap = {
     home: {
     home: {
       title: useCustomTitle(props, t('Wiki Management Home Page')),
       title: useCustomTitle(props, t('Wiki Management Home Page')),
@@ -154,6 +163,12 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
       title: useCustomTitle(props, t('UserGroup Management')),
       title: useCustomTitle(props, t('UserGroup Management')),
       component: <UserGroupPage />,
       component: <UserGroupPage />,
     },
     },
+    'user-group-detail': {
+      [userGroupId]: {
+        title: t('UserGroup Management'),
+        component: <UserGroupDetailPage userGroupId={userGroupId} />,
+      },
+    },
     search: {
     search: {
       title: useCustomTitle(props, t('Full Text Search Management')),
       title: useCustomTitle(props, t('Full Text Search Management')),
       component: <ElasticsearchManagement />,
       component: <ElasticsearchManagement />,
@@ -164,17 +179,16 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
     },
     },
   };
   };
 
 
-  const getTargetPageToRender = (pagesMap, keys) => {
+  const getTargetPageToRender = (pagesMap, keys): {title: string, component: JSX.Element} => {
     return keys.reduce((pagesMap, key) => {
     return keys.reduce((pagesMap, key) => {
       return pagesMap[key];
       return pagesMap[key];
     }, pagesMap);
     }, pagesMap);
   };
   };
 
 
-  const targetPage: {title: string, component: JSX.Element} = getTargetPageToRender(adminPagesMap, pagePathKeys);
-  const title = targetPage.title;
+  const targetPage = getTargetPageToRender(adminPagesMap, pagePathKeys);
 
 
   useCurrentUser(props.currentUser != null ? JSON.parse(props.currentUser) : null);
   useCurrentUser(props.currentUser != null ? JSON.parse(props.currentUser) : null);
-  useIsMailerSetup(props.isMailerSetup);
+  // useIsMailerSetup(props.isMailerSetup);
 
 
   // useSearchServiceConfigured(props.isSearchServiceConfigured);
   // useSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
@@ -200,7 +214,6 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
     const adminNotificationContainer = new AdminNotificationContainer();
     const adminNotificationContainer = new AdminNotificationContainer();
     const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer();
     const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer();
     const adminMarkDownContainer = new AdminMarkDownContainer();
     const adminMarkDownContainer = new AdminMarkDownContainer();
-    const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer();
 
 
     injectableContainers.push(
     injectableContainers.push(
       adminAppContainer,
       adminAppContainer,
@@ -212,7 +225,6 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
       adminNotificationContainer,
       adminNotificationContainer,
       adminSlackIntegrationLegacyContainer,
       adminSlackIntegrationLegacyContainer,
       adminMarkDownContainer,
       adminMarkDownContainer,
-      adminUserGroupDetailContainer,
     );
     );
   }
   }
 
 
@@ -246,13 +258,12 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
         adminTwitterSecurityContainer,
         adminTwitterSecurityContainer,
       );
       );
     }
     }
-
   }
   }
 
 
 
 
   return (
   return (
     <Provider inject={[...injectableContainers, ...adminSecurityContainers]}>
     <Provider inject={[...injectableContainers, ...adminSecurityContainers]}>
-      <AdminLayout title={title} selectedNavOpt={pagePathKeys[0]}>
+      <AdminLayout title={targetPage.title} selectedNavOpt={firstPath}>
         {targetPage.component}
         {targetPage.component}
       </AdminLayout>
       </AdminLayout>
     </Provider>
     </Provider>

+ 7 - 30
packages/app/src/pages/installer.page.tsx

@@ -6,7 +6,7 @@ import {
 } from 'next';
 } from 'next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 
 
-import { RawLayout } from '~/components/Layout/RawLayout';
+import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
 
 
 import InstallerForm from '../components/InstallerForm';
 import InstallerForm from '../components/InstallerForm';
 import {
 import {
@@ -46,36 +46,13 @@ const InstallerPage: NextPage<Props> = (props: Props) => {
   const classNames: string[] = [];
   const classNames: string[] = [];
 
 
   return (
   return (
-    <>
-      <RawLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
-        <div className='nologin'>
-          <div id="wrapper">
-            <div id="page-wrapper">
-              <div className="main container-fluid">
-
-                <div className="row">
-
-                  <div className="col-md-12">
-                    <div className="login-header mx-auto">
-                      <div className="logo"></div>
-                      <h1 className="my-3">GROWI</h1>
-                      <div className="login-form-errors px-3"></div>
-                    </div>
-                  </div>
-
-                  <div className="col-md-12">
-                    <div id="installer-form-container">
-                      <InstallerForm />
-                    </div>
-                  </div>
-
-                </div>
-              </div>
-            </div>
-          </div>
+    <NoLoginLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
+      <div className="col-md-12">
+        <div id="installer-form-container">
+          <InstallerForm />
         </div>
         </div>
-      </RawLayout>
-    </>
+      </div>
+    </NoLoginLayout>
   );
   );
 };
 };
 
 

+ 8 - 15
packages/app/src/pages/login.page.tsx

@@ -1,13 +1,12 @@
 import React from 'react';
 import React from 'react';
 
 
 
 
-import { pagePathUtils } from '@growi/core';
 import {
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 } from 'next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 
 
-import { RawLayout } from '~/components/Layout/RawLayout';
+import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 
 
 import {
 import {
@@ -36,25 +35,19 @@ const LoginPage: NextPage<Props> = (props: Props) => {
   // page
   // page
   useCurrentPathname(props.currentPathname);
   useCurrentPathname(props.currentPathname);
 
 
-  const classNames: string[] = [];
+  const classNames: string[] = ['login-page'];
 
 
   const LoginForm = dynamic(() => import('~/components/LoginForm'), {
   const LoginForm = dynamic(() => import('~/components/LoginForm'), {
     ssr: false,
     ssr: false,
   });
   });
 
 
   return (
   return (
-    <>
-      <RawLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
-        <div className='nologin'>
-          <div id='wrapper'>
-            <div id="page-wrapper">
-              <LoginForm objOfIsExternalAuthEnableds={props.enabledStrategies} isLocalStrategySetup={true} isLdapStrategySetup={true}
-                isRegistrationEnabled={true} registrationWhiteList={props.registrationWhiteList} isPasswordResetEnabled={true} />
-            </div>
-          </div>
-        </div>
-      </RawLayout>
-    </>
+    <NoLoginLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
+      <div className="col-md-12">
+        <LoginForm objOfIsExternalAuthEnableds={props.enabledStrategies} isLocalStrategySetup={true} isLdapStrategySetup={true}
+          isRegistrationEnabled={true} registrationWhiteList={props.registrationWhiteList} isPasswordResetEnabled={true} />
+      </div>
+    </NoLoginLayout>
   );
   );
 };
 };
 
 

+ 4 - 2
packages/app/src/pages/me.page.tsx

@@ -139,7 +139,7 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
 
 
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
   const req = context.req as CrowiRequest<IUserHasId & any>;
   const req = context.req as CrowiRequest<IUserHasId & any>;
-  const { user } = req;
+  const { user, crowi } = req;
 
 
   const result = await getServerSideCommonProps(context);
   const result = await getServerSideCommonProps(context);
 
 
@@ -153,7 +153,9 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   const props: Props = result.props as Props;
   const props: Props = result.props as Props;
 
 
   if (user != null) {
   if (user != null) {
-    props.currentUser = user.toObject();
+    const User = crowi.model('User');
+    const userData = await User.findById(req.user.id).populate({ path: 'imageAttachment', select: 'filePathProxied' });
+    props.currentUser = userData.toObject();
   }
   }
 
 
   await injectUserUISettings(context, props);
   await injectUserUISettings(context, props);

+ 6 - 5
packages/app/src/server/crowi/index.js

@@ -3,6 +3,7 @@ import http from 'http';
 import path from 'path';
 import path from 'path';
 
 
 import { createTerminus } from '@godaddy/terminus';
 import { createTerminus } from '@godaddy/terminus';
+import lsxRoutes from '@growi/plugin-lsx/server/routes';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 import next from 'next';
 import next from 'next';
 
 
@@ -33,7 +34,6 @@ import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '../util/m
 const logger = loggerFactory('growi:crowi');
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
 const httpErrorHandler = require('../middlewares/http-error-handler');
 const models = require('../models');
 const models = require('../models');
-const PluginService = require('../plugins/plugin.service');
 
 
 const sep = path.sep;
 const sep = path.sep;
 
 
@@ -434,10 +434,6 @@ Crowi.prototype.start = async function() {
 
 
   const { express, configManager } = this;
   const { express, configManager } = this;
 
 
-  // setup plugins
-  this.pluginService = new PluginService(this, express);
-  await this.pluginService.autoDetectAndLoadPlugins();
-
   const app = (this.node_env === 'development') ? this.crowiDev.setupServer(express) : express;
   const app = (this.node_env === 'development') ? this.crowiDev.setupServer(express) : express;
 
 
   const httpServer = http.createServer(app);
   const httpServer = http.createServer(app);
@@ -465,6 +461,7 @@ Crowi.prototype.start = async function() {
   }
   }
 
 
   // setup Express Routes
   // setup Express Routes
+  this.setupRoutesForPlugins();
   this.setupRoutesAtLast();
   this.setupRoutesAtLast();
 
 
   // setup Global Error Handlers
   // setup Global Error Handlers
@@ -515,6 +512,10 @@ Crowi.prototype.setupTerminus = function(server) {
   });
   });
 };
 };
 
 
+Crowi.prototype.setupRoutesForPlugins = function() {
+  lsxRoutes(this, this.express);
+};
+
 /**
 /**
  * setup Express Routes
  * setup Express Routes
  * !! this must be at last because it includes '/*' route !!
  * !! this must be at last because it includes '/*' route !!

+ 0 - 40
packages/app/src/server/plugins/plugin-utils-v2.js

@@ -1,40 +0,0 @@
-const path = require('path');
-
-class PluginUtilsV2 {
-
-  /**
-   * return a definition objects that has following structure:
-   *
-   * {
-   *   name: 'crowi-plugin-X',
-   *   meta: require('crowi-plugin-X'),
-   *   entries: [
-   *     'crowi-plugin-X/lib/client-entry'
-   *   ]
-   * }
-   *
-   *
-   * @param {string} pluginName
-   * @return
-   * @memberOf PluginService
-   */
-  generatePluginDefinition(name, isForClient = false) {
-    const meta = require(name);
-    let entries = (isForClient) ? meta.clientEntries : meta.serverEntries;
-
-    entries = entries.map((entryPath) => {
-      const moduleRoot = path.resolve(require.resolve(`${name}/package.json`), '..');
-      const entryRelativePath = path.relative(moduleRoot, entryPath);
-      return path.join(name, entryRelativePath);
-    });
-
-    return {
-      name,
-      meta,
-      entries,
-    };
-  }
-
-}
-
-module.exports = PluginUtilsV2;

+ 0 - 38
packages/app/src/server/plugins/plugin-utils-v4.ts

@@ -1,38 +0,0 @@
-import path from 'path';
-
-import { PluginMetaV4, PluginDefinitionV4 } from '@growi/core';
-
-export class PluginUtilsV4 {
-
-  /**
-   * return a definition objects that has following structure:
-   *
-   * {
-   *   name: 'crowi-plugin-X',
-   *   meta: require('crowi-plugin-X'),
-   *   entries: [
-   *     'crowi-plugin-X/lib/client-entry'
-   *   ]
-   * }
-   *
-   *
-   * @param {string} pluginName
-   * @return
-   * @memberOf PluginService
-   */
-  async generatePluginDefinition(name: string, isForClient = false): Promise<PluginDefinitionV4> {
-    const meta: PluginMetaV4 = await import(name);
-    let entries = (isForClient) ? meta.clientEntries : meta.serverEntries;
-
-    entries = entries.map((entryPath) => {
-      return path.join(name, entryPath);
-    });
-
-    return {
-      name,
-      meta,
-      entries,
-    };
-  }
-
-}

+ 1 - 44
packages/app/src/server/plugins/plugin-utils.js

@@ -1,57 +1,14 @@
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 
 
-import { PluginUtilsV4 } from './plugin-utils-v4';
+// import { PluginUtilsV4 } from './plugin-utils-v4';
 
 
 const fs = require('graceful-fs');
 const fs = require('graceful-fs');
 
 
 const logger = loggerFactory('growi:plugins:plugin-utils');
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
 
-const pluginUtilsV4 = new PluginUtilsV4();
-
 class PluginUtils {
 class PluginUtils {
 
 
-  /**
-   * return a definition objects that has following structure:
-   *
-   * {
-   *   name: 'growi-plugin-X',
-   *   meta: require('growi-plugin-X'),
-   *   entries: [
-   *     'growi-plugin-X/lib/client-entry'
-   *   ]
-   * }
-   *
-   * @param {string} pluginName
-   * @return
-   * @memberOf PluginService
-   */
-  async generatePluginDefinition(name, isForClient = false) {
-    const meta = require(name);
-    let definition;
-
-    switch (meta.pluginSchemaVersion) {
-      // v1, v2 and v3 is deprecated
-      case 1:
-        logger.debug('pluginSchemaVersion 1 is deprecated');
-        break;
-      case 2:
-        logger.debug('pluginSchemaVersion 2 is deprecated');
-        break;
-      case 3:
-        logger.debug('pluginSchemaVersion 3 is deprecated');
-        break;
-      // v4 or above
-      case 4:
-        definition = await pluginUtilsV4.generatePluginDefinition(name, isForClient);
-        break;
-      default:
-        logger.warn('Unsupported schema version', meta.pluginSchemaVersion);
-    }
-
-    return definition;
-  }
-
   /**
   /**
    * list plugin module objects
    * list plugin module objects
    *  that starts with 'growi-plugin-' or 'crowi-plugin-'
    *  that starts with 'growi-plugin-' or 'crowi-plugin-'

+ 0 - 72
packages/app/src/server/plugins/plugin.service.js

@@ -1,72 +0,0 @@
-import loggerFactory from '~/utils/logger';
-
-const PluginUtils = require('./plugin-utils');
-
-const logger = loggerFactory('growi:plugins:PluginService');
-
-class PluginService {
-
-  constructor(crowi, app) {
-    this.crowi = crowi;
-    this.app = app;
-    this.pluginUtils = new PluginUtils();
-  }
-
-  async autoDetectAndLoadPlugins() {
-    const isEnabledPlugins = this.crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins');
-
-    // import plugins
-    if (isEnabledPlugins) {
-      logger.debug('Plugins are enabled');
-      return this.loadPlugins(this.pluginUtils.listPluginNames(this.crowi.rootDir));
-    }
-
-  }
-
-  /**
-   * load plugins
-   *
-   * @memberOf PluginService
-   */
-  async loadPlugins(pluginNames) {
-    // get definitions
-    const definitions = [];
-    for (const pluginName of pluginNames) {
-      // eslint-disable-next-line no-await-in-loop
-      const definition = await this.pluginUtils.generatePluginDefinition(pluginName);
-      if (definition != null) {
-        this.loadPlugin(definition);
-      }
-    }
-  }
-
-  loadPlugin(definition) {
-    const meta = definition.meta;
-
-    switch (meta.pluginSchemaVersion) {
-      // v1, v2 and v3 is deprecated
-      case 1:
-        logger.warn('pluginSchemaVersion 1 is deprecated', definition);
-        break;
-      case 2:
-        logger.warn('pluginSchemaVersion 2 is deprecated', definition);
-        break;
-      case 3:
-        logger.warn('pluginSchemaVersion 3 is deprecated', definition);
-        break;
-      // v4 or above
-      case 4:
-        logger.info(`load plugin '${definition.name}'`);
-        definition.entries.forEach((entryPath) => {
-          const entry = require(entryPath);
-          entry(this.crowi, this.app);
-        });
-        break;
-      default:
-        logger.warn('Unsupported schema version', meta.pluginSchemaVersion);
-    }
-  }
-
-}
-
-module.exports = PluginService;

+ 7 - 4
packages/app/src/server/routes/admin.js

@@ -524,10 +524,13 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success());
     return res.json(ApiResponse.success());
   };
   };
 
 
-  actions.notFound = {};
-  actions.notFound.index = function(req, res) {
-    return res.render('admin/not_found');
-  };
+  /*
+  * Use AdminNotFoundPage component instead
+  */
+  // actions.notFound = {};
+  // actions.notFound.index = function(req, res) {
+  //   return res.render('admin/not_found');
+  // };
 
 
   return actions;
   return actions;
 };
 };

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

@@ -765,7 +765,6 @@ module.exports = (crowi) => {
     try {
     try {
       const userGroup = await UserGroup.findById(id);
       const userGroup = await UserGroup.findById(id);
       const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
       const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
-
       return res.apiv3({ userGroupRelations });
       return res.apiv3({ userGroupRelations });
     }
     }
     catch (err) {
     catch (err) {

+ 7 - 5
packages/app/src/server/routes/ogp.ts

@@ -1,15 +1,17 @@
+import * as fs from 'fs';
+import path from 'path';
+
+import { DevidedPagePath } from '@growi/core';
+// eslint-disable-next-line no-restricted-imports
+import axios from 'axios';
 import {
 import {
   Request, Response, NextFunction,
   Request, Response, NextFunction,
 } from 'express';
 } from 'express';
 import { param, validationResult, ValidationError } from 'express-validator';
 import { param, validationResult, ValidationError } from 'express-validator';
 
 
-import path from 'path';
-import * as fs from 'fs';
-
-import { DevidedPagePath } from '@growi/core';
-import axios from '~/utils/axios';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 import { projectRoot } from '~/utils/project-dir-utils';
+
 import { convertStreamToBuffer } from '../util/stream';
 import { convertStreamToBuffer } from '../util/stream';
 
 
 const logger = loggerFactory('growi:routes:ogp');
 const logger = loggerFactory('growi:routes:ogp');

+ 2 - 1
packages/app/src/server/service/s2s-messaging/nchan.ts

@@ -1,9 +1,10 @@
 import path from 'path';
 import path from 'path';
 
 
+// eslint-disable-next-line no-restricted-imports
+import axios from 'axios';
 import ReconnectingWebSocket from 'reconnecting-websocket';
 import ReconnectingWebSocket from 'reconnecting-websocket';
 import WebSocket from 'ws';
 import WebSocket from 'ws';
 
 
-import axios from '~/utils/axios';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import S2sMessage from '../../models/vo/s2s-message';
 import S2sMessage from '../../models/vo/s2s-message';

+ 3 - 2
packages/app/src/server/views/admin/not_found.html

@@ -1,7 +1,8 @@
-{% extends '../layout/admin.html' %}
+<!-- Use AdminNotFoundPage component instead -->
+<!-- {% extends '../layout/admin.html' %}
 
 
 {% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('not_found_page.page_not_exist')) }}{% endblock %}
 {% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('not_found_page.page_not_exist')) }}{% endblock %}
 
 
 {% block content_main %}
 {% block content_main %}
 <h1 class="title">{{ t('not_found_page.page_not_exist') }}</h1>
 <h1 class="title">{{ t('not_found_page.page_not_exist') }}</h1>
-{% endblock content_main %}
+{% endblock content_main %} -->

+ 27 - 0
packages/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.ts

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

+ 25 - 8
packages/app/src/services/renderer/rehype-plugins/relative-links.ts

@@ -1,19 +1,40 @@
-import { selectAll, HastNode } from 'hast-util-select';
+import { selectAll, HastNode, Element } from 'hast-util-select';
 import isAbsolute from 'is-absolute-url';
 import isAbsolute from 'is-absolute-url';
 import { Plugin } from 'unified';
 import { Plugin } from 'unified';
 
 
-type RelativeLinksPluginParams = {
+export type IAnchorsSelector = (node: HastNode) => Element[];
+export type IHrefResolver = (relativeHref: string, basePath: string) => string;
+
+const defaultAnchorsSelector: IAnchorsSelector = (node) => {
+  return selectAll('a[href]', node);
+};
+
+const defaultHrefResolver: IHrefResolver = (relativeHref, basePath) => {
+  // generate relative pathname
+  const baseUrl = new URL(basePath, 'https://example.com');
+  const relativeUrl = new URL(relativeHref, baseUrl);
+
+  return relativeUrl.pathname;
+};
+
+
+export type RelativeLinksPluginParams = {
   pagePath?: string,
   pagePath?: string,
+  anchorsSelector?: IAnchorsSelector,
+  hrefResolver?: IHrefResolver,
 }
 }
 
 
 export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
 export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
+  const anchorsSelector = options.anchorsSelector ?? defaultAnchorsSelector;
+  const hrefResolver = options.hrefResolver ?? defaultHrefResolver;
+
   return (tree) => {
   return (tree) => {
     if (options.pagePath == null) {
     if (options.pagePath == null) {
       return;
       return;
     }
     }
 
 
     const pagePath = options.pagePath;
     const pagePath = options.pagePath;
-    const anchors = selectAll('a[href]', tree as HastNode);
+    const anchors = anchorsSelector(tree as HastNode);
 
 
     anchors.forEach((anchor) => {
     anchors.forEach((anchor) => {
       if (anchor.properties == null) {
       if (anchor.properties == null) {
@@ -25,11 +46,7 @@ export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {})
         return;
         return;
       }
       }
 
 
-      // generate relative pathname
-      const baseUrl = new URL(pagePath, 'https://example.com');
-      const relativeUrl = new URL(href, baseUrl);
-
-      anchor.properties.href = relativeUrl.pathname;
+      anchor.properties.href = hrefResolver(href, pagePath);
     });
     });
   };
   };
 };
 };

+ 80 - 0
packages/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.ts

@@ -0,0 +1,80 @@
+import { fromMarkdown, toMarkdown } from 'mdast-util-wiki-link';
+import { syntax } from 'micromark-extension-wiki-link';
+import { Plugin } from 'unified';
+
+
+type FromMarkdownExtension = {
+  enter: {
+    wikiLink: (token: string) => void,
+  },
+  exit: {
+    wikiLinkTarget: (token: string) => void,
+    wikiLinkAlias: (token: string) => void,
+    wikiLink: (token: string) => void,
+  }
+}
+
+type FromMarkdownData = {
+  value: string | null,
+  data: {
+    alias: string | null,
+    hProperties: Record<string, unknown>,
+  }
+}
+
+function swapTargetAndAlias(fromMarkdownExtension: FromMarkdownExtension): FromMarkdownExtension {
+  return {
+    enter: fromMarkdownExtension.enter,
+    exit: {
+      wikiLinkTarget: fromMarkdownExtension.exit.wikiLinkTarget,
+      wikiLinkAlias: fromMarkdownExtension.exit.wikiLinkAlias,
+      wikiLink(token: string) {
+        const wikiLink: FromMarkdownData = this.stack[this.stack.length - 1];
+
+        // swap target and alias
+        //    The default Wiki Link behavior: [[${target}${aliasDivider}${alias}]]
+        //    After swapping:                 [[${alias}${aliasDivider}${target}]]
+        const target = wikiLink.value;
+        const alias = wikiLink.data.alias;
+        if (target != null && alias != null) {
+          wikiLink.value = alias;
+          wikiLink.data.alias = target;
+        }
+
+        // invoke original wikiLink method
+        const orgWikiLink = fromMarkdownExtension.exit.wikiLink.bind(this);
+        orgWikiLink(token);
+      },
+    },
+  };
+}
+
+/**
+ * Implemented with reference to https://github.com/landakram/remark-wiki-link/blob/master/src/index.js
+ */
+export const pukiwikiLikeLinker: Plugin = function() {
+  const data = this.data();
+
+  function add(field: string, value) {
+    if (data[field] != null) {
+      const array = data[field];
+      if (Array.isArray(array)) {
+        array.push(value);
+      }
+    }
+    else {
+      data[field] = [value];
+    }
+  }
+
+  add('micromarkExtensions', syntax({
+    aliasDivider: '>',
+  }));
+  add('fromMarkdownExtensions', swapTargetAndAlias(fromMarkdown({
+    wikiLinkClassName: 'pukiwiki-like-linker',
+    newClassName: ' ',
+    pageResolver: value => [value],
+    hrefTemplate: permalink => permalink,
+  })));
+  add('toMarkdownExtensions', toMarkdown({}));
+};

+ 134 - 42
packages/app/src/services/renderer/renderer.ts → packages/app/src/services/renderer/renderer.tsx

@@ -1,3 +1,12 @@
+// allow only types to import from react
+import { ComponentType } from 'react';
+
+import { Lsx } from '@growi/plugin-lsx/components';
+import * as lsxGrowiPlugin from '@growi/plugin-lsx/services/renderer';
+import growiPlugin from '@growi/remark-growi-plugin';
+import { Schema as SanitizeOption } from 'hast-util-sanitize';
+import { SpecialComponents } from 'react-markdown/lib/ast-to-react';
+import { NormalComponents } from 'react-markdown/lib/complex-types';
 import { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
 import { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
 import katex from 'rehype-katex';
 import katex from 'rehype-katex';
 import raw from 'rehype-raw';
 import raw from 'rehype-raw';
@@ -8,6 +17,9 @@ import breaks from 'remark-breaks';
 import emoji from 'remark-emoji';
 import emoji from 'remark-emoji';
 import gfm from 'remark-gfm';
 import gfm from 'remark-gfm';
 import math from 'remark-math';
 import math from 'remark-math';
+import deepmerge from 'ts-deepmerge';
+import { PluggableList, Pluggable, PluginTuple } from 'unified';
+
 
 
 import { CodeBlock } from '~/components/ReactMarkdownComponents/CodeBlock';
 import { CodeBlock } from '~/components/ReactMarkdownComponents/CodeBlock';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
@@ -17,6 +29,8 @@ import loggerFactory from '~/utils/logger';
 
 
 import { addClass } from './rehype-plugins/add-class';
 import { addClass } from './rehype-plugins/add-class';
 import { relativeLinks } from './rehype-plugins/relative-links';
 import { relativeLinks } from './rehype-plugins/relative-links';
+import { relativeLinksByPukiwikiLikeLinker } from './rehype-plugins/relative-links-by-pukiwiki-like-linker';
+import { pukiwikiLikeLinker } from './remark-plugins/pukiwiki-like-linker';
 
 
 // import CsvToTable from './PreProcessor/CsvToTable';
 // import CsvToTable from './PreProcessor/CsvToTable';
 // import EasyGrid from './PreProcessor/EasyGrid';
 // import EasyGrid from './PreProcessor/EasyGrid';
@@ -212,25 +226,70 @@ const logger = loggerFactory('growi:util:GrowiRenderer');
 
 
 // }
 // }
 
 
-export type RendererOptions = Partial<ReactMarkdownOptions>;
+type SanitizePlugin = PluginTuple<[SanitizeOption]>;
+export type RendererOptions = Omit<ReactMarkdownOptions, 'remarkPlugins' | 'rehypePlugins' | 'components' | 'children'> & {
+  remarkPlugins: PluggableList,
+  rehypePlugins: PluggableList,
+  components?:
+    | Partial<
+        Omit<NormalComponents, keyof SpecialComponents>
+        & SpecialComponents
+        & {
+          [elem: string]: ComponentType<any>,
+        }
+      >
+    | undefined
+};
+
+const commonSanitizeOption: SanitizeOption = deepmerge(
+  sanitizeDefaultSchema,
+  {
+    attributes: {
+      '*': ['class', 'className'],
+    },
+  },
+);
+
+const isSanitizePlugin = (pluggable: Pluggable): pluggable is SanitizePlugin => {
+  if (!Array.isArray(pluggable) || pluggable.length < 2) {
+    return false;
+  }
+  const sanitizeOption = pluggable[1];
+  return 'tagNames' in sanitizeOption && 'attributes' in sanitizeOption;
+};
 
 
+const hasSanitizePluginAtTheLast = (options: RendererOptions): boolean => {
+  const { rehypePlugins } = options;
+  if (rehypePlugins == null || rehypePlugins.length === 0) {
+    return false;
+  }
+
+  // get the last element
+  const lastPluggableElem = rehypePlugins.slice(-1)[0];
+
+  return isSanitizePlugin(lastPluggableElem);
+};
+
+const verifySanitizePlugin = (options: RendererOptions): void => {
+  if (hasSanitizePluginAtTheLast(options)) {
+    return;
+  }
+
+  throw new Error('The specified options does not have sanitize plugin in \'rehypePlugins\'');
+};
 
 
 const generateCommonOptions = (pagePath: string|undefined, config: RendererConfig): RendererOptions => {
 const generateCommonOptions = (pagePath: string|undefined, config: RendererConfig): RendererOptions => {
   return {
   return {
-    remarkPlugins: [gfm],
+    remarkPlugins: [
+      gfm,
+      pukiwikiLikeLinker,
+      growiPlugin,
+    ],
     rehypePlugins: [
     rehypePlugins: [
       slug,
       slug,
+      [relativeLinksByPukiwikiLikeLinker, { pagePath }],
       [relativeLinks, { pagePath }],
       [relativeLinks, { pagePath }],
       raw,
       raw,
-      [sanitize, {
-        ...sanitizeDefaultSchema,
-        attributes: {
-          ...sanitizeDefaultSchema.attributes,
-          '*': sanitizeDefaultSchema.attributes != null
-            ? sanitizeDefaultSchema.attributes['*'].concat('class', 'className')
-            : ['class', 'className'],
-        },
-      }],
       [addClass, {
       [addClass, {
         table: 'table table-bordered',
         table: 'table table-bordered',
       }],
       }],
@@ -245,7 +304,7 @@ const generateCommonOptions = (pagePath: string|undefined, config: RendererConfi
 export const generateViewOptions = (
 export const generateViewOptions = (
     pagePath: string,
     pagePath: string,
     config: RendererConfig,
     config: RendererConfig,
-    storeTocNode: (node: HtmlElementNode) => void,
+    storeTocNode: (toc: HtmlElementNode) => void,
 ): RendererOptions => {
 ): RendererOptions => {
 
 
   const options = generateCommonOptions(pagePath, config);
   const options = generateCommonOptions(pagePath, config);
@@ -253,18 +312,19 @@ export const generateViewOptions = (
   const { remarkPlugins, rehypePlugins, components } = options;
   const { remarkPlugins, rehypePlugins, components } = options;
 
 
   // add remark plugins
   // add remark plugins
-  if (remarkPlugins != null) {
-    remarkPlugins.push(emoji);
-    remarkPlugins.push(math);
-    if (config.isEnabledLinebreaks) {
-      remarkPlugins.push(breaks);
-    }
+  remarkPlugins.push(
+    emoji,
+    math,
+    lsxGrowiPlugin.remarkPlugin,
+  );
+  if (config.isEnabledLinebreaks) {
+    remarkPlugins.push(breaks);
   }
   }
 
 
-  // store toc node
-  if (rehypePlugins != null) {
-    rehypePlugins.push(katex);
-    rehypePlugins.push([toc, {
+  // add rehype plugins
+  rehypePlugins.push(
+    katex,
+    [toc, {
       nav: false,
       nav: false,
       headings: ['h1', 'h2', 'h3'],
       headings: ['h1', 'h2', 'h3'],
       customizeTOC: (toc: HtmlElementNode) => {
       customizeTOC: (toc: HtmlElementNode) => {
@@ -280,20 +340,32 @@ export const generateViewOptions = (
           });
           });
         };
         };
         replacer([toc]); // replace <ol> to <ul>
         replacer([toc]); // replace <ol> to <ul>
-        storeTocNode(toc); // store tocNode to global state with swr
+
+        // For storing tocNode to global state with swr
+        // search: tocRef.current
+        storeTocNode(toc);
+
         return false; // not show toc in body
         return false; // not show toc in body
       },
       },
-    }]);
-  }
-  // renderer.rehypePlugins.push([autoLinkHeadings, {
-  //   behavior: 'append',
-  // }]);
+    }],
+    [lsxGrowiPlugin.rehypePlugin, { pagePath }],
+    // [autoLinkHeadings, {
+    //   behavior: 'append',
+    // }]
+  );
+
+  const sanitizeOption = deepmerge(
+    commonSanitizeOption,
+    lsxGrowiPlugin.sanitizeOption,
+  );
+  rehypePlugins.push([sanitize, sanitizeOption]);
 
 
   // add components
   // add components
   if (components != null) {
   if (components != null) {
     components.h1 = Header;
     components.h1 = Header;
     components.h2 = Header;
     components.h2 = Header;
     components.h3 = Header;
     components.h3 = Header;
+    components.lsx = props => <Lsx {...props} forceToFetchData />;
   }
   }
 
 
   // // Add configurers for viewer
   // // Add configurers for viewer
@@ -308,6 +380,7 @@ export const generateViewOptions = (
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.configure();
   // renderer.configure();
 
 
+  verifySanitizePlugin(options);
   return options;
   return options;
 };
 };
 
 
@@ -318,25 +391,27 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   const { remarkPlugins, rehypePlugins } = options;
   const { remarkPlugins, rehypePlugins } = options;
 
 
   // add remark plugins
   // add remark plugins
-  if (remarkPlugins != null) {
-    remarkPlugins.push(emoji);
-  }
-  // set toc node
-  if (rehypePlugins != null) {
-    rehypePlugins.push([toc, {
+  remarkPlugins.push(emoji);
+
+  // add rehype plugins
+  rehypePlugins.push(
+    [toc, {
       headings: ['h1', 'h2', 'h3'],
       headings: ['h1', 'h2', 'h3'],
       customizeTOC: () => tocNode,
       customizeTOC: () => tocNode,
-    }]);
-  }
+    }],
+    [sanitize, commonSanitizeOption],
+  );
   // renderer.rehypePlugins.push([autoLinkHeadings, {
   // renderer.rehypePlugins.push([autoLinkHeadings, {
   //   behavior: 'append',
   //   behavior: 'append',
   // }]);
   // }]);
 
 
+  verifySanitizePlugin(options);
   return options;
   return options;
 };
 };
 
 
 export const generatePreviewOptions = (config: RendererConfig): RendererOptions => {
 export const generatePreviewOptions = (config: RendererConfig): RendererOptions => {
   const options = generateCommonOptions(undefined, config);
   const options = generateCommonOptions(undefined, config);
+  const { rehypePlugins } = options;
 
 
   // // Add configurers for preview
   // // Add configurers for preview
   // renderer.addConfigurers([
   // renderer.addConfigurers([
@@ -348,19 +423,23 @@ export const generatePreviewOptions = (config: RendererConfig): RendererOptions
   // renderer.setMarkdownSettings({ breaks: rendererSettings?.isEnabledLinebreaks });
   // renderer.setMarkdownSettings({ breaks: rendererSettings?.isEnabledLinebreaks });
   // renderer.configure();
   // renderer.configure();
 
 
+  // add rehype plugins
+  rehypePlugins.push(
+    [sanitize, commonSanitizeOption],
+  );
+
+  verifySanitizePlugin(options);
   return options;
   return options;
 };
 };
 
 
 export const generateCommentPreviewOptions = (config: RendererConfig): RendererOptions => {
 export const generateCommentPreviewOptions = (config: RendererConfig): RendererOptions => {
   const options = generateCommonOptions(undefined, config);
   const options = generateCommonOptions(undefined, config);
-  const { remarkPlugins } = options;
+  const { remarkPlugins, rehypePlugins } = options;
 
 
   // add remark plugins
   // add remark plugins
-  if (remarkPlugins != null) {
-    remarkPlugins.push(emoji);
-    if (config.isEnabledLinebreaksInComments) {
-      remarkPlugins.push(breaks);
-    }
+  remarkPlugins.push(emoji);
+  if (config.isEnabledLinebreaksInComments) {
+    remarkPlugins.push(breaks);
   }
   }
 
 
   // renderer.addConfigurers([
   // renderer.addConfigurers([
@@ -370,11 +449,18 @@ export const generateCommentPreviewOptions = (config: RendererConfig): RendererO
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaksInComments });
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaksInComments });
   // renderer.configure();
   // renderer.configure();
 
 
+  // add rehype plugins
+  rehypePlugins.push(
+    [sanitize, commonSanitizeOption],
+  );
+
+  verifySanitizePlugin(options);
   return options;
   return options;
 };
 };
 
 
 export const generateOthersOptions = (config: RendererConfig): RendererOptions => {
 export const generateOthersOptions = (config: RendererConfig): RendererOptions => {
   const options = generateCommonOptions(undefined, config);
   const options = generateCommonOptions(undefined, config);
+  const { rehypePlugins } = options;
 
 
   // renderer.addConfigurers([
   // renderer.addConfigurers([
   //   new TableConfigurer(),
   //   new TableConfigurer(),
@@ -383,5 +469,11 @@ export const generateOthersOptions = (config: RendererConfig): RendererOptions =
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.configure();
   // renderer.configure();
 
 
+  // add rehype plugins
+  rehypePlugins.push(
+    [sanitize, commonSanitizeOption],
+  );
+
+  verifySanitizePlugin(options);
   return options;
   return options;
 };
 };

+ 6 - 6
packages/app/src/stores/context.tsx

@@ -1,5 +1,3 @@
-import EventEmitter from 'events';
-
 import { HtmlElementNode } from 'rehype-toc';
 import { HtmlElementNode } from 'rehype-toc';
 import { Key, SWRResponse } from 'swr';
 import { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
@@ -232,10 +230,6 @@ export const useRendererConfig = (initialData?: RendererConfig): SWRResponse<Ren
   return useStaticSWR('growiRendererConfig', initialData);
   return useStaticSWR('growiRendererConfig', initialData);
 };
 };
 
 
-export const useCurrentPageTocNode = (): SWRResponse<HtmlElementNode, any> => {
-  return useStaticSWR('currentPageTocNode');
-};
-
 export const useIsBlinkedHeaderAtBoot = (initialData?: boolean): SWRResponse<boolean, Error> => {
 export const useIsBlinkedHeaderAtBoot = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useStaticSWR('isBlinkedAtBoot', initialData);
   return useStaticSWR('isBlinkedAtBoot', initialData);
 };
 };
@@ -278,3 +272,9 @@ export const useIsEditable = (): SWRResponse<boolean, Error> => {
     },
     },
   );
   );
 };
 };
+
+export const useCurrentPageTocNode = (): SWRResponse<HtmlElementNode, any> => {
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  return useStaticSWR(['currentPageTocNode', currentPagePath]);
+};

+ 18 - 7
packages/app/src/stores/renderer.tsx

@@ -1,3 +1,4 @@
+import { HtmlElementNode } from 'rehype-toc';
 import { Key, SWRResponse } from 'swr';
 import { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
@@ -9,7 +10,9 @@ import {
 } from '~/services/renderer/renderer';
 } from '~/services/renderer/renderer';
 
 
 
 
-import { useCurrentPagePath, useCurrentPageTocNode, useRendererConfig } from './context';
+import {
+  useCurrentPagePath, useCurrentPageTocNode, useRendererConfig,
+} from './context';
 
 
 interface ReactMarkdownOptionsGenerator {
 interface ReactMarkdownOptionsGenerator {
   (config: RendererConfig): RendererOptions
   (config: RendererConfig): RendererOptions
@@ -37,10 +40,9 @@ const _useOptionsBase = (
   return useSWRImmutable<RendererOptions, Error>(key);
   return useSWRImmutable<RendererOptions, Error>(key);
 };
 };
 
 
-export const useViewOptions = (): SWRResponse<RendererOptions, Error> => {
+export const useViewOptions = (storeTocNodeHandler: (toc: HtmlElementNode) => void): SWRResponse<RendererOptions, Error> => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: rendererConfig } = useRendererConfig();
   const { data: rendererConfig } = useRendererConfig();
-  const { mutate: storeTocNode } = useCurrentPageTocNode();
 
 
   const isAllDataValid = currentPagePath != null && rendererConfig != null;
   const isAllDataValid = currentPagePath != null && rendererConfig != null;
 
 
@@ -50,16 +52,25 @@ export const useViewOptions = (): SWRResponse<RendererOptions, Error> => {
 
 
   return useSWRImmutable<RendererOptions, Error>(
   return useSWRImmutable<RendererOptions, Error>(
     key,
     key,
-    (rendererId, currentPagePath, rendererConfig) => generateViewOptions(currentPagePath, rendererConfig, storeTocNode),
+    (rendererId, currentPagePath, rendererConfig) => generateViewOptions(currentPagePath, rendererConfig, storeTocNodeHandler),
   );
   );
 };
 };
 
 
 export const useTocOptions = (): SWRResponse<RendererOptions, Error> => {
 export const useTocOptions = (): SWRResponse<RendererOptions, Error> => {
-  const key = 'tocOptions';
-
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: rendererConfig } = useRendererConfig();
   const { data: tocNode } = useCurrentPageTocNode();
   const { data: tocNode } = useCurrentPageTocNode();
 
 
-  return _useOptionsBase(key, config => generateTocOptions(config, tocNode));
+  const isAllDataValid = rendererConfig != null;
+
+  const key = isAllDataValid
+    ? ['tocOptions', currentPagePath, tocNode, rendererConfig]
+    : null;
+
+  return useSWRImmutable<RendererOptions, Error>(
+    key,
+    (rendererId, path, tocNode, rendererConfig) => generateTocOptions(rendererConfig, tocNode),
+  );
 };
 };
 
 
 export const usePreviewOptions = (): SWRResponse<RendererOptions, Error> => {
 export const usePreviewOptions = (): SWRResponse<RendererOptions, Error> => {

+ 4 - 3
packages/app/src/stores/user-group.tsx

@@ -6,7 +6,8 @@ import { apiv3Get } from '~/client/util/apiv3-client';
 import { IPageHasId } from '~/interfaces/page';
 import { IPageHasId } from '~/interfaces/page';
 import { IUserGroupHasId, IUserGroupRelationHasId } from '~/interfaces/user';
 import { IUserGroupHasId, IUserGroupRelationHasId } from '~/interfaces/user';
 import {
 import {
-  UserGroupResult, UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult,
+  IUserGroupRelationHasIdPopulatedUser,
+  UserGroupResult, UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult, UserGroupRelationsResult,
   UserGroupPagesResult, SelectableParentUserGroupsResult, SelectableUserChildGroupsResult, AncestorUserGroupsResult,
   UserGroupPagesResult, SelectableParentUserGroupsResult, SelectableUserChildGroupsResult, AncestorUserGroupsResult,
 } from '~/interfaces/user-group-response';
 } from '~/interfaces/user-group-response';
 
 
@@ -51,10 +52,10 @@ export const useSWRxChildUserGroupList = (
   );
   );
 };
 };
 
 
-export const useSWRxUserGroupRelations = (groupId: string): SWRResponse<IUserGroupRelationHasId[], Error> => {
+export const useSWRxUserGroupRelations = (groupId: string): SWRResponse<IUserGroupRelationHasIdPopulatedUser[], Error> => {
   return useSWRImmutable(
   return useSWRImmutable(
     groupId != null ? [`/user-groups/${groupId}/user-group-relations`] : null,
     groupId != null ? [`/user-groups/${groupId}/user-group-relations`] : null,
-    endpoint => apiv3Get<UserGroupRelationListResult>(endpoint).then(result => result.data.userGroupRelations),
+    endpoint => apiv3Get<UserGroupRelationsResult>(endpoint).then(result => result.data.userGroupRelations),
   );
   );
 };
 };
 
 

+ 1 - 1
packages/app/src/styles/style-next.scss

@@ -47,7 +47,7 @@
 // @import 'page-content-footer';
 // @import 'page-content-footer';
 // @import 'handsontable';
 // @import 'handsontable';
 @import 'layout';
 @import 'layout';
-@import 'login';
+// @import 'login';
 // @import 'me';
 // @import 'me';
 // @import 'mirror_mode';
 // @import 'mirror_mode';
 @import 'modal';
 @import 'modal';

+ 2 - 2
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -141,7 +141,7 @@ ul.pagination {
     linear-gradient(225deg, darken(var.$growi-blue, 20%) 10%, hsla(140, 90%, 50%, 0) 80%),
     linear-gradient(225deg, darken(var.$growi-blue, 20%) 10%, hsla(140, 90%, 50%, 0) 80%),
     linear-gradient(315deg, darken($color-gradient, 25%) 100%, hsla(35, 95%, 55%, 0) 70%);
     linear-gradient(315deg, darken($color-gradient, 25%) 100%, hsla(35, 95%, 55%, 0) 70%);
 
 
-  .login-header {
+  .noLogin-header {
     background-color: rgba(black, 0.5);
     background-color: rgba(black, 0.5);
 
 
     .logo {
     .logo {
@@ -154,7 +154,7 @@ ul.pagination {
     }
     }
   }
   }
 
 
-  .login-dialog {
+  .noLogin-dialog {
     background-color: rgba(black, 0.5);
     background-color: rgba(black, 0.5);
   }
   }
 
 

+ 2 - 2
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -84,7 +84,7 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
     linear-gradient(135deg, var.$growi-green 10%, hsla(225, 95%, 50%, 0) 70%), linear-gradient(225deg, var.$growi-blue 10%, hsla(140, 90%, 50%, 0) 80%),
     linear-gradient(135deg, var.$growi-green 10%, hsla(225, 95%, 50%, 0) 70%), linear-gradient(225deg, var.$growi-blue 10%, hsla(140, 90%, 50%, 0) 80%),
     linear-gradient(315deg, darken($color-gradient, 25%) 100%, hsla(35, 95%, 55%, 0) 70%);
     linear-gradient(315deg, darken($color-gradient, 25%) 100%, hsla(35, 95%, 55%, 0) 70%);
 
 
-  .login-header {
+  .noLogin-header {
     background-color: rgba(white, 0.5);
     background-color: rgba(white, 0.5);
 
 
     .logo {
     .logo {
@@ -97,7 +97,7 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
     }
     }
   }
   }
 
 
-  .login-dialog {
+  .noLogin-dialog {
     background-color: rgba(white, 0.5);
     background-color: rgba(white, 0.5);
   }
   }
 
 

+ 5 - 2
packages/app/src/utils/download.ts

@@ -1,9 +1,12 @@
 import path from 'path';
 import path from 'path';
+import { Readable, Writable, Transform } from 'stream';
+
+// eslint-disable-next-line no-restricted-imports
+import axios from 'axios';
 import fs from 'graceful-fs';
 import fs from 'graceful-fs';
 import mkdirp from 'mkdirp';
 import mkdirp from 'mkdirp';
 import streamToPromise from 'stream-to-promise';
 import streamToPromise from 'stream-to-promise';
-import { Readable, Writable, Transform } from 'stream';
-import axios from '~/utils/axios';
+
 
 
 export async function downloadTo(url: string, outDir: string, fileName: string, transform: Transform|null = null): Promise<void> {
 export async function downloadTo(url: string, outDir: string, fileName: string, transform: Transform|null = null): Promise<void> {
   // get
   // get

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

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

+ 1 - 0
packages/app/tsconfig.build.client.json

@@ -8,6 +8,7 @@
     "paths": {
     "paths": {
       "~/*": ["./src/*"],
       "~/*": ["./src/*"],
       "^/*": ["./*"],
       "^/*": ["./*"],
+      "@growi/plugin-lsx/*": ["../plugin-lsx/src/*"],
       "@growi/*": ["../*/src"],
       "@growi/*": ["../*/src"],
       "debug": ["./src/server/utils/logger/alias-for-debug"]
       "debug": ["./src/server/utils/logger/alias-for-debug"]
     }
     }

+ 10 - 0
packages/app/tsconfig.build.server-tsc-alias.json

@@ -0,0 +1,10 @@
+{
+  "extends": "./tsconfig.build.server.json",
+  "compilerOptions": {
+    "paths": {
+      "~/*": ["./src/*"],
+      "^/*": ["./*"],
+      "debug": ["./src/utils/logger/alias-for-debug"]
+    }
+  }
+}

+ 2 - 1
packages/app/tsconfig.build.server.json

@@ -11,6 +11,7 @@
     "paths": {
     "paths": {
       "~/*": ["./src/*"],
       "~/*": ["./src/*"],
       "^/*": ["./*"],
       "^/*": ["./*"],
+      "@growi/plugin-lsx/*": ["../plugin-lsx/dist/cjs/*"],
       "debug": ["./src/utils/logger/alias-for-debug"]
       "debug": ["./src/utils/logger/alias-for-debug"]
     }
     }
   },
   },
@@ -21,6 +22,6 @@
     "src/linter-checker",
     "src/linter-checker",
     "src/stores",
     "src/stores",
     "src/styles",
     "src/styles",
-    "src/styles-hackmd",
+    "src/styles-hackmd"
   ]
   ]
 }
 }

+ 1 - 0
packages/app/tsconfig.json

@@ -5,6 +5,7 @@
     "paths": {
     "paths": {
       "~/*": ["./src/*"],
       "~/*": ["./src/*"],
       "^/*": ["./*"],
       "^/*": ["./*"],
+      "@growi/plugin-lsx/*": ["../plugin-lsx/src/*"],
       "@growi/*": ["../*/src"],
       "@growi/*": ["../*/src"],
       "debug": ["./src/server/utils/logger/alias-for-debug"]
       "debug": ["./src/server/utils/logger/alias-for-debug"]
     }
     }

+ 2 - 3
packages/core/src/index.ts

@@ -1,11 +1,10 @@
-import * as _customTagUtils from './plugin/util/custom-tag-utils';
 import * as _envUtils from './utils/env-utils';
 import * as _envUtils from './utils/env-utils';
 
 
 // export utils by *.js
 // export utils by *.js
 export const envUtils = _envUtils;
 export const envUtils = _envUtils;
-export const customTagUtils = _customTagUtils;
 
 
 // export utils with namespace
 // export utils with namespace
+export * as customTagUtils from './plugin/util/custom-tag-utils';
 export * as templateChecker from './utils/template-checker';
 export * as templateChecker from './utils/template-checker';
 export * as objectIdUtils from './utils/objectid-utils';
 export * as objectIdUtils from './utils/objectid-utils';
 export * as pagePathUtils from './utils/page-path-utils';
 export * as pagePathUtils from './utils/page-path-utils';
@@ -13,6 +12,7 @@ export * as pathUtils from './utils/path-utils';
 export * as pageUtils from './utils/page-utils';
 export * as pageUtils from './utils/page-utils';
 
 
 // export all
 // export all
+export * from './plugin/interfaces/option-parser';
 export * from './interfaces/attachment';
 export * from './interfaces/attachment';
 export * from './interfaces/common';
 export * from './interfaces/common';
 export * from './interfaces/has-object-id';
 export * from './interfaces/has-object-id';
@@ -22,7 +22,6 @@ export * from './interfaces/revision';
 export * from './interfaces/subscription';
 export * from './interfaces/subscription';
 export * from './interfaces/tag';
 export * from './interfaces/tag';
 export * from './interfaces/user';
 export * from './interfaces/user';
-export * from './plugin/interfaces/plugin-definition-v4';
 export * from './plugin/service/tag-cache-manager';
 export * from './plugin/service/tag-cache-manager';
 export * from './models/devided-page-path';
 export * from './models/devided-page-path';
 export * from './service/localstorage-manager';
 export * from './service/localstorage-manager';

+ 2 - 0
packages/core/src/interfaces/user.ts

@@ -17,6 +17,8 @@ export type IUser = {
   isEmailPublished: boolean,
   isEmailPublished: boolean,
   lang: Lang,
   lang: Lang,
   slackMemberId?: string,
   slackMemberId?: string,
+  createdAt: Date,
+  lastLoginAt?: Date,
 }
 }
 
 
 export type IUserGroupRelation = {
 export type IUserGroupRelation = {

+ 4 - 0
packages/core/src/plugin/interfaces/option-parser.ts

@@ -0,0 +1,4 @@
+export type ParseRangeResult = {
+  start: number,
+  end: number,
+}

+ 0 - 11
packages/core/src/plugin/interfaces/plugin-definition-v4.ts

@@ -1,11 +0,0 @@
-export type PluginMetaV4 = {
-  pluginSchemaVersion: number,
-  serverEntries: string[],
-  clientEntries: string[],
-};
-
-export type PluginDefinitionV4 = {
-  name: string,
-  meta: PluginMetaV4,
-  entries: string[],
-};

Неке датотеке нису приказане због велике количине промена