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

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 лет назад
Родитель
Сommit
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
       - packages/app/**
       - '!packages/app/docker/**'
+      - packages/codemirror-textlint/**
       - packages/core/**
+      - packages/remark-growi-plugin/**
       - packages/slack/**
       - packages/ui/**
       - packages/plugin-**
@@ -30,7 +32,9 @@ on:
       - yarn.lock
       - packages/app/**
       - '!packages/app/docker/**'
+      - packages/codemirror-textlint/**
       - packages/core/**
+      - packages/remark-growi-plugin/**
       - packages/slack/**
       - packages/ui/**
       - packages/plugin-**

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

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

+ 0 - 4
.vscode/launch.json

@@ -76,10 +76,6 @@
             "url": "webpack://_n_e/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",
             "path": "${workspaceFolder}/packages/plugin-lsx"

+ 4 - 2
package.json

@@ -48,10 +48,12 @@
     "cross-env": "^7.0.0",
     "dotenv-flow": "^3.2.0",
     "npm-run-all": "^4.1.5",
+    "ts-deepmerge": "^3.0.0",
     "tslib": "^2.3.1"
   },
   "devDependencies": {
     "@testing-library/cypress": "^8.0.2",
+    "@types/css-modules": "^1.0.2",
     "@types/jest": "^26.0.22",
     "@types/node": "^17.0.43",
     "@types/rewire": "^2.5.28",
@@ -66,7 +68,7 @@
     "eslint-plugin-jest": "^26.5.3",
     "eslint-plugin-react": "^7.30.1",
     "eslint-plugin-react-hooks": "^4.6.0",
-    "jest": "^27.0.6",
+    "jest": "^28.1.3",
     "jest-date-mock": "^1.0.8",
     "jest-localstorage-mock": "^2.4.14",
     "lerna": "^4.0.0",
@@ -81,7 +83,7 @@
     "shipjs": "^0.24.1",
     "stylelint": "^14.2.0",
     "stylelint-config-recess-order": "^3.0.0",
-    "ts-jest": "^27.0.4",
+    "ts-jest": "^28.0.7",
     "ts-node": "^10.9.1",
     "tsconfig-paths": "^3.9.0",
     "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 AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
 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 ContextExtractor from '~/client/services/ContextExtractor';
 import loggerFactory from '~/utils/logger';
@@ -67,7 +67,7 @@ const adminExternalAccountsContainer = new AdminExternalAccountsContainer(appCon
 const adminNotificationContainer = new AdminNotificationContainer(appContainer);
 const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer(appContainer);
 const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
-const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appContainer);
+// const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appContainer);
 const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 const injectableContainers = [
   appContainer,
@@ -81,7 +81,7 @@ const injectableContainers = [
   adminNotificationContainer,
   adminSlackIntegrationLegacyContainer,
   adminMarkDownContainer,
-  adminUserGroupDetailContainer,
+  // adminUserGroupDetailContainer,
   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/plugin-attachment-refs packages/plugin-attachment-refs
 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/ui packages/ui
 

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

@@ -5,7 +5,8 @@
 const MODULE_NAME_MAPPING = {
   '^\\^/(.+)$': '<rootDir>/$1',
   '^~/(.+)$': '<rootDir>/src/$1',
-  '^@growi/(.+)$': '<rootDir>/../$1/src',
+  '^@growi/([^/]+)$': '<rootDir>/../$1/src',
+  '^@growi/([^/]+)/(.+)$': '<rootDir>/../$1/src/$2',
 };
 
 module.exports = {
@@ -20,6 +21,11 @@ module.exports = {
 
       preset: 'ts-jest/presets/js-with-ts',
 
+      // transform ESM to CJS
+      transformIgnorePatterns: [
+        '/node_modules/(?!remark-gfm)/',
+      ],
+
       rootDir: '.',
       roots: ['<rootDir>'],
       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
       clearMocks: true,
       moduleNameMapper: MODULE_NAME_MAPPING,
+
     },
     {
       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
     'react-markdown',
     'unified',
+    'character-entities-html4',
     'comma-separated-tokens',
     'decode-named-character-reference',
     'hastscript',
@@ -33,7 +34,9 @@ const setupTranspileModules = () => {
     'longest-streak',
     'property-information',
     'space-separated-tokens',
+    'stringify-entities',
     'trim-lines',
+    'trough',
     'web-namespaces',
     'vfile',
     'zwitch',

+ 3 - 3
packages/app/package.json

@@ -8,8 +8,8 @@
     "start": "yarn next start",
     "build:client": "yarn next build",
     "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",
     "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",
@@ -67,7 +67,6 @@
     "@growi/core": "^5.1.3-RC.0",
     "@growi/plugin-attachment-refs": "^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",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
@@ -169,6 +168,7 @@
     "remark-emoji": "^3.0.2",
     "remark-gfm": "^3.0.1",
     "remark-math": "^5.1.1",
+    "remark-wiki-link": "^1.0.4",
     "rimraf": "^3.0.0",
     "socket.io": "^4.2.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
 
-(available by [weseek/growi-plugin-pukiwiki-like-linker
-](https://github.com/weseek/growi-plugin-pukiwiki-like-linker) )
-
 This is the most flexible linker.
 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
 
-(available by [weseek/growi-plugin-pukiwiki-like-linker
-](https://github.com/weseek/growi-plugin-pukiwiki-like-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
 
-(available by [weseek/growi-plugin-pukiwiki-like-linker
-](https://github.com/weseek/growi-plugin-pukiwiki-like-linker) )
-
 This is the most flexible linker.
 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 { Container } from 'unstated';
 
-import loggerFactory from '~/utils/logger';
-
 import {
   apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
 } 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)

+ 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 LdapAuthTest from './LdapAuthTest';
+import { LdapAuthTest } from './LdapAuthTest';
 
 
 class LdapAuthTestModal extends React.Component {

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

@@ -40,7 +40,7 @@ const actionForPages = {
   transfer: 'transfer',
 };
 
-const UserGroupDeleteModal: FC<Props> = (props: Props) => {
+export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
 
   const { t } = useTranslation();
 
@@ -209,5 +209,3 @@ const UserGroupDeleteModal: FC<Props> = (props: Props) => {
     </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 { useTranslation } from 'next-i18next';
 
 import { IUserGroupHasId } from '~/interfaces/user';
@@ -9,7 +10,7 @@ type Props = {
   onClickCreateUserGroupButton?(): void
 };
 
-const UserGroupDropdown: FC<Props> = (props: Props) => {
+export const UserGroupDropdown: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
 
   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
 };
 
-const UserGroupForm: FC<Props> = (props: Props) => {
+export const UserGroupForm: FC<Props> = (props: Props) => {
 
   const { t } = useTranslation();
 
@@ -152,5 +152,3 @@ const UserGroupForm: FC<Props> = (props: Props) => {
     </form>
   );
 };
-
-export default UserGroupForm;

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

@@ -19,7 +19,7 @@ type Props = {
   onHide?: () => Promise<void> | void
 };
 
-const UserGroupModal: FC<Props> = (props: Props) => {
+export const UserGroupModal: FC<Props> = (props: Props) => {
 
   const { t } = useTranslation();
 
@@ -116,5 +116,3 @@ const UserGroupModal: FC<Props> = (props: Props) => {
     </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 dynamic from 'next/dynamic';
 import { useTranslation } from 'react-i18next';
 
-
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Delete, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import { useIsAclEnabled } from '~/stores/context';
 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 { data: isAclEnabled } = useIsAclEnabled();
@@ -193,5 +193,3 @@ const UserGroupPage: FC = () => {
     </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();
 
   /*
@@ -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 { useTranslation } from 'next-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
@@ -7,7 +8,7 @@ import {
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
 
 
-const UpdateParentConfirmModal: FC = () => {
+export const UpdateParentConfirmModal: FC = () => {
   const { t } = useTranslation();
 
   const [isForceUpdate, setForceUpdate] = useState(false);
@@ -88,5 +89,3 @@ const UpdateParentConfirmModal: FC = () => {
     </Modal>
   );
 };
-
-export default UpdateParentConfirmModal;

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

@@ -1,56 +1,77 @@
 import React, {
-  FC, useState, useCallback,
+  useState, useCallback, useEffect, useMemo,
 } 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 {
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
 } 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 {
-  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList,
+  useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxUserGroup,
   useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups,
 } 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 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 [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
   const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
   const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
   const [isUpdateModalShown, setUpdateModalShown] = 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
    */
-  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 grandChildUserGroups = childUserGroupsList != null ? childUserGroupsList.grandChildUserGroups : [];
   const childUserGroupIds = childUserGroups.map(group => group._id);
@@ -58,10 +79,10 @@ const UserGroupDetailPage: FC = () => {
   const { data: userGroupRelationList, mutate: mutateUserGroupRelations } = useSWRxUserGroupRelationList(childUserGroupIds);
   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();
 
@@ -70,17 +91,15 @@ const UserGroupDetailPage: FC = () => {
   /*
    * Function
    */
-  // TODO 85062: old name: switchIsAlsoMailSearched
   const toggleIsAlsoMailSearched = useCallback(() => {
     setAlsoMailSearched(prev => !prev);
   }, []);
 
-  // TODO 85062: old name: switchIsAlsoNameSearched
   const toggleAlsoNameSearched = useCallback(() => {
     setAlsoNameSearched(prev => !prev);
   }, []);
 
-  const switchSearchType = useCallback((searchType) => {
+  const switchSearchType = useCallback((searchType: SearchType) => {
     setSearchType(searchType);
   }, []);
 
@@ -98,13 +117,11 @@ const UserGroupDetailPage: FC = () => {
     });
     const { userGroup: updatedUserGroup } = res.data;
 
-    setUserGroup(updatedUserGroup);
-
     // mutate
     mutateAncestorUserGroups();
     mutateSelectableChildUserGroups();
     mutateSelectableParentUserGroups();
-  }, [setUserGroup, mutateAncestorUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups]);
+  }, [mutateAncestorUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups]);
 
   const onSubmitUpdateGroup = useCallback(
     async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>, forceUpdateParents: boolean): Promise<void> => {
@@ -142,8 +159,8 @@ const UserGroupDetailPage: FC = () => {
     }
   }, [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,
       searchType,
       isAlsoMailSearched,
@@ -153,18 +170,25 @@ const UserGroupDetailPage: FC = () => {
     const { users } = res.data;
 
     return users;
-  }, [searchType, isAlsoMailSearched, isAlsoNameSearched]);
+  }, [currentUserGroupId, searchType, isAlsoMailSearched, isAlsoNameSearched]);
 
-  // TODO 85062: will be used in UserGroupUserFormByInput
   const addUserByUsername = useCallback(async(username: string) => {
-    await apiv3Post(`/user-groups/${currentUserGroup._id}/users/${username}`);
+    await apiv3Post(`/user-groups/${currentUserGroupId}/users/${username}`);
+    setIsUserGroupUserModalShown(false);
     mutateUserGroupRelations();
-  }, [currentUserGroup, mutateUserGroupRelations]);
+  }, [currentUserGroupId, mutateUserGroupRelations]);
 
+  // Fix: invalid csrf token => https://redmine.weseek.co.jp/issues/102704
   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) => {
     setUpdateModalShown(true);
@@ -201,11 +225,11 @@ const UserGroupDetailPage: FC = () => {
     await openUpdateParentConfirmModal(
       selectedChild,
       {
-        parent: currentUserGroup._id,
+        parent: currentUserGroupId,
       },
       onSubmitUpdateGroup,
     );
-  }, [openUpdateParentConfirmModal, onSubmitUpdateGroup, currentUserGroup]);
+  }, [openUpdateParentConfirmModal, currentUserGroupId, onSubmitUpdateGroup]);
 
   const showCreateModal = useCallback(() => {
     setCreateModalShown(true);
@@ -220,7 +244,7 @@ const UserGroupDetailPage: FC = () => {
       await apiv3Post('/user-groups', {
         name: userGroupData.name,
         description: userGroupData.description,
-        parentId: currentUserGroup._id,
+        parentId: currentUserGroupId,
       });
 
       toastSuccess(t('toaster.update_successed', { target: t('UserGroup') }));
@@ -235,7 +259,7 @@ const UserGroupDetailPage: FC = () => {
     catch (err) {
       toastError(err);
     }
-  }, [t, currentUserGroup, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, hideCreateModal]);
+  }, [currentUserGroupId, t, mutateChildUserGroups, mutateSelectableChildUserGroups, mutateSelectableParentUserGroups, hideCreateModal]);
 
   const showDeleteModal = useCallback(async(group: IUserGroupHasId) => {
     setSelectedUserGroup(group);
@@ -290,7 +314,7 @@ const UserGroupDetailPage: FC = () => {
   /*
    * Dependencies
    */
-  if (currentUserGroup == null) {
+  if (currentUserGroup == null || currentUserGroupId == null) {
     return <></>;
   }
 
@@ -303,8 +327,8 @@ const UserGroupDetailPage: FC = () => {
             ancestorUserGroups != null && ancestorUserGroups.length > 0 && (
               ancestorUserGroups.map((ancestorUserGroup: IUserGroupHasId) => (
                 // 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}</>
                   ) : (
                     <a href={`/admin/user-group-detail/${ancestorUserGroup._id}`}>{ancestorUserGroup.name}</a>
@@ -325,8 +349,25 @@ const UserGroupDetailPage: FC = () => {
         />
       </div>
       <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>
       <UserGroupDropdown
@@ -372,7 +413,7 @@ const UserGroupDetailPage: FC = () => {
 
       <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
       <div className="page-list">
-        <UserGroupPageList />
+        <UserGroupPageList userGroupId={currentUserGroupId} relatedPages={userGroupPages} />
       </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 { UserPicture } from '@growi/ui';
+import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
-import { useTranslation } from 'next-i18next';
 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 Xss from '~/services/xss';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
 class UserGroupUserFormByInput extends React.Component {
 
   constructor(props) {
@@ -39,16 +35,13 @@ class UserGroupUserFormByInput extends React.Component {
   }
 
   async addUserBySubmit() {
-    const { adminUserGroupDetailContainer } = this.props;
-    const { userGroup } = adminUserGroupDetailContainer.state;
+    const { userGroup, onClickAddUserBtn } = this.props;
 
     if (this.state.inputUser.length === 0) { return }
     const userName = this.state.inputUser[0].username;
 
     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)}"`);
       this.setState({ inputUser: '' });
     }
@@ -64,10 +57,10 @@ class UserGroupUserFormByInput extends React.Component {
   }
 
   async searhApplicableUsers() {
-    const { adminUserGroupDetailContainer } = this.props;
+    const { onSearchApplicableUsers } = this.props;
 
     try {
-      const users = await adminUserGroupDetailContainer.fetchApplicableUsers(this.state.keyword);
+      const users = await onSearchApplicableUsers(this.state.keyword);
       this.setState({ applicableUsers: users, isLoading: false });
     }
     catch (err) {
@@ -84,7 +77,6 @@ class UserGroupUserFormByInput extends React.Component {
   }
 
   handleSearch(keyword) {
-
     if (keyword === '') {
       return;
     }
@@ -101,15 +93,15 @@ class UserGroupUserFormByInput extends React.Component {
   }
 
   renderMenuItemChildren(option) {
-    const { adminUserGroupDetailContainer } = this.props;
+    const { isAlsoNameSearched, isAlsoMailSearched } = this.props;
     const user = option;
     return (
-      <React.Fragment>
+      <>
         <UserPicture user={user} size="sm" noLink noTooltip />
         <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 = {
   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) => {
@@ -171,9 +166,4 @@ const UserGroupUserFormByInputWrapperFC = (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>;
 
     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="col-md-12">
             <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';
 
+const AdminNotFoundPage = dynamic(() => import('../Admin/NotFoundPage').then(mod => mod.AdminNotFoundPage), { ssr: false });
+
 
 type Props = {
   title: string
@@ -33,7 +35,7 @@ const AdminLayout = ({
       <div className={`admin-page ${styles['admin-page']}`}>
         <GrowiNavbar />
 
-        <header className="py-0">
+        <header className="py-0 position-relative">
           <h1 className="title">{title}</h1>
         </header>
         <div id="main" className="main">
@@ -43,7 +45,7 @@ const AdminLayout = ({
                 <AdminNavigation selected={selectedNavOpt} />
               </div>
               <div className="col-lg-9">
-                {children}
+                {children || <AdminNotFoundPage />}
               </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 {
     background: none;
   }
@@ -24,7 +24,7 @@
           margin-left: 20px;
         }
 
-        .login-header {
+        .noLogin-header {
           display: flex;
           flex-direction: column;
           align-items: center;
@@ -32,7 +32,7 @@
           padding-bottom: 10px;
         }
 
-        .login-form-errors {
+        .noLogin-form-errors {
           width: 100%;
 
           .alert {
@@ -56,7 +56,7 @@
   // #wrapper
 
   // styles
-  .login-header {
+  .noLogin-header {
     h1 {
       font-size: 22px;
       line-height: 1em;
@@ -89,46 +89,42 @@
     }
   }
 
-  .collapse-external-auth {
-    overflow: hidden;
-  }
-
   $btn-fill-colors: (
     'login': (
-      rgba(bs.$danger, 0.4),
+      rgba($danger, 0.4),
       rgba(#7e4153, 0.7),
     ),
     'register': (
-      rgba(bs.$success, 0.4),
+      rgba($success, 0.4),
       rgba(#3f7263, 0.7),
     ),
     'google': (
       rgba(#24292e, 0.4),
-      bs.$gray-700,
+      $gray-700,
     ),
     'github': (
       rgba(lighten(black, 20%), 0.4),
-      bs.$gray-700,
+      $gray-700,
     ),
     'facebook': (
       rgba(#29487d, 0.4),
-      bs.$gray-700,
+      $gray-700,
     ),
     'twitter': (
       rgba(#1da1f2, 0.4),
-      bs.$gray-700,
+      $gray-700,
     ),
     'oidc': (
       rgba(#24292e, 0.4),
-      bs.$gray-700,
+      $gray-700,
     ),
     'saml': (
       rgba(#55a79a, 0.4),
-      bs.$gray-700,
+      $gray-700,
     ),
     'basic': (
       rgba(#24292e, 0.4),
-      bs.$gray-700,
+      $gray-700,
     ),
   );
 
@@ -154,57 +150,20 @@
       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 {
   .alert h2 {
     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 Image from 'next/image';
 
 import { useGrowiTheme } from '~/stores/context';
 import { Themes, useNextThemes } from '~/stores/use-next-themes';
 
+import { getBackgroundImageSrc } from '../Theme/utils/ThemeImageProvider';
 import { ThemeProvider } from '../Theme/utils/ThemeProvider';
 
 type Props = {
-  title: string,
+  title?: string,
   className?: string,
   children?: ReactNode,
 }
@@ -25,12 +29,19 @@ export const RawLayout = ({ children, title, className }: Props): JSX.Element =>
   const { resolvedTheme } = useNextThemes();
 
   const [colorScheme, setColorScheme] = useState<Themes|undefined>(undefined);
+  const [backgroundImageSrc, setBackgroundImageSrc] = useState<string | undefined>(undefined);
 
   // set colorScheme in CSR
   useEffect(() => {
     setColorScheme(resolvedTheme as Themes);
   }, [resolvedTheme]);
 
+  // set background image
+  useEffect(() => {
+    const imgSrc = getBackgroundImageSrc(growiTheme, colorScheme);
+    setBackgroundImageSrc(imgSrc);
+  }, [growiTheme, colorScheme]);
+
   return (
     <>
       <Head>
@@ -40,6 +51,9 @@ export const RawLayout = ({ children, title, className }: Props): JSX.Element =>
       </Head>
       <ThemeProvider theme={growiTheme}>
         <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}
         </div>
       </ThemeProvider>

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

@@ -291,7 +291,7 @@ class LoginForm extends React.Component {
     const isSomeExternalAuthEnabled = true;
 
     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="col-12">
             <ReactCardFlip isFlipped={this.state.isRegistering} flipDirection="horizontal" cardZIndex="3">

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

@@ -6,12 +6,16 @@ import {
   ModalHeader,
   ModalBody,
   ModalFooter,
+  Nav,
+  NavLink,
+  TabContent,
+  TabPane,
 } from 'reactstrap';
 
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { usePersonalSettings, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
 
-import LdapAuthTest from '../Admin/Security/LdapAuthTest';
+import { LdapAuthTest } from '../Admin/Security/LdapAuthTest';
 
 type Props = {
   isOpen: boolean,
@@ -22,6 +26,7 @@ const AssociateModal = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { mutate: mutatePersonalExternalAccounts } = useSWRxPersonalExternalAccounts();
   const { associateLdapAccount } = usePersonalSettings();
+  const [activeTab, setActiveTab] = useState(1);
   const { isOpen, onClose } = props;
 
   const [username, setUsername] = useState('');
@@ -55,46 +60,61 @@ const AssociateModal = (props: Props): JSX.Element => {
         { t('admin:user_management.create_external_account') }
       </ModalHeader>
       <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
-            </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
-            </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
-            </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
-            </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
-            </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>
       </ModalBody>
       <ModalFooter className="border-top-0">

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

@@ -1,9 +1,10 @@
 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 { localeMetadatas } from '~/client/util/i18n';
 import { useRegistrationWhiteList } from '~/stores/context';
 import { usePersonalSettings } from '~/stores/personal-settings';
 
@@ -104,23 +105,27 @@ export const BasicInfoSettings = (): JSX.Element => {
       <div className="form-group row">
         <label className="text-left text-md-right col-md-3 col-form-label">{t('Language')}</label>
         <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 className="form-group row">

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

@@ -1,15 +1,15 @@
 import React, {
+  useCallback,
   useEffect, useRef, useState,
 } from 'react';
 
 import dynamic from 'next/dynamic';
-import PropTypes from 'prop-types';
 // import { debounce } from 'throttle-debounce';
 
 import { blinkSectionHeaderAtBoot } from '~/client/util/blink-section-header';
 // import { getOptionsToSave } from '~/client/util/editor';
 import {
-  useIsGuestUser, useIsBlinkedHeaderAtBoot,
+  useIsGuestUser, useIsBlinkedHeaderAtBoot, useCurrentPageTocNode,
 } from '~/stores/context';
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
@@ -22,6 +22,7 @@ import {
 import loggerFactory from '~/utils/logger';
 
 import RevisionRenderer from './Page/RevisionRenderer';
+import { HtmlElementNode } from 'rehype-toc';
 
 // TODO: import dynamically
 // import MarkdownTable from '~/client/models/MarkdownTable';
@@ -30,9 +31,28 @@ import RevisionRenderer from './Page/RevisionRenderer';
 
 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);
 
     this.state = {
@@ -138,7 +158,7 @@ class PageSubstance extends React.Component {
     // }
   }
 
-  render() {
+  override render() {
     const {
       rendererOptions, page, isMobile, isGuestUser,
     } = 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: editorMode } = useEditorMode();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPage?.path);
   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 { data: isBlinkedAtBoot, mutate: mutateBlinkedAtBoot } = useIsBlinkedHeaderAtBoot();
+  const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
 
   const pageRef = useRef(null);
 
@@ -206,6 +223,11 @@ export const Page = (props) => {
     mutateBlinkedAtBoot(true);
   }, [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
   // useEffect(() => {
   //   const handler = (beginLineNumber, endLineNumber) => {
@@ -253,7 +275,7 @@ export const Page = (props) => {
       isMobile={isMobile}
       isSlackEnabled={isSlackEnabled}
       pageTags={pageTags}
-      slackChannels={slackChannelsData.toString()}
+      slackChannels={slackChannelsData?.toString()}
       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 { useCurrentPathname, useInterceptorManager } from '~/stores/context';
 import { useEditorSettings } from '~/stores/editor';
-import { useViewOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
 
 // import RevisionBody from './RevisionBody';
@@ -95,7 +94,7 @@ type Props = {
   additionalClassName?: string,
 }
 
-const RevisionRenderer = (props: Props): JSX.Element => {
+const RevisionRenderer = React.memo((props: Props): JSX.Element => {
 
   const {
     rendererOptions, markdown, pagePath, highlightKeywords, additionalClassName,
@@ -246,6 +245,7 @@ const RevisionRenderer = (props: Props): JSX.Element => {
   //   />
   // );
 
-};
+});
+RevisionRenderer.displayName = '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,
   href?: string,
   className?: string,
-} ;
+};
 
 export const NextLink = ({
   href, children, className, ...props

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

@@ -16,7 +16,6 @@
 
 .growi:not(.login-page) {
   // add background-image
-  #page-wrapper,
   .page-editor-preview-container {
     background-image: url('/images/themes/antarctic/bg.svg');
     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;
 $themelight: #f0f8ff;
 $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 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 => {
   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) {
   // add background-image
-  #page-wrapper,
   .page-editor-preview-container {
     background-image: url('/images/themes/christmas/christmas.jpg');
     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
 //
 .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);
     }
     .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 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 => {
   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: #;
 
 .growi:not(.login-page) {
-  #wrapper > .navbar {
-    background-image: url(/images/themes/halloween/halloween-navbar.jpg);
-  }
-
   // add background-image
-  #page-wrapper,
   .page-editor-preview-container {
     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
 //
 .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 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 => {
   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;
 // }
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+
+  .grw-bg-image {
+    object-fit: cover;
+    object-position: bottom;
+  }
+}
+
 //== Light Mode
 //
 .theme[data-color-scheme='light'] :global {
@@ -107,7 +120,6 @@
 
   .growi:not(.login-page) {
     // add background-image
-    #page-wrapper,
     .page-editor-preview-container {
       background-image: url('/images/themes/hufflepuff/badger-light3.png');
       background-attachment: fixed;
@@ -126,8 +138,8 @@
       background-size: cover;
     }
 
-    .login-header,
-    .login-dialog {
+    .noLogin-header,
+    .noLogin-dialog {
       background-color: rgba(black, 0.1);
     }
 
@@ -275,7 +287,6 @@
 
   .growi:not(.login-page) {
     // add background-image
-    #page-wrapper,
     .page-editor-preview-container {
       background-image: url('/images/themes/hufflepuff/badger-dark.jpg');
       background-attachment: fixed;
@@ -294,8 +305,8 @@
       background-size: cover;
     }
 
-    .login-header,
-    .login-dialog {
+    .noLogin-header,
+    .noLogin-dialog {
       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 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 => {
   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-themelight: rgba(183, 226, 219, 1);
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+}
+
 .theme :global {
   $primary: $color-primary;
   // Background colors
@@ -89,7 +97,6 @@ $color-themelight: rgba(183, 226, 219, 1);
     background: lighten($color-themelight, 5%);
   }
 
-  #wrapper > #page-wrapper,
   .page-editor-preview-container {
     background-image: url('/images/themes/island/island.png');
     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 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 => {
   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;
 }
 
+.theme :global {
+  .grw-bg-image-wrapper {
+    position: fixed;
+    width: 100%;
+    height: 100%;
+  }
+
+  .grw-bg-image {
+    object-fit: cover;
+    object-position: bottom;
+  }
+}
+
 //== Light Mode
 //
 .theme :global {
@@ -107,7 +120,6 @@ $accentcolor: #e08dbc;
 
   .growi:not(.login-page) {
     // add background-image
-    #page-wrapper,
     .page-editor-preview-container {
       background-image: url('/images/themes/spring/spring02.svg');
       background-attachment: fixed;
@@ -126,8 +138,8 @@ $accentcolor: #e08dbc;
       background-size: cover;
     }
 
-    .login-header,
-    .login-dialog {
+    .noLogin-header,
+    .noLogin-dialog {
       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 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 => {
   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) {
   // add background-image
-  #page-wrapper,
   .page-editor-preview-container {
     background-image: url('/images/themes/wood/wood.jpg');
     background-attachment: fixed;
@@ -35,6 +34,19 @@
 $themecolor: #b9b177;
 $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
 //
 .theme :global {
@@ -147,8 +159,8 @@ $themelight: #f5f3ee;
   .nologin {
     background: white;
 
-    .login-header,
-    .login-dialog {
+    .noLogin-header,
+    .noLogin-dialog {
       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 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 => {
   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 {
+  IUser, IUserGroup, IUserGroupHasId, IUserGroupRelationHasId,
+} from './user';
 
 export type UserGroupResult = {
   userGroup: IUserGroupHasId,
@@ -18,6 +22,16 @@ export type UserGroupRelationListResult = {
   userGroupRelations: IUserGroupRelationHasId[],
 };
 
+export type IUserGroupRelationHasIdPopulatedUser = {
+  relatedGroup: Ref<IUserGroup>,
+  relatedUser: IUser & HasObjectId,
+  createdAt: Date,
+} & HasObjectId;
+
+export type UserGroupRelationsResult = {
+  userGroupRelations: IUserGroupRelationHasIdPopulatedUser[],
+};
+
 export type UserGroupPagesResult = {
   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>
       {/* <BasicLayout title={useCustomTitle(props, t('GROWI'))} className={classNames.join(' ')}> */}
       <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} />
         </header>
         <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 {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
@@ -27,7 +27,6 @@ import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityConta
 import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { SupportedActionType } from '~/interfaces/activity';
 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 UserManagement = dynamic(() => import('../../components/Admin/UserManagement'), { 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 });
-// 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 });
+// 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();
 
@@ -92,6 +90,17 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
   const { path } = router.query;
   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 = {
     home: {
       title: useCustomTitle(props, t('Wiki Management Home Page')),
@@ -154,6 +163,12 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
       title: useCustomTitle(props, t('UserGroup Management')),
       component: <UserGroupPage />,
     },
+    'user-group-detail': {
+      [userGroupId]: {
+        title: t('UserGroup Management'),
+        component: <UserGroupDetailPage userGroupId={userGroupId} />,
+      },
+    },
     search: {
       title: useCustomTitle(props, t('Full Text Search Management')),
       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 pagesMap[key];
     }, 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);
-  useIsMailerSetup(props.isMailerSetup);
+  // useIsMailerSetup(props.isMailerSetup);
 
   // useSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
@@ -200,7 +214,6 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
     const adminNotificationContainer = new AdminNotificationContainer();
     const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer();
     const adminMarkDownContainer = new AdminMarkDownContainer();
-    const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer();
 
     injectableContainers.push(
       adminAppContainer,
@@ -212,7 +225,6 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
       adminNotificationContainer,
       adminSlackIntegrationLegacyContainer,
       adminMarkDownContainer,
-      adminUserGroupDetailContainer,
     );
   }
 
@@ -246,13 +258,12 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
         adminTwitterSecurityContainer,
       );
     }
-
   }
 
 
   return (
     <Provider inject={[...injectableContainers, ...adminSecurityContainers]}>
-      <AdminLayout title={title} selectedNavOpt={pagePathKeys[0]}>
+      <AdminLayout title={targetPage.title} selectedNavOpt={firstPath}>
         {targetPage.component}
       </AdminLayout>
     </Provider>

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

@@ -6,7 +6,7 @@ import {
 } from 'next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 
-import { RawLayout } from '~/components/Layout/RawLayout';
+import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
 
 import InstallerForm from '../components/InstallerForm';
 import {
@@ -46,36 +46,13 @@ const InstallerPage: NextPage<Props> = (props: Props) => {
   const classNames: string[] = [];
 
   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>
-      </RawLayout>
-    </>
+      </div>
+    </NoLoginLayout>
   );
 };
 

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

@@ -1,13 +1,12 @@
 import React from 'react';
 
 
-import { pagePathUtils } from '@growi/core';
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 import dynamic from 'next/dynamic';
 
-import { RawLayout } from '~/components/Layout/RawLayout';
+import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 
 import {
@@ -36,25 +35,19 @@ const LoginPage: NextPage<Props> = (props: Props) => {
   // page
   useCurrentPathname(props.currentPathname);
 
-  const classNames: string[] = [];
+  const classNames: string[] = ['login-page'];
 
   const LoginForm = dynamic(() => import('~/components/LoginForm'), {
     ssr: false,
   });
 
   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) => {
   const req = context.req as CrowiRequest<IUserHasId & any>;
-  const { user } = req;
+  const { user, crowi } = req;
 
   const result = await getServerSideCommonProps(context);
 
@@ -153,7 +153,9 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   const props: Props = result.props as Props;
 
   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);

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

@@ -3,6 +3,7 @@ import http from 'http';
 import path from 'path';
 
 import { createTerminus } from '@godaddy/terminus';
+import lsxRoutes from '@growi/plugin-lsx/server/routes';
 import mongoose from 'mongoose';
 import next from 'next';
 
@@ -33,7 +34,6 @@ import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '../util/m
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
 const models = require('../models');
-const PluginService = require('../plugins/plugin.service');
 
 const sep = path.sep;
 
@@ -434,10 +434,6 @@ Crowi.prototype.start = async function() {
 
   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 httpServer = http.createServer(app);
@@ -465,6 +461,7 @@ Crowi.prototype.start = async function() {
   }
 
   // setup Express Routes
+  this.setupRoutesForPlugins();
   this.setupRoutesAtLast();
 
   // setup Global Error Handlers
@@ -515,6 +512,10 @@ Crowi.prototype.setupTerminus = function(server) {
   });
 };
 
+Crowi.prototype.setupRoutesForPlugins = function() {
+  lsxRoutes(this, this.express);
+};
+
 /**
  * setup Express Routes
  * !! 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 { resolveFromRoot } from '~/utils/project-dir-utils';
 
-import { PluginUtilsV4 } from './plugin-utils-v4';
+// import { PluginUtilsV4 } from './plugin-utils-v4';
 
 const fs = require('graceful-fs');
 
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
-const pluginUtilsV4 = new PluginUtilsV4();
-
 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
    *  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());
   };
 
-  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;
 };

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

@@ -765,7 +765,6 @@ module.exports = (crowi) => {
     try {
       const userGroup = await UserGroup.findById(id);
       const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
-
       return res.apiv3({ userGroupRelations });
     }
     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 {
   Request, Response, NextFunction,
 } from 'express';
 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 { projectRoot } from '~/utils/project-dir-utils';
+
 import { convertStreamToBuffer } from '../util/stream';
 
 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';
 
+// eslint-disable-next-line no-restricted-imports
+import axios from 'axios';
 import ReconnectingWebSocket from 'reconnecting-websocket';
 import WebSocket from 'ws';
 
-import axios from '~/utils/axios';
 import loggerFactory from '~/utils/logger';
 
 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 content_main %}
 <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 { 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,
+  anchorsSelector?: IAnchorsSelector,
+  hrefResolver?: IHrefResolver,
 }
 
 export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
+  const anchorsSelector = options.anchorsSelector ?? defaultAnchorsSelector;
+  const hrefResolver = options.hrefResolver ?? defaultHrefResolver;
+
   return (tree) => {
     if (options.pagePath == null) {
       return;
     }
 
     const pagePath = options.pagePath;
-    const anchors = selectAll('a[href]', tree as HastNode);
+    const anchors = anchorsSelector(tree as HastNode);
 
     anchors.forEach((anchor) => {
       if (anchor.properties == null) {
@@ -25,11 +46,7 @@ export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {})
         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 katex from 'rehype-katex';
 import raw from 'rehype-raw';
@@ -8,6 +17,9 @@ import breaks from 'remark-breaks';
 import emoji from 'remark-emoji';
 import gfm from 'remark-gfm';
 import math from 'remark-math';
+import deepmerge from 'ts-deepmerge';
+import { PluggableList, Pluggable, PluginTuple } from 'unified';
+
 
 import { CodeBlock } from '~/components/ReactMarkdownComponents/CodeBlock';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
@@ -17,6 +29,8 @@ import loggerFactory from '~/utils/logger';
 
 import { addClass } from './rehype-plugins/add-class';
 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 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 => {
   return {
-    remarkPlugins: [gfm],
+    remarkPlugins: [
+      gfm,
+      pukiwikiLikeLinker,
+      growiPlugin,
+    ],
     rehypePlugins: [
       slug,
+      [relativeLinksByPukiwikiLikeLinker, { pagePath }],
       [relativeLinks, { pagePath }],
       raw,
-      [sanitize, {
-        ...sanitizeDefaultSchema,
-        attributes: {
-          ...sanitizeDefaultSchema.attributes,
-          '*': sanitizeDefaultSchema.attributes != null
-            ? sanitizeDefaultSchema.attributes['*'].concat('class', 'className')
-            : ['class', 'className'],
-        },
-      }],
       [addClass, {
         table: 'table table-bordered',
       }],
@@ -245,7 +304,7 @@ const generateCommonOptions = (pagePath: string|undefined, config: RendererConfi
 export const generateViewOptions = (
     pagePath: string,
     config: RendererConfig,
-    storeTocNode: (node: HtmlElementNode) => void,
+    storeTocNode: (toc: HtmlElementNode) => void,
 ): RendererOptions => {
 
   const options = generateCommonOptions(pagePath, config);
@@ -253,18 +312,19 @@ export const generateViewOptions = (
   const { remarkPlugins, rehypePlugins, components } = options;
 
   // 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,
       headings: ['h1', 'h2', 'h3'],
       customizeTOC: (toc: HtmlElementNode) => {
@@ -280,20 +340,32 @@ export const generateViewOptions = (
           });
         };
         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
       },
-    }]);
-  }
-  // renderer.rehypePlugins.push([autoLinkHeadings, {
-  //   behavior: 'append',
-  // }]);
+    }],
+    [lsxGrowiPlugin.rehypePlugin, { pagePath }],
+    // [autoLinkHeadings, {
+    //   behavior: 'append',
+    // }]
+  );
+
+  const sanitizeOption = deepmerge(
+    commonSanitizeOption,
+    lsxGrowiPlugin.sanitizeOption,
+  );
+  rehypePlugins.push([sanitize, sanitizeOption]);
 
   // add components
   if (components != null) {
     components.h1 = Header;
     components.h2 = Header;
     components.h3 = Header;
+    components.lsx = props => <Lsx {...props} forceToFetchData />;
   }
 
   // // Add configurers for viewer
@@ -308,6 +380,7 @@ export const generateViewOptions = (
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.configure();
 
+  verifySanitizePlugin(options);
   return options;
 };
 
@@ -318,25 +391,27 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   const { remarkPlugins, rehypePlugins } = options;
 
   // 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'],
       customizeTOC: () => tocNode,
-    }]);
-  }
+    }],
+    [sanitize, commonSanitizeOption],
+  );
   // renderer.rehypePlugins.push([autoLinkHeadings, {
   //   behavior: 'append',
   // }]);
 
+  verifySanitizePlugin(options);
   return options;
 };
 
 export const generatePreviewOptions = (config: RendererConfig): RendererOptions => {
   const options = generateCommonOptions(undefined, config);
+  const { rehypePlugins } = options;
 
   // // Add configurers for preview
   // renderer.addConfigurers([
@@ -348,19 +423,23 @@ export const generatePreviewOptions = (config: RendererConfig): RendererOptions
   // renderer.setMarkdownSettings({ breaks: rendererSettings?.isEnabledLinebreaks });
   // renderer.configure();
 
+  // add rehype plugins
+  rehypePlugins.push(
+    [sanitize, commonSanitizeOption],
+  );
+
+  verifySanitizePlugin(options);
   return options;
 };
 
 export const generateCommentPreviewOptions = (config: RendererConfig): RendererOptions => {
   const options = generateCommonOptions(undefined, config);
-  const { remarkPlugins } = options;
+  const { remarkPlugins, rehypePlugins } = options;
 
   // 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([
@@ -370,11 +449,18 @@ export const generateCommentPreviewOptions = (config: RendererConfig): RendererO
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaksInComments });
   // renderer.configure();
 
+  // add rehype plugins
+  rehypePlugins.push(
+    [sanitize, commonSanitizeOption],
+  );
+
+  verifySanitizePlugin(options);
   return options;
 };
 
 export const generateOthersOptions = (config: RendererConfig): RendererOptions => {
   const options = generateCommonOptions(undefined, config);
+  const { rehypePlugins } = options;
 
   // renderer.addConfigurers([
   //   new TableConfigurer(),
@@ -383,5 +469,11 @@ export const generateOthersOptions = (config: RendererConfig): RendererOptions =
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.configure();
 
+  // add rehype plugins
+  rehypePlugins.push(
+    [sanitize, commonSanitizeOption],
+  );
+
+  verifySanitizePlugin(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 { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
@@ -232,10 +230,6 @@ export const useRendererConfig = (initialData?: RendererConfig): SWRResponse<Ren
   return useStaticSWR('growiRendererConfig', initialData);
 };
 
-export const useCurrentPageTocNode = (): SWRResponse<HtmlElementNode, any> => {
-  return useStaticSWR('currentPageTocNode');
-};
-
 export const useIsBlinkedHeaderAtBoot = (initialData?: boolean): SWRResponse<boolean, Error> => {
   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 useSWRImmutable from 'swr/immutable';
 
@@ -9,7 +10,9 @@ import {
 } from '~/services/renderer/renderer';
 
 
-import { useCurrentPagePath, useCurrentPageTocNode, useRendererConfig } from './context';
+import {
+  useCurrentPagePath, useCurrentPageTocNode, useRendererConfig,
+} from './context';
 
 interface ReactMarkdownOptionsGenerator {
   (config: RendererConfig): RendererOptions
@@ -37,10 +40,9 @@ const _useOptionsBase = (
   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: rendererConfig } = useRendererConfig();
-  const { mutate: storeTocNode } = useCurrentPageTocNode();
 
   const isAllDataValid = currentPagePath != null && rendererConfig != null;
 
@@ -50,16 +52,25 @@ export const useViewOptions = (): SWRResponse<RendererOptions, Error> => {
 
   return useSWRImmutable<RendererOptions, Error>(
     key,
-    (rendererId, currentPagePath, rendererConfig) => generateViewOptions(currentPagePath, rendererConfig, storeTocNode),
+    (rendererId, currentPagePath, rendererConfig) => generateViewOptions(currentPagePath, rendererConfig, storeTocNodeHandler),
   );
 };
 
 export const useTocOptions = (): SWRResponse<RendererOptions, Error> => {
-  const key = 'tocOptions';
-
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: rendererConfig } = useRendererConfig();
   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> => {

+ 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 { IUserGroupHasId, IUserGroupRelationHasId } from '~/interfaces/user';
 import {
-  UserGroupResult, UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult,
+  IUserGroupRelationHasIdPopulatedUser,
+  UserGroupResult, UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult, UserGroupRelationsResult,
   UserGroupPagesResult, SelectableParentUserGroupsResult, SelectableUserChildGroupsResult, AncestorUserGroupsResult,
 } 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(
     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 'handsontable';
 @import 'layout';
-@import 'login';
+// @import 'login';
 // @import 'me';
 // @import 'mirror_mode';
 @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(315deg, darken($color-gradient, 25%) 100%, hsla(35, 95%, 55%, 0) 70%);
 
-  .login-header {
+  .noLogin-header {
     background-color: rgba(black, 0.5);
 
     .logo {
@@ -154,7 +154,7 @@ ul.pagination {
     }
   }
 
-  .login-dialog {
+  .noLogin-dialog {
     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(315deg, darken($color-gradient, 25%) 100%, hsla(35, 95%, 55%, 0) 70%);
 
-  .login-header {
+  .noLogin-header {
     background-color: rgba(white, 0.5);
 
     .logo {
@@ -97,7 +97,7 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
     }
   }
 
-  .login-dialog {
+  .noLogin-dialog {
     background-color: rgba(white, 0.5);
   }
 

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

@@ -1,9 +1,12 @@
 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 mkdirp from 'mkdirp';
 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> {
   // 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": {
       "~/*": ["./src/*"],
       "^/*": ["./*"],
+      "@growi/plugin-lsx/*": ["../plugin-lsx/src/*"],
       "@growi/*": ["../*/src"],
       "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": {
       "~/*": ["./src/*"],
       "^/*": ["./*"],
+      "@growi/plugin-lsx/*": ["../plugin-lsx/dist/cjs/*"],
       "debug": ["./src/utils/logger/alias-for-debug"]
     }
   },
@@ -21,6 +22,6 @@
     "src/linter-checker",
     "src/stores",
     "src/styles",
-    "src/styles-hackmd",
+    "src/styles-hackmd"
   ]
 }

+ 1 - 0
packages/app/tsconfig.json

@@ -5,6 +5,7 @@
     "paths": {
       "~/*": ["./src/*"],
       "^/*": ["./*"],
+      "@growi/plugin-lsx/*": ["../plugin-lsx/src/*"],
       "@growi/*": ["../*/src"],
       "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';
 
 // export utils by *.js
 export const envUtils = _envUtils;
-export const customTagUtils = _customTagUtils;
 
 // export utils with namespace
+export * as customTagUtils from './plugin/util/custom-tag-utils';
 export * as templateChecker from './utils/template-checker';
 export * as objectIdUtils from './utils/objectid-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 all
+export * from './plugin/interfaces/option-parser';
 export * from './interfaces/attachment';
 export * from './interfaces/common';
 export * from './interfaces/has-object-id';
@@ -22,7 +22,6 @@ export * from './interfaces/revision';
 export * from './interfaces/subscription';
 export * from './interfaces/tag';
 export * from './interfaces/user';
-export * from './plugin/interfaces/plugin-definition-v4';
 export * from './plugin/service/tag-cache-manager';
 export * from './models/devided-page-path';
 export * from './service/localstorage-manager';

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

@@ -17,6 +17,8 @@ export type IUser = {
   isEmailPublished: boolean,
   lang: Lang,
   slackMemberId?: string,
+  createdAt: Date,
+  lastLoginAt?: Date,
 }
 
 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[],
-};

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